WireGuard

Linux 커널 WireGuard VPN을 1차 자료 기준으로 다시 정리합니다. Noise_IKpsk2 핸드셰이크, AllowedIPs 기반 Cryptokey Routing, per-CPU 암복호화 큐와 per-peer NAPI/GRO, wg/wg-quick·fwmark·namespace 운용, MTU·NAT·로밍·제한사항·디버깅 절차까지 실제 운영 관점에서 설명합니다.

전제 조건: 네트워크 스택, 라우팅, Linux Crypto Framework (Crypto API) 문서를 먼저 읽으세요. WireGuard는 단순히 “VPN 프로그램”이 아니라, L3 인터페이스·라우팅·UDP 소켓·암호 키 합의가 한 장치 안에 결합된 구조라서 기본 네트워크 경로를 이해한 뒤 보는 편이 훨씬 정확합니다.
일상 비유: 이 개념은 사원증과 전용 출입구와 비슷합니다. 상대 공개키는 “누구인가”를 확인하는 사원증이고, AllowedIPs는 “어느 문으로 들어오고 나갈 수 있는가”를 정하는 출입 규칙입니다. WireGuard는 이 둘을 분리하지 않고 한 시스템으로 묶습니다.

핵심 요약

  • Peer — 공개키로 식별되는 원격 상대입니다.
  • AllowedIPs — 송신 시 목적지 선택, 수신 시 소스 검증을 동시에 담당하는 규칙입니다.
  • Endpoint — 실제 암호문 UDP 패킷을 주고받는 IP:포트입니다.
  • Handshake — 세션 키를 만드는 1-RTT 교환이며, 첫 데이터 패킷이 키 확인 역할도 합니다.
  • PersistentKeepalive — NAT나 상태 기반 방화벽 매핑을 유지하려고 보내는 주기적 빈 인증 패킷입니다.

단계별 이해

  1. 공개키와 주소 대역을 짝지어 생각하기
    먼저 “이 피어는 어떤 IP 대역을 대표하는가”를 정합니다.
  2. 터널과 경로를 분리해서 보기
    wg는 장치 상태를 바꾸고, wg-quick은 주소·라우트·DNS·훅을 추가합니다.
  3. 핸드셰이크와 데이터 경로를 구분하기
    핸드셰이크 실패인지, 핸드셰이크는 됐지만 AllowedIPs/MTU가 틀린 것인지 따로 봐야 합니다.
  4. 문제는 카운터로 확인하기
    wg show의 latest handshake, endpoint, transfer가 가장 먼저 볼 지표입니다.
사실 관계: WireGuard는 Linux 5.6에 메인라인 통합된 L3 VPN 인터페이스입니다. 2026년 3월 7일 기준 최신 stable 커널은 6.19.6이며, 상류 소스 트리에서도 drivers/net/wireguard/ 경로가 유지됩니다. 같은 날짜 기준으로 WireGuard의 공식 기준 문서는 IETF 표준 RFC가 아니라 WireGuard 공식 프로토콜 문서와 기술 백서입니다.

WireGuard가 다른 VPN과 다른 점

WireGuard는 “복잡한 협상형 VPN”보다 “작은 커널 네트워크 장치”에 가깝습니다. 인터페이스 이름은 wg0, wg1처럼 보이지만 내부적으로는 공개키, AllowedIPs 프리픽스 트라이, UDP 소켓, 세션 키, 타이머, 작업 큐를 묶어 둔 L3 전용 네트워크 장치입니다. 데이터 평면은 커널 안에 있고, 제어 평면은 Generic Netlink로 설정됩니다.

공식 Quick Start 문서가 강조하듯 WireGuard는 유휴 상태에서는 최대한 조용하게 동작합니다. 평문 IP 패킷을 보낼 이유가 없으면 핸드셰이크도, keepalive도 필요할 때만 발생합니다. 이 점은 “항상 시끄러운 터널”을 기대하는 운영자에게는 낯설 수 있지만, WireGuard를 제대로 이해하려면 가장 먼저 잡아야 하는 특징입니다.

항목WireGuardIPSec / xfrmOpenVPN
데이터 평면 위치 커널 L3 netdevice 커널 xfrm / 정책 엔진 주로 유저스페이스 tun/tap
핵심 키 합의 Noise_IKpsk2 기반 1-RTT 대개 IKEv2 TLS 기반
암호 알고리즘 협상 없음, 고정 스위트 있음 있음
정책 모델 공개키 + AllowedIPs SPD/SAD와 터널 정책 서버/클라이언트 옵션과 TLS 상태
로밍 인증된 패킷의 최신 소스 주소로 자동 갱신 구현과 확장(MOBIKE)에 따라 다름 재연결 또는 상위 로직 필요
NAT 친화성 단일 UDP 플로우, Keepalive 지원 ESP / UDP 캡슐화 조합에 따라 달라짐 TCP/UDP 선택 가능하지만 유저스페이스 비용 존재
L2 브리징 기본 제공 안 함, IP 전용 추가 오버레이 필요 TAP 모드 가능
운영 표면 wg, wg-quick, Netlink ip xfrm, IKE 데몬, PKI 프로세스/서비스 설정 파일
제어 평면 wg / wg-quick / systemd-networkd → Generic Netlink 평문 IP 패킷 소켓, 라우팅, 로컬 프로세스 목적지 기준으로 wg0 선택 wg0 / WireGuard device AllowedIPs LPM peer / keypair GSO 분할 / 대기열 per-CPU 암복호화 수신 시 source AllowedIPs 검증 후 per-peer NAPI/GRO로 스택 복귀 UDP 소켓 birth namespace에 고정 endpoint, fwmark, roaming 원격 peer 암호문 UDP만 관찰

암호 스위트와 키 재료

WireGuard는 암호 민첩성(ciphersuite agility)을 의도적으로 포기합니다. 이유는 단순합니다. 협상 단계가 있으면 구현 복잡도와 다운그레이드 면적이 같이 늘어나기 때문입니다. 그래서 WireGuard는 고정된 현대적 스위트를 사용하고, 그 위에 공개키 기반 인증과 선택적 사전공유키(PSK)를 얹습니다.

용도알고리즘운영상 의미
ECDH Curve25519 정적 공개키와 임시 공개키를 이용해 세션 비밀을 만듭니다.
대칭 암호 + 인증 ChaCha20-Poly1305 데이터 패킷을 AEAD로 보호합니다. 소프트웨어 구현 성능과 이식성이 좋습니다.
해시 / KDF BLAKE2s, HKDF-BLAKE2s 핸드셰이크 체인 키와 세션 키를 유도합니다.
해시테이블 키 SipHash24 내부 해시테이블 키로 사용됩니다. 데이터 패킷 인증과는 역할이 다릅니다.
선택적 추가 비밀 PresharedKey wg(8) 기준 선택 사항이며, 기존 공개키 교환 위에 추가 대칭 비밀을 섞어 후양자 저항성 관점의 여지를 더합니다.
왜 AES-GCM 협상이 없는가: WireGuard의 목표는 “모든 환경에서 가장 빠른 알고리즘 찾기”가 아니라 “작고 감사 가능한 구현 + 예측 가능한 보안 속성”입니다. 일부 CPU에서는 AES 계열이 더 빠를 수 있어도, WireGuard는 전체 설계 표면을 줄이는 쪽을 선택합니다.

Noise 핸드셰이크와 패킷 형식

WireGuard 공식 프로토콜 문서는 Noise_IK를 기반으로 하고, 구성 이름으로는 Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s를 사용합니다. 여기서 핵심은 다음 세 가지입니다.

커널 소스의 패킷 구조를 기준으로 보면, 핸드셰이크 initiation은 148바이트, response는 92바이트, cookie reply는 64바이트입니다. 데이터 패킷은 message_data 헤더 16바이트와 AEAD 태그 16바이트 때문에 평문 대비 최소 32바이트가 늘어나고, 여기에 바깥쪽 UDP/IP 헤더까지 더하면 일반적인 MTU 계산에서 IPv4는 60바이트, IPv6는 80바이트 여유를 잡는 것이 안전합니다.

Initiator Responder Handshake Initiation (148B) ephemeral, encrypted static, encrypted timestamp, mac1, mac2 Handshake Response (92B) ephemeral, encrypted_nothing, mac1, mac2 첫 data 패킷 세션 키 사용 시작 + 키 확인 서버가 과부하 상태면 cookie reply (64B) 가능 mac1은 항상 확인, mac2는 부하 시 쿠키 검증에 사용
struct message_handshake_initiation {
    struct message_header header;
    __le32 sender_index;
    u8 unencrypted_ephemeral[NOISE_PUBLIC_KEY_LEN];
    u8 encrypted_static[noise_encrypted_len(NOISE_PUBLIC_KEY_LEN)];
    u8 encrypted_timestamp[noise_encrypted_len(NOISE_TIMESTAMP_LEN)];
    struct message_macs macs;
};

struct message_handshake_response {
    struct message_header header;
    __le32 sender_index;
    __le32 receiver_index;
    u8 unencrypted_ephemeral[NOISE_PUBLIC_KEY_LEN];
    u8 encrypted_nothing[noise_encrypted_len(0)];
    struct message_macs macs;
};

struct message_data {
    struct message_header header;
    __le32 key_idx;
    __le64 counter;
    u8 encrypted_data[];
};

여기서 특히 중요한 점은 데이터 패킷 헤더가 receiver_index가 아니라 key_idx를 사용한다는 점입니다. 수신자는 이 인덱스로 현재/이전 세션 키 쌍을 빠르게 찾고, 그다음 nonce 윈도우와 인증 태그를 검증합니다. 운영자가 “peer 식별자”처럼 이해하면 내부 동작을 잘못 추적하게 됩니다.

쿠키 기반 DoS 완화

WireGuard는 CPU를 많이 쓰는 DH 계산을 남발당하지 않도록 cookie 기반 DoS 완화를 둡니다. 모든 핸드셰이크 메시지는 먼저 mac1을 통과해야 하고, 서버가 과부하 상태이면 추가로 mac2까지 요구할 수 있습니다. 이때 서버는 즉시 키 교환을 진행하는 대신 cookie reply 패킷을 돌려주고, 다음 요청에서 올바른 cookie가 실린 mac2가 확인될 때만 본격적인 핸드셰이크를 처리합니다.

공식 프로토콜 설명 기준으로 cookie는 2분마다 바뀌는 서버 비밀과 송신자 IP 주소를 조합해 만듭니다. 즉, 소스 주소를 위조한 공격자는 cookie를 되돌려 받을 수 없고, 서버는 “실제로 그 IP를 가진 상대만” 다음 단계로 올릴 수 있습니다. 그래서 WireGuard는 “무조건 응답”이 아니라 “유효한 패킷에만 최소한으로 응답”하는 방향을 취합니다.

AllowedIPs: 라우팅이면서 ACL인 핵심 규칙

AllowedIPs는 WireGuard를 이해하는 데 가장 중요한 개념입니다. 이것은 단순한 “허용 목록”이 아닙니다. wg(8) 문서 그대로, 들어오는 트래픽에 대해 이 피어로부터 허용할 소스 프리픽스이면서 동시에 나가는 트래픽을 어느 피어로 보낼지 결정하는 목적지 프리픽스입니다.

커널 내부에서는 AllowedIPs를 프리픽스 트라이 형태로 유지하고, 송신 시에는 목적지 주소에 대해 최장 프리픽스 매칭(longest prefix match)을 수행합니다. 수신 시에는 복호화 후 내부 IP 헤더의 소스 주소가 실제 그 피어에 허용된 범위인지 다시 확인합니다. 즉, 라우팅 선택과 소스 검증이 한 자료구조로 연결됩니다.

송신 판단 dst 10.0.0.42 → peer A dst 10.0.0.0/24보다 10.0.0.42/32가 더 구체적 수신 판단 peer A가 복호화한 패킷의 src가 10.0.0.42/32면 통과 다른 소스면 폐기 AllowedIPs 트라이 10.0.0.42/32 → peer A 10.0.0.0/24 → peer B 192.168.50.0/24 → peer C 송신: 목적지로 검색 수신: 복호화 후 소스로 검증 핵심: 공개키와 프리픽스가 함께 정책을 형성 peer A AllowedIPs: 10.0.0.42/32 가장 구체적인 경로 peer B AllowedIPs: 10.0.0.0/24 더 넓은 집계 경로
예시 설정의미주의점
10.0.0.2/32 개별 호스트 하나를 해당 피어로 보냅니다. 서버-클라이언트 1:1 매핑에서 가장 흔합니다.
10.10.0.0/16 서브넷 전체를 그 피어 뒤에 있다고 가정합니다. 사이트 간 터널에서 유용하지만 경로 충돌을 주의해야 합니다.
0.0.0.0/0, ::/0 기본 경로를 터널로 보내는 전체 터널(full tunnel)입니다. endpoint 재귀 라우팅을 피하려면 fwmark나 별도 예외 경로가 필요합니다.
운영 규칙: AllowedIPs는 “접속 허가 목록”이자 “라우팅 규칙”이므로, 서로 다른 피어에 겹치는 대역을 줄 때는 최장 프리픽스가 누구를 가리키는지를 의도적으로 설계해야 합니다. 같은 대역을 여러 피어에 무심코 나눠 넣으면, 문제는 암호화가 아니라 경로 선택에서 먼저 터집니다.

커널 내부 구조

WireGuard 장치는 일반 네트워크 장치처럼 등록되지만, Ethernet 장치가 아닙니다. 상류 커널의 wg_setup()는 장치 타입을 ARPHRD_NONE으로 두고, 플래그에 IFF_POINTOPOINT | IFF_NOARP를 사용합니다. 즉, ARP를 하는 L2 장치가 아니라 IP 패킷을 바로 받아서 암호화하는 L3 장치입니다.

또한 device.c를 보면 송신 엔트리 포인트는 .ndo_start_xmit = wg_xmit이고, 그 안에서 GSO skb는 먼저 분할한 뒤 peer별 staged queue에 넣습니다. 수신 쪽은 data 메시지를 복호화 워커가 처리하고, 검증이 끝난 평문 skb를 napi_gro_receive(&peer->napi, skb)로 네트워크 스택에 되돌립니다. 따라서 “WireGuard는 NAPI를 안 쓴다”도 틀리고, “하드웨어 NIC처럼 NAPI polling만으로 돈다”도 틀립니다. 정확히는 복호화 이후 per-peer NAPI/GRO 경로를 활용합니다.

static const struct net_device_ops netdev_ops = {
    .ndo_open = wg_open,
    .ndo_stop = wg_stop,
    .ndo_start_xmit = wg_xmit,
    .ndo_get_stats64 = wg_get_stats64,
};

static void wg_setup(struct net_device *dev)
{
    dev->type = ARPHRD_NONE;
    dev->flags = IFF_POINTOPOINT | IFF_NOARP;
    dev->needed_headroom = DATA_PACKET_HEAD_ROOM;
}

제어 평면은 Generic Netlink로 노출됩니다. wg(8)는 private key, peer, endpoint, AllowedIPs, fwmark, persistent-keepalive 같은 순수 WireGuard 속성만 커널에 밀어 넣습니다. 반대로 wg-quick(8)은 쉘 래퍼로서 주소 할당, 라우팅 테이블, DNS, PostUp/PreDown 훅, SaveConfig까지 다룹니다. 이 둘을 헷갈리면 운영 장애 시 원인을 잘못 찾게 됩니다.

송수신 경로 상세

송신 경로 (TX)

  1. 로컬 스택이 목적지 IP를 보고 wg0를 선택
    이 시점의 패킷은 평문 IPv4/IPv6 skb입니다.
  2. wg_xmit()가 AllowedIPs로 peer 검색
    매칭 peer가 없으면 커널은 host unreachable 계열 오류를 되돌릴 수 있습니다. “조용히 터널이 먹었다”고 보면 안 됩니다.
  3. peer의 endpoint 주소군과 현재 keypair 상태 확인
    유효 endpoint가 아직 없으면 전송 자체가 불가능합니다. endpoint는 정적으로 넣거나, 이전에 인증된 수신 패킷으로 학습되어야 합니다.
  4. GSO skb면 먼저 분할하고 peer staged queue에 적재
    커널 소스는 암호화 전에 skb_gso_segment()를 호출합니다. 이것이 MTU와 대용량 전송 성능을 이해하는 핵심입니다.
  5. 세션 키가 없거나 오래됐으면 핸드셰이크 큐잉
    패킷은 잠시 staged queue에 머물고, 새 세션이 준비되면 순서대로 암호화됩니다.
  6. 암호화 워커가 UDP 소켓으로 송신
    이때 바깥쪽 패킷에는 fwmark가 붙을 수 있고, 실제 송신 인터페이스는 WireGuard 장치의 birth namespace 소켓이 선택합니다.

수신 경로 (RX)

  1. UDP 소켓이 암호문 패킷 수신
    메시지 타입이 initiation, response, cookie, data 중 무엇인지 먼저 분기합니다.
  2. 핸드셰이크 메시지는 MAC / cookie / 타임스탬프 검증
    과부하 상태라면 cookie reply만 보낼 수 있습니다.
  3. data 메시지는 key_idx로 keypair 조회 후 복호화 워커로 이동
    여기서 nonce와 AEAD 태그 검증이 수행됩니다.
  4. 리플레이 윈도우 확인
    현재 커널 구현은 8192비트 카운터 비트맵에서 중복 영역을 뺀 COUNTER_WINDOW_SIZE를 씁니다. 64비트 플랫폼에서는 대략 8128개 이전 nonce를 추적합니다.
  5. 복호화된 패킷의 source AllowedIPs 재검증
    이 검사를 통과해야만 “이 peer가 이 내부 주소를 주장할 자격이 있다”고 인정합니다.
  6. per-peer NAPI/GRO로 일반 네트워크 스택 복귀
    이후에는 로컬 라우팅/소켓 경로와 동일한 평문 skb처럼 처리됩니다.
오해 방지: WireGuard는 TUN 장치처럼 보일 수 있지만, “평문 패킷을 유저스페이스 VPN 프로세스에 올렸다가 다시 내려보내는 구조”가 아닙니다. 핵심 암복호화 데이터 경로는 커널에 있고, 유저스페이스는 설정만 바꿉니다.

타이머, 재키잉, keepalive

상류 커널의 messages.htimers.c 기준으로 WireGuard는 몇 가지 고정 상수를 조합해 세션을 유지합니다. 이 값들을 정확히 알아야 “왜 25초 keepalive가 필요한가”, “왜 2분마다 새 핸드셰이크가 보이는가”를 설명할 수 있습니다.

상수의미운영 해석
REKEY_TIMEOUT 5초 핸드셰이크 응답이 없을 때 재전송 타이머 기준값 연결이 잠시 막히면 약 5초 간격으로 재시도합니다.
KEEPALIVE_TIMEOUT 10초 수신은 있었지만 송신이 없을 때 내부 keepalive를 보낼 기준 사용자 설정 PersistentKeepalive와 다른 내부 메커니즘입니다.
REKEY_AFTER_TIME 120초 initiator가 현재 세션을 새로 고칠 시점 2분 이상 같은 세션 키를 계속 쓰지 않으려는 안전장치입니다.
REJECT_AFTER_TIME 180초 오래된 세션을 더 이상 받지 않을 기준 키 자체 제거는 이 값의 3배(540초) 타이머로 별도 수행됩니다.
REKEY_AFTER_MESSAGES 2^60 보낸 패킷 수가 너무 많아지면 새 세션 유도 장기 대역폭 세션에서 시간 기준 외에 메시지 수 기준도 있습니다.
MAX_TIMER_HANDSHAKES 18 재시도 상한 계속 실패하면 staged queue 패킷을 버리고 잔여 키를 정리합니다.

사용자가 설정하는 PersistentKeepalive는 별개입니다. wg(8) 문서대로 1초에서 65535초 사이를 줄 수 있고, NAT나 상태 기반 방화벽 뒤에서 유휴 상태에서도 외부로부터 들어오는 패킷을 받고 싶을 때만 켭니다. 공식 Quick Start가 추천하는 범용 값은 25초입니다.

실무 포인트: PersistentKeepalive = 25는 “모든 peer에 기본으로 넣는 값”이 아닙니다. 보통은 NAT 뒤에 있고, 한동안 아무 것도 보내지 않아도 외부에서 먼저 들어올 수 있어야 하는 쪽에만 넣습니다. 서버 측이나 항상 송신이 일어나는 peer에는 불필요한 경우가 많습니다.

wg, wg-quick, fwmark, 전체 터널

wg는 WireGuard 장치의 순수 속성만 설정합니다. 주소 부여, 라우트 추가, DNS 설정, 방화벽 훅은 하지 않습니다. 반면 wg-quick은 설정 파일의 Address, DNS, MTU, Table, PreUp, PostUp, PreDown, PostDown, SaveConfig 같은 항목을 해석해 운영 편의를 제공합니다.

기본 서버 / 클라이언트 예제

[Interface]
Address = 10.70.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY

[Peer]
PublicKey = CLIENT_PUBLIC_KEY
AllowedIPs = 10.70.0.2/32

# ------------------------------

[Interface]
Address = 10.70.0.2/32
PrivateKey = CLIENT_PRIVATE_KEY
DNS = 10.70.0.1

[Peer]
PublicKey = SERVER_PUBLIC_KEY
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25

위 구성에서 서버는 “이 공개키의 peer가 내부 주소 10.70.0.2/32를 대표한다”고 이해합니다. 클라이언트는 기본 경로 전체를 wg0로 넣으므로, endpoint 재귀 라우팅을 피하기 위해 wg-quickfwmark와 정책 라우팅을 함께 사용합니다.

# 순수 wg + ip 명령만으로 최소 구성
ip link add wg0 type wireguard
ip address add 10.70.0.1/24 dev wg0
wg set wg0 private-key ./server.key listen-port 51820 \
    peer CLIENT_PUBLIC_KEY allowed-ips 10.70.0.2/32
ip link set wg0 up

# 전체 터널의 핵심 정책 라우팅 아이디어
wg set wg0 fwmark 51820
ip route add default dev wg0 table 51820
ip rule add not fwmark 51820 table 51820
ip rule add table main suppress_prefixlength 0

# 무중단 재적용
wg syncconf wg0 <(wg-quick strip wg0)

Table = off를 쓰면 wg-quick이 라우트를 자동으로 추가하지 않게 할 수 있고, 그 대신 운영자가 원하는 policy routing을 직접 설계할 수 있습니다. 반대로 편의성이 중요하면 wg-quick의 자동 라우팅을 쓰되, 반드시 ip rule 결과를 같이 확인해야 합니다.

네트워크 네임스페이스와 birthplace 소켓

WireGuard 공식 namespace 문서가 가장 강하게 강조하는 특징은 birth namespace입니다. WireGuard 인터페이스는 “어디서 생성되었는지”를 기억하고, 실제 암호문 UDP 소켓은 그 생성 당시 namespace에 남습니다. 인터페이스를 다른 namespace로 이동해도 이 사실은 변하지 않습니다.

이 덕분에 “컨테이너 namespace 안에서는 평문 트래픽만 보이고, 실제 암호문 UDP는 호스트의 물리 NIC namespace에서 나가는” 구조를 만들 수 있습니다. 전체 터널 환경을 깔끔하게 분리할 때 매우 강력합니다.

birth namespace / physical eth0 / wlan0 UDP socket ciphertext, fwmark 호스트 라우팅 / NAT / 방화벽 실제 외부 네트워크와 만나는 위치 container / workload namespace wg0 interface 평문 주소, AllowedIPs, 로컬 라우팅에서 보이는 장치 애플리케이션 / 컨테이너 평문 IP만 관찰 인터페이스는 이동 가능, 소켓은 생성 namespace 유지
# init namespace에서 생성
ip netns add container
ip link add wg0 type wireguard

# 인터페이스만 다른 namespace로 이동
ip link set wg0 netns container
ip -n container address add 10.70.0.2/32 dev wg0
ip netns exec container wg set wg0 private-key ./client.key \
    peer SERVER_PUBLIC_KEY endpoint 203.0.113.10:51820 \
    allowed-ips 0.0.0.0/0,::/0
ip -n container link set wg0 up

위 예제에서 wg0는 container namespace 안에 있지만, 암호문 UDP 소켓은 원래 생성된 namespace에 남아 있습니다. 전체 터널 환경을 더 극단적으로 분리하고 싶다면 공식 문서처럼 물리 NIC를 별도 namespace로 옮기고, WireGuard 인터페이스만 기본 namespace로 다시 가져오는 방식도 가능합니다.

성능과 MTU 튜닝

WireGuard 성능을 이야기할 때 가장 흔한 실수는 “몇 Gbps 나온다” 같은 절대 수치를 먼저 외우는 것입니다. 실제 성능은 CPU 세대, SIMD 구현, physical NIC 드라이버, IRQ 분배, GSO/GRO 경로, MTU, NAT 위치, 테스트 방법에 따라 크게 달라집니다. WireGuard는 구조상 유리한 편이지만, 성능 병목은 늘 주변 계층과 함께 측정해야 합니다.

점검 항목왜 중요한가실무 확인 방법
MTU / PMTU 터널 헤더와 바깥 IP/UDP 헤더가 붙어 단편화가 생길 수 있습니다. tracepath, ping -M do, tcpdump로 확인합니다.
GSO / GRO 경로 WireGuard는 송신 전 GSO 분할, 수신 후 GRO 경로 활용 여부가 중요합니다. perf, ethtool -k, 드라이버/장치별 기능 확인이 필요합니다.
멀티코어 분산 암복호화 워커와 physical NIC IRQ가 특정 CPU에 몰리면 성능이 꺾입니다. top -H, perf top, IRQ affinity를 함께 봅니다.
UDP 버퍼 / 드롭 암호문 UDP가 burst로 몰릴 때 소켓 버퍼 부족이 문제일 수 있습니다. ss -u -i, netstat -su, 시스템 UDP 통계를 봅니다.
테스트 방식 단일 스트림과 다중 스트림은 CPU와 qdisc 사용 양상이 다릅니다. iperf3 -P로 여러 스트림을 함께 측정합니다.

실무에서 자주 보이는 숫자는 MTU 1420이지만, 이것은 “늘 정답”이 아니라 1500바이트 링크에서 IPv6 최악 헤더 여유까지 잡은 흔한 출발점에 가깝습니다. wg-quick은 endpoint 주소나 기본 라우트에서 자동으로 적절한 MTU를 추정하므로, 먼저 자동값을 확인하고 문제가 있을 때만 수동으로 조정하는 편이 낫습니다.

# PMTU / 단편화 점검
tracepath 10.70.0.1
ping -M do -s 1372 10.70.0.1

# 대역폭과 멀티스트림 점검
iperf3 -c 10.70.0.1 -P 4

# CPU 핫스팟 확인
perf top -g

# 바깥쪽 암호문 / 안쪽 평문 비교
tcpdump -ni eth0 udp port 51820
tcpdump -ni wg0

보안 성질과 형식 검증

형식 검증 쪽도 현재 문서에서 자주 과장되는 부분입니다. WireGuard 공식 formal verification 문서 기준으로, 프로토콜 자체는 TamarinCryptoVerif 계열 연구에서 다루어졌고, Curve25519 구현은 HACL*Fiat-Crypto(Coq 기반) 계열 검증 구현을 활용합니다. 하지만 이것이 “커널 모듈 전체가 끝까지 전부 정형 증명되었다”는 뜻은 아닙니다. 정확한 표현은 프로토콜과 일부 암호 구현이 폭넓게 검증되었다입니다.

표현 주의: “WireGuard는 형식 검증되었다”는 말만 쓰면 너무 거칠고, “아무 것도 검증되지 않았다”도 틀립니다. 정확하게는 프로토콜 수준 증명과 일부 암호 구현 검증이 존재하며, 운영 안전성은 여전히 커널 버전, 설정, 라우팅, 방화벽, 키 관리 품질에 크게 좌우됩니다.

운영상 한계와 함정

제한의미대응
UDP만 지원 공식 known limitations 문서처럼 TCP 모드는 지원하지 않습니다. TCP-over-TCP 문제를 피하기 위한 설계입니다. 검열 회피나 난독화는 상위 계층 도구가 맡아야 합니다.
Roaming Mischief 능동적 MITM은 endpoint 주소를 바꿔 끼우는 식의 장난을 칠 수 있습니다. 평문을 읽을 수는 없지만, endpoint를 특정 IP로 고정하고 싶다면 일반 방화벽으로 소켓 대상을 제한합니다.
시스템 시간 의존 시계가 크게 앞으로/뒤로 튀면 핸드셰이크가 깨질 수 있습니다. NTP를 안정적으로 운영하고, hostile 환경에서 시스템 시간을 공격자가 제어하지 못하게 해야 합니다.
L3 전용 이더넷 브로드캐스트, ARP 브리지, 순수 L2 VPN 용도로는 바로 쓰기 어렵습니다. L2가 필요하면 다른 오버레이를 함께 설계해야 합니다.
터널 재귀 전체 터널에서 endpoint 자신도 터널로 보내면 루프가 납니다. fwmark, policy routing, 또는 별도 namespace로 탈출 경로를 분리합니다.

디버깅과 모니터링

WireGuard 장애는 대체로 네 종류로 압축됩니다. “핸드셰이크 자체가 안 됨”, “핸드셰이크는 되는데 데이터가 안 흐름”, “한 방향만 흐름”, “한동안 되다가 끊김”입니다. 이 네 가지를 구분하지 않으면 무의미하게 방화벽과 MTU만 반복해서 만지게 됩니다.

증상먼저 볼 것자주 나오는 원인
latest handshake: never endpoint, 포트 개방, 외부 UDP 도달성, 시스템 시간 ListenPort 미개방, 잘못된 endpoint, NAT 밖 peer에 keepalive 필요, 시간 문제
핸드셰이크는 되지만 전송량이 0 AllowedIPs, 라우트, source 주소, 로컬 방화벽 peer는 맞지만 내부 프리픽스가 틀림, 경로가 다른 인터페이스로 감
송신만 늘고 수신이 없음 NAT 매핑, reverse 경로, 상대 peer AllowedIPs 상대가 내 source 대역을 허용하지 않음, return route 부재
대용량 전송에서만 끊김 MTU / PMTU / GSO 단편화, ICMP 차단, 잘못된 수동 MTU
로밍 후 간헐적 불안정 endpoint 갱신 여부, keepalive, 소켓 고정 방화벽 새 경로 학습이 늦거나, 방화벽이 옛 주소만 허용
# 장치 / peer 상태
wg show wg0
wg show wg0 latest-handshakes transfer endpoints allowed-ips
wg showconf wg0

# 링크 / 주소 / 라우팅 / 정책 라우팅
ip -d link show wg0
ip addr show dev wg0
ip route get 10.70.0.1
ip rule show

# 소켓 / UDP / 패킷
ss -ulpn
tcpdump -ni eth0 udp port 51820
tcpdump -ni wg0

# 커널 런타임 디버그
modprobe wireguard
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
dmesg | tail -n 100

특히 마지막 두 줄은 공식 Quick Start의 디버그 절차와 같습니다. 예전 글에서 보이는 CONFIG_WIREGUARD_DEBUG=y 같은 설명은 현재 일반 운영 문맥에서 우선순위가 낮고, 실제로는 dynamic debug가 더 직접적입니다.

Noise IK 핸드셰이크 심화

WireGuard가 채택한 Noise_IKpsk2 패턴을 좀 더 정밀하게 들여다봅니다. Noise 프레임워크의 IK 패턴은 Initiator가 Responder의 정적 공개키를 미리 알고 있다는 전제(pre-message pattern ← s)에서 출발합니다. 여기에 psk2 수정자가 붙어 핸드셰이크 두 번째 메시지 직후에 사전 공유키(PSK)를 체인 키에 혼합합니다.

핸드셰이크의 네 단계 메시지 교환을 커널 소스 수준으로 분해하면 다음과 같습니다.

  1. Initiator → Responder: Handshake Initiation
    Initiator는 임시 키 쌍 (e_i, E_i)를 생성하고, DH(e_i, S_r)로 체인 키를 진행시킵니다. 그런 다음 자신의 정적 공개키 S_i를 AEAD 암호화하여 encrypted_static 필드에 넣고, DH(s_i, S_r)로 다시 체인 키를 갱신합니다. 마지막으로 TAI64N 타임스탬프를 암호화하여 리플레이를 방어합니다.
  2. Responder → Initiator: Handshake Response
    Responder도 임시 키 쌍 (e_r, E_r)를 생성합니다. DH(e_r, E_i), DH(e_r, S_i) 순으로 체인 키를 진행시키고, 이 시점에서 PSK를 혼합합니다(psk2). encrypted_nothing 필드에 빈 페이로드를 AEAD 암호화하여 키 확인 토큰 역할을 합니다.
  3. 세션 키 유도
    양쪽 모두 최종 체인 키에서 HKDF-BLAKE2s를 사용해 송신용과 수신용 대칭 키를 각각 유도합니다. Initiator의 송신 키가 Responder의 수신 키가 되는 대칭 구조입니다.
  4. 첫 데이터 패킷 = 키 확인
    Initiator가 보내는 첫 message_data가 성공적으로 복호화되면 Responder는 세션이 올바르게 수립되었음을 확인합니다. 별도의 확인 메시지 없이 데이터 전송과 키 확인이 동시에 이루어지는 1-RTT 설계입니다.
Initiator (s_i, S_i) Responder (s_r, S_r) e_i = DH_GENERATE() DH(e_i, S_r), DH(s_i, S_r) Initiation: E_i + encrypted(S_i) + encrypted(timestamp) mac1 = MAC(HASH(LABEL_MAC1 || S_r), msg) e_r = DH_GENERATE() DH(e_r, E_i), DH(e_r, S_i) Response: E_r + encrypted(empty) + mac1 + mac2 PSK 혼합 시점 (psk2): Response 처리 후 체인 키에 MixKey(PSK) 세션 키 유도: HKDF-BLAKE2s(chaining_key) T_send_i = T_recv_r, T_recv_i = T_send_r 첫 data 패킷 (키 확인 겸용) counter=0, AEAD(T_send_i, 0, plaintext, header) DH 연산 요약 (총 4회 Curve25519) 1. DH(e_i, S_r) — Initiator 임시 ↔ Responder 정적 2. DH(s_i, S_r) — Initiator 정적 ↔ Responder 정적 3. DH(e_r, E_i) — Responder 임시 ↔ Initiator 임시 4. DH(e_r, S_i) — Responder 임시 ↔ Initiator 정적
타임스탬프 방어: Initiation에 포함된 TAI64N 타임스탬프는 리플레이 공격을 차단합니다. Responder는 이전에 받은 것보다 더 최신인 타임스탬프만 수락합니다. 따라서 공격자가 이전 Initiation 패킷을 다시 보내도 Responder는 거부합니다. 이 메커니즘이 제대로 동작하려면 시스템 시계가 합리적으로 정확해야 합니다.
/* noise.c — Initiator 측 핸드셰이크 생성 핵심 흐름 (단순화) */
bool wg_noise_handshake_create_initiation(
    struct message_handshake_initiation *dst,
    struct noise_handshake *handshake)
{
    struct noise_symmetric_key key;
    u8 timestamp[NOISE_TIMESTAMP_LEN];

    /* 1. 임시 키 쌍 생성 */
    curve25519_generate_secret(handshake->ephemeral_private);
    curve25519_generate_public(
        dst->unencrypted_ephemeral,
        handshake->ephemeral_private);

    /* 2. DH(e_i, S_r) → 체인 키 갱신 */
    mix_dh(handshake, handshake->ephemeral_private,
           handshake->remote_static);

    /* 3. 정적 공개키 암호화 */
    message_encrypt(dst->encrypted_static,
        handshake->static_identity->static_public,
        NOISE_PUBLIC_KEY_LEN, key);

    /* 4. DH(s_i, S_r) → 체인 키 갱신 */
    mix_dh(handshake,
           handshake->static_identity->static_private,
           handshake->remote_static);

    /* 5. 타임스탬프 암호화 (리플레이 방어) */
    tai64n_now(timestamp);
    message_encrypt(dst->encrypted_timestamp,
        timestamp, NOISE_TIMESTAMP_LEN, key);

    /* 6. mac1, mac2 계산 */
    message_macs(dst, handshake);
    return true;
}

Noise 패턴 비교: IK vs XX vs KK

WireGuard가 IK 패턴을 선택한 이유를 이해하려면 다른 Noise 패턴과 비교해 볼 필요가 있습니다.

패턴사전 지식RTT신원 보호WireGuard 선택 이유
IK Initiator가 Responder의 S를 미리 앎 1-RTT Initiator의 정적 키가 암호화됨 VPN에서 peer 공개키는 항상 미리 설정 → IK가 자연스러움
XX 없음 (상호 미지) 1.5-RTT 양쪽 모두 보호 추가 RTT가 필요하고, VPN에서는 불필요한 유연성
KK 양쪽 모두 상대 S를 미리 앎 1-RTT 없음 (양쪽 정적 키 노출) Initiator 신원 보호가 없어 프라이버시 불리
NK Initiator가 Responder의 S를 미리 앎 1-RTT Initiator 인증 없음 Responder가 Initiator를 식별할 수 없어 VPN 부적합
Noise_IKpsk2에서 PSK의 역할: psk2 수정자는 Response 메시지 처리 후 사전 공유키(PSK)를 체인 키에 혼합합니다. 이 PSK는 공개키 암호를 보완하는 추가 대칭 비밀입니다. 양자 컴퓨터가 Curve25519를 깨더라도 PSK까지 알지 못하면 세션 키를 유도할 수 없으므로, 후양자(post-quantum) 저항성에 한 겹의 방어를 더합니다. 다만 PSK 자체를 안전하게 교환하고 관리하는 것은 운영자의 책임입니다.

핸드셰이크 상태 머신

커널 소스의 noise.c에서 핸드셰이크 상태는 enum noise_handshake_state로 관리됩니다. 각 상태 전환을 이해하면 "왜 핸드셰이크가 멈추는가"를 정확히 진단할 수 있습니다.

/* noise.h — 핸드셰이크 상태 열거 */
enum noise_handshake_state {
    HANDSHAKE_ZEROED,        /* 초기화 전 */
    HANDSHAKE_CREATED_INITIATION, /* Initiation 전송 완료 */
    HANDSHAKE_CONSUMED_INITIATION, /* Initiation 수신/처리 완료 */
    HANDSHAKE_CREATED_RESPONSE,  /* Response 전송 완료 */
    HANDSHAKE_CONSUMED_RESPONSE, /* Response 수신/처리 → 키 활성화 */
};

/* Responder 측 핸드셰이크 소비 (단순화) */
struct wg_peer *wg_noise_handshake_consume_initiation(
    struct message_handshake_initiation *src,
    struct wg_device *wg)
{
    struct noise_handshake *handshake;
    u8 s[NOISE_PUBLIC_KEY_LEN];
    u8 timestamp[NOISE_TIMESTAMP_LEN];

    /* 1. Initiator의 임시 공개키로 DH */
    mix_hash(&handshake_hash, src->unencrypted_ephemeral,
             NOISE_PUBLIC_KEY_LEN);
    mix_dh(&chaining_key, wg->static_identity.static_private,
           src->unencrypted_ephemeral);

    /* 2. Initiator의 정적 공개키 복호화 → peer 식별 */
    message_decrypt(s, src->encrypted_static,
        NOISE_PUBLIC_KEY_LEN + NOISE_AUTHTAG_LEN,
        key, handshake_hash);

    /* 3. peer 조회 (정적 공개키 기반) */
    handshake = lookup_peer(wg, s);

    /* 4. 타임스탬프 검증 (리플레이 방어) */
    message_decrypt(timestamp, src->encrypted_timestamp,
        NOISE_TIMESTAMP_LEN + NOISE_AUTHTAG_LEN,
        key, handshake_hash);

    if (!timestamp_is_newer(timestamp, handshake))
        goto fail; /* 리플레이 거부 */

    handshake->state = HANDSHAKE_CONSUMED_INITIATION;
    return handshake->peer;
}
상태전환 조건실패 시 동작
ZEROED → CREATED_INITIATION Initiator가 데이터 전송 필요 키 생성 실패 시 로그 없이 포기
ZEROED → CONSUMED_INITIATION Responder가 유효한 Initiation 수신 mac1 실패, 타임스탬프 리플레이, 알 수 없는 peer → 무응답
CONSUMED_INITIATION → CREATED_RESPONSE Responder가 Response 생성/전송 DH 실패 시 핸드셰이크 중단
CREATED_INITIATION → CONSUMED_RESPONSE Initiator가 유효한 Response 수신 AEAD 검증 실패 → 무시, REKEY_TIMEOUT 후 재시도
CONSUMED_RESPONSE → 세션 활성화 keypair 승격, 데이터 전송 시작 첫 데이터 패킷이 실패하면 새 핸드셰이크 시도

AllowedIPs 트라이 구조와 최장 접두사 매칭

AllowedIPs는 단순한 목록이 아니라 커널 내부에서 비트 단위 트라이(bitwise trie) 자료구조로 유지됩니다. 상류 커널의 allowedips.c를 보면, IPv4와 IPv6에 대해 각각 독립적인 트라이 루트를 관리하며, 각 노드는 struct allowedips_node로 구성됩니다.

이 트라이의 핵심 특성은 다음과 같습니다.

IPv4 trie root 10.x.x.x (bit 0..7 = 00001010) 10.0.0.0/24 → peer B 10.10.0.0/16 → peer C 10.0.0.42/32 → peer A (승리) 192.168.50.0/24 → peer D 조회 흐름: dst = 10.0.0.42 1. 루트에서 비트 순서대로 탐색 시작 2. 10.0.0.0/24 노드 통과 (peer B 후보 기록) 3. 10.0.0.42/32 노드 도달 (peer A가 더 구체적 → peer A 최종 선택) 핵심: 트라이를 끝까지 따라가며 마지막으로 매칭된 peer가 최장 접두사 승자
/* allowedips.c — 트라이 조회 핵심 구조 */
struct allowedips_node {
    struct wg_peer __rcu *peer;
    struct allowedips_node __rcu *bit[2];
    u8 cidr, bit_at_a, bit_at_b, bitlen;

    /* 노드 메모리는 RCU로 보호 */
};

/* 송신 시 목적지 IP로 peer 조회 */
struct wg_peer *wg_allowedips_lookup_dst(
    struct allowedips *table,
    struct sk_buff *skb)
{
    if (skb->protocol == htons(ETH_P_IP))
        return lookup(table->root4,
            32, &ip_hdr(skb)->daddr);
    else if (skb->protocol == htons(ETH_P_IPV6))
        return lookup(table->root6,
            128, &ipv6_hdr(skb)->daddr);
    return NULL;
}

/* 수신 시 소스 IP 검증 */
struct wg_peer *wg_allowedips_lookup_src(
    struct allowedips *table,
    struct sk_buff *skb)
{
    if (skb->protocol == htons(ETH_P_IP))
        return lookup(table->root4,
            32, &ip_hdr(skb)->saddr);
    else if (skb->protocol == htons(ETH_P_IPV6))
        return lookup(table->root6,
            128, &ipv6_hdr(skb)->saddr);
    return NULL;
}
라우팅 테이블 통합 주의: wg-quick은 AllowedIPs의 각 프리픽스에 대해 시스템 라우팅 테이블에 경로를 자동 추가합니다. 따라서 WireGuard 내부 트라이와 커널 FIB(Forwarding Information Base)에 동일한 정보가 이중으로 존재합니다. Table = auto(기본값)일 때 wg-quick은 별도 라우팅 테이블을 만들고, 0.0.0.0/0이 포함되면 fwmark 기반 정책 라우팅을 설정합니다. 이 이중 구조를 이해하지 못하면 "라우트를 삭제했는데 왜 여전히 패킷이 터널로 가는가" 같은 혼란이 생깁니다.

RCU 보호와 동시성

AllowedIPs 트라이는 RCU(Read-Copy-Update)로 보호됩니다. 이는 데이터 경로(송수신)에서 트라이 조회가 잠금 없이 수행되어야 하기 때문입니다. peer 추가/삭제 같은 변경 작업은 Netlink 경로에서 rtnl_lock 아래에서 수행되고, 읽기 측은 rcu_read_lock() 영역에서 안전하게 트라이를 탐색합니다.

/* allowedips.c — RCU 보호 트라이 조회 (단순화) */
static struct wg_peer *lookup(
    struct allowedips_node __rcu *top,
    u8 bits, const void *be_ip)
{
    struct allowedips_node *node;
    struct wg_peer *peer = NULL;
    u8 cidr;

    /* RCU 읽기 영역 — 잠금 없이 안전한 탐색 */
    rcu_read_lock();

    node = rcu_dereference(top);
    while (node) {
        /* 현재 노드의 CIDR 비트까지 IP가 일치하면 */
        if (prefix_matches(node, be_ip, bits)) {
            /* peer가 있으면 후보로 기록 (마지막 매칭이 승자) */
            struct wg_peer *p =
                rcu_dereference(node->peer);
            if (p)
                peer = p;
            /* 다음 비트 방향으로 하강 */
            node = rcu_dereference(
                node->bit[choose_bit(be_ip, node)]);
        } else {
            break;
        }
    }

    rcu_read_unlock();
    return peer;
}
연산경로잠금시간 복잡도
조회 (송신/수신) 데이터 경로 (softirq/BH) rcu_read_lock() (잠금 없음) O(비트 길이): IPv4=32, IPv6=128
삽입 제어 경로 (Netlink) rtnl_lock + mutex O(비트 길이)
삭제 (peer 제거 시) 제어 경로 (Netlink) rtnl_lock + RCU synchronize O(노드 수) + RCU grace period 대기
전체 재구성 제어 경로 (syncconf) rtnl_lock O(총 프리픽스 수 x 비트 길이)
성능 특성: AllowedIPs 트라이의 조회 성능은 등록된 프리픽스 수에 거의 무관합니다. 1000개의 peer가 있어도 IPv4 조회는 최대 32번의 비트 비교로 끝납니다. 이 특성 덕분에 WireGuard는 대규모 mesh VPN 구성에서도 per-packet 오버헤드가 증가하지 않습니다. 다만, peer 추가/삭제 시 RCU grace period를 대기해야 하므로 매우 빈번한 동적 peer 변경은 제어 평면 지연을 유발할 수 있습니다.

암호 프리미티브 내부 구조

WireGuard가 선택한 세 가지 핵심 암호 프리미티브 — ChaCha20-Poly1305, BLAKE2s, Curve25519 — 각각의 선택 이유와 커널 내부에서의 구현 방식을 살펴봅니다.

ChaCha20-Poly1305: AEAD 암호

ChaCha20은 Daniel Bernstein이 설계한 스트림 암호이고, Poly1305는 같은 설계자의 원타임 MAC입니다. 이 조합을 AEAD(Authenticated Encryption with Associated Data)로 묶은 것이 RFC 8439의 구성이며, WireGuard는 이것을 데이터 패킷 암호화와 핸드셰이크 내부 필드 암호화에 모두 사용합니다.

속성ChaCha20-Poly1305AES-GCMWireGuard 선택 이유
하드웨어 가속 AES-NI 없는 CPU에서 유리 AES-NI 있는 CPU에서 유리 하드웨어 의존성 없이 일관된 성능
소프트웨어 구현 단순한 ARX(Add-Rotate-XOR) GF(2^128) 곱셈 필요 감사 용이성, 이식성
사이드 채널 상수 시간 구현 자연스러움 테이블 기반 구현 시 캐시 타이밍 위험 타이밍 공격 면적 축소
nonce 크기 96비트 (12바이트) 96비트 (12바이트) WireGuard는 64비트 카운터 사용
SIMD 최적화 NEON/AVX2/AVX-512 구현 존재 AES-NI + CLMUL 커널에 zinc/lib 경로로 SIMD 구현 포함

BLAKE2s: 해시와 KDF

BLAKE2s는 BLAKE2 계열의 32비트 워드 변형으로, SHA-256보다 빠르면서 충돌 저항성은 동등합니다. WireGuard는 핸드셰이크 체인 키 유도에 HMAC-BLAKE2s를 사용하고, HKDF-BLAKE2s로 최종 세션 키를 추출합니다.

WireGuard에서 BLAKE2s가 수행하는 구체적 역할은 다음과 같습니다.

용도구성입력출력
MixHash BLAKE2s(h || data) 현재 해시 + 새 데이터 갱신된 핸드셰이크 해시
MixKey (HKDF-Extract) HMAC-BLAKE2s(ck, input) 체인 키 + DH 결과 새 체인 키 + 임시 키
HKDF-Expand HMAC-BLAKE2s(prk, info) 추출된 PRK + 정보 세션 송신/수신 키
mac1 계산 MAC(HASH(label || pk), msg) LABEL_MAC1 + 수신자 공개키 + 메시지 16바이트 MAC
cookie 계산 MAC(key, ip || port) 2분 비밀 + 소스 주소 16바이트 쿠키
/* noise.c — HKDF-BLAKE2s 기반 키 유도 (단순화) */
static void kdf(u8 *first_dst, u8 *second_dst,
    u8 *third_dst,
    const u8 *data, size_t dlen,
    const u8 chaining_key[NOISE_HASH_LEN])
{
    u8 output[BLAKE2S_HASH_SIZE + 1];
    u8 secret[BLAKE2S_HASH_SIZE];

    /* Extract: HMAC-BLAKE2s(chaining_key, data) */
    blake2s256_hmac(secret, data, chaining_key,
        dlen, NOISE_HASH_LEN);

    /* Expand 1: T1 = HMAC-BLAKE2s(secret, 0x01) */
    output[0] = 1;
    blake2s256_hmac(first_dst, output, secret,
        1, BLAKE2S_HASH_SIZE);

    if (!second_dst)
        goto out;

    /* Expand 2: T2 = HMAC-BLAKE2s(secret, T1 || 0x02) */
    memcpy(output, first_dst, BLAKE2S_HASH_SIZE);
    output[BLAKE2S_HASH_SIZE] = 2;
    blake2s256_hmac(second_dst, output, secret,
        BLAKE2S_HASH_SIZE + 1,
        BLAKE2S_HASH_SIZE);

    /* 필요하면 T3도 유사하게 유도 */
out:
    memzero_explicit(secret, BLAKE2S_HASH_SIZE);
    memzero_explicit(output, sizeof(output));
}

Curve25519: ECDH

Curve25519는 Daniel Bernstein이 설계한 타원 곡선으로, 32바이트 공개키와 32바이트 비밀키를 사용합니다. 커널 구현은 lib/crypto/curve25519.c에 위치하며, 아키텍처별로 SIMD 최적화된 구현(curve25519-x86_64.c 등)이 선택됩니다.

Curve25519의 핵심 보안 속성은 다음과 같습니다.

SIMD 자동 선택: 커널은 부팅 시 CPU 기능을 확인하여 최적의 Curve25519 구현을 선택합니다. x86_64에서는 arch/x86/crypto/curve25519-x86_64.c가 ADX/BMI2 명령어를 활용하고, ARM64에서는 NEON 구현이 선택됩니다. cat /proc/crypto | grep curve25519로 현재 활성화된 구현을 확인할 수 있습니다.
Curve25519 정적/임시 키 생성 DH 공유 비밀 계산 32B 키 × 4회 DH = 핸드셰이크 BLAKE2s / HKDF 체인 키(chaining key) 유도 세션 키 추출 HMAC-BLAKE2s → HKDF 확장 ChaCha20-Poly1305 핸드셰이크 필드 AEAD 데이터 패킷 AEAD nonce = 64b 카운터 + 태그 16B 핸드셰이크 단계 Curve25519 DH → BLAKE2s 체인 → ChaCha20-Poly1305 필드 암호화 → 세션 키 유도 데이터 전송 단계 평문 → ChaCha20-Poly1305(session_key, counter, data) → 암호문 + 16B 태그 SipHash24 (보조 용도) 내부 해시테이블 키 (index_hashtable) 데이터 인증과는 무관, 커널 자료구조 보호용 커널 구현 위치 lib/crypto/curve25519.c, blake2s.c arch/x86/crypto/ 아래 SIMD 변형 자동 선택
/* 데이터 패킷 암호화 핵심 (send.c 기반, 단순화) */
static bool encrypt_packet(
    struct sk_buff *skb,
    struct noise_keypair *keypair)
{
    struct message_data *header;
    u64 nonce;
    unsigned int padding_len, plaintext_len;

    /* nonce = 원자적으로 증가하는 64비트 카운터 */
    nonce = atomic64_inc_return(
        &keypair->sending_counter) - 1;

    /* REKEY_AFTER_MESSAGES 도달 시 재핸드셰이크 유도 */
    if (unlikely(nonce >= REKEY_AFTER_MESSAGES))
        goto rekey;

    /* 헤더 세팅 */
    header = (struct message_data *)skb_push(
        skb, sizeof(*header));
    header->header.type = cpu_to_le32(
        MESSAGE_DATA);
    header->key_idx = keypair->remote_index;
    header->counter = cpu_to_le64(nonce);

    /* ChaCha20-Poly1305 AEAD 암호화 */
    chacha20poly1305_encrypt(
        skb->data + sizeof(*header),
        skb->data + sizeof(*header),
        plaintext_len,
        header, sizeof(*header),  /* AAD */
        nonce, keypair->sending.key);

    return true;
}

커널 모듈 아키텍처 심화

WireGuard 커널 모듈의 내부 구조를 소스 파일 단위로 분해합니다. 상류 커널의 drivers/net/wireguard/ 디렉토리는 약 4,000줄의 C 코드로 구성되어 있으며, 이 작은 크기가 WireGuard의 핵심 설계 원칙인 "감사 가능한 코드 베이스"를 반영합니다.

소스 파일역할핵심 함수/구조체
main.c 모듈 초기화/해제, rtnl_link_ops 등록 wg_init(), wg_exit()
device.c net_device 생성/파괴, ndo_start_xmit wg_setup(), wg_xmit(), wg_open()
noise.c Noise_IKpsk2 핸드셰이크 상태 머신 wg_noise_handshake_create_initiation()
peer.c peer 생명주기, 참조 카운팅 struct wg_peer, wg_peer_create()
allowedips.c AllowedIPs 비트 트라이 관리 wg_allowedips_lookup_dst()
send.c 송신 경로, GSO 분할, 암호화 큐잉 wg_packet_send_staged_packets()
receive.c 수신 경로, 복호화, NAPI/GRO 전달 wg_packet_receive()
socket.c UDP 소켓 생성/관리, birth namespace wg_socket_init()
netlink.c Generic Netlink 인터페이스 (wg 명령) wg_set_device(), wg_get_device()
timers.c 재키잉, keepalive, 만료 타이머 wg_timers_data_sent()
cookie.c DoS 완화 쿠키 계산/검증 wg_cookie_message_create()
queueing.c per-CPU 병렬 암복호화 큐 wg_packet_queue_init()
유저스페이스: wg / wg-quick / systemd-networkd netlink.c (Generic Netlink) drivers/net/wireguard/ (커널 모듈) device.c net_device, wg_xmit ARPHRD_NONE, L3 전용 noise.c Noise_IKpsk2 상태 머신 핸드셰이크 생성/소비 peer.c struct wg_peer 생명주기, 참조 카운팅 allowedips.c 비트 트라이 LPM 송신 선택 + 수신 검증 send.c GSO 분할 → staged queue per-CPU 암호화 워커 receive.c 복호화 → AllowedIPs 검증 per-peer NAPI/GRO 전달 socket.c UDP 소켓 관리 birth namespace 고정 timers.c — 재키잉/만료 REKEY_AFTER_TIME 등 cookie.c — DoS 완화 mac1/mac2, cookie reply queueing.c — 병렬 큐 per-CPU 워커 분산 lib/crypto/ curve25519, blake2s, chacha20
/* device.c — WireGuard 장치의 핵심 구조체 */
struct wg_device {
    struct net_device *dev;

    /* 암호 식별자 */
    struct noise_static_identity static_identity;

    /* peer 관리 */
    struct list_head peer_list;
    struct allowedips peer_allowedips;
    u32 num_peers;

    /* UDP 소켓 (birth namespace에 고정) */
    struct wg_socket __rcu *sock4, *sock6;
    u16 incoming_port;

    /* 암복호화 병렬 큐 */
    struct wg_queue encrypt_queue, decrypt_queue;
    struct workqueue_struct *handshake_send_wq;
    struct workqueue_struct *handshake_receive_wq;

    /* 네트워크 네임스페이스 */
    struct net *creating_net;  /* birth ns */

    /* DoS 완화 */
    struct cookie_checker cookie_checker;

    /* 인덱스 해시테이블: key_idx → keypair 빠른 조회 */
    struct index_hashtable index_hashtable;
};

/* peer.c — 피어별 상태 */
struct wg_peer {
    struct wg_device *device;
    struct noise_handshake handshake;
    struct noise_keypairs keypairs;

    /* endpoint 정보 (로밍 시 갱신) */
    struct endpoint endpoint;

    /* 송신 대기 큐 */
    struct sk_buff_head staged_packet_queue;

    /* per-peer NAPI (수신 GRO 경로) */
    struct napi_struct napi;

    /* 타이머 */
    struct timer_list timer_retransmit_handshake;
    struct timer_list timer_send_keepalive;
    struct timer_list timer_new_handshake;
    struct timer_list timer_zero_key_material;
    struct timer_list timer_persistent_keepalive;

    /* 통계 */
    u64 last_sent_handshake;
    u64 rx_bytes, tx_bytes;
};

데이터 패킷 처리 경로 심화

앞서 개요 수준에서 송수신 경로를 봤다면, 이 절에서는 커널 소스 수준에서 패킷이 실제로 거치는 함수 호출 체인과 병렬화 메커니즘을 자세히 분석합니다.

송신 경로 (TX) 상세

wg_xmit().ndo_start_xmit 콜백으로, 네트워크 스택이 wg0를 선택했을 때 호출됩니다. 이 함수의 핵심 동작은 다음과 같습니다.

  1. 프로토콜 확인: IPv4/IPv6만 허용합니다. 다른 프로토콜은 즉시 폐기됩니다.
  2. AllowedIPs LPM으로 peer 결정: 목적지 IP에 대해 트라이를 조회합니다.
  3. GSO skb 분할: skb_is_gso()가 참이면 skb_gso_segment()로 세그먼트 단위로 쪼갭니다. 각 세그먼트가 개별적으로 AEAD 암호화되어야 하기 때문입니다.
  4. staged queue에 적재: 분할된 skb들은 peer->staged_packet_queue에 들어갑니다.
  5. keypair 확인 및 핸드셰이크 트리거: 유효한 세션 키가 없으면 핸드셰이크를 큐잉하고, 패킷은 staged queue에서 대기합니다.
  6. per-CPU 암호화 큐로 이동: wg_packet_send_staged_packets()가 staged queue에서 패킷을 꺼내 encrypt_queue의 per-CPU 슬롯에 넣습니다.
  7. 암호화 워커 실행: wg_packet_encrypt_worker()가 ChaCha20-Poly1305 AEAD를 적용하고, 완료된 패킷을 wg_packet_send_keepalive() 경로를 통해 UDP 소켓으로 전송합니다.

수신 경로 (RX) 상세

UDP 소켓의 encap_rcv 콜백인 wg_receive()에서 시작됩니다.

  1. 메시지 타입 분기: 헤더의 첫 4바이트로 MESSAGE_HANDSHAKE_INITIATION, MESSAGE_HANDSHAKE_RESPONSE, MESSAGE_HANDSHAKE_COOKIE, MESSAGE_DATA를 구분합니다.
  2. 핸드셰이크 메시지: 전용 핸드셰이크 워크큐로 전달됩니다. rate limiting이 적용됩니다.
  3. 데이터 메시지: key_idxindex_hashtable에서 keypair를 조회합니다.
  4. per-CPU 복호화 큐: decrypt_queue의 per-CPU 슬롯에 넣고 워커를 깨웁니다.
  5. 복호화 워커: wg_packet_decrypt_worker()가 AEAD 복호화와 nonce 윈도우 확인을 수행합니다.
  6. AllowedIPs 소스 검증: 복호화된 평문의 소스 IP가 해당 peer의 AllowedIPs에 속하는지 확인합니다.
  7. per-peer NAPI/GRO: 검증 통과 시 napi_gro_receive(&peer->napi, skb)로 네트워크 스택에 주입합니다.

per-CPU 암복호화 큐 메커니즘

WireGuard의 병렬 암복호화는 struct wg_queue를 통해 관리됩니다. 이 큐는 ptr_ring 자료구조를 기반으로 하며, 각 CPU가 독립적으로 패킷을 처리합니다.

/* queueing.h — per-CPU 큐 구조 */
struct wg_queue {
    struct ptr_ring ring;
    struct work_struct work;
    int last_cpu;
};

/* 패킷을 큐에 넣고 워커를 깨우는 핵심 로직 */
static inline int wg_queue_enqueue_per_device_and_peer(
    struct wg_queue *device_queue,
    struct wg_queue *peer_queue,
    struct sk_buff *skb,
    struct workqueue_struct *wq)
{
    int cpu;

    /* peer 큐에 삽입 (순서 보장용) */
    ptr_ring_produce(&peer_queue->ring, skb);

    /* device 큐에도 삽입 (CPU 분산용) */
    cpu = wg_cpumask_next_online(
        &device_queue->last_cpu);
    ptr_ring_produce_bh(&device_queue->ring, skb);

    /* 해당 CPU의 워커 깨우기 */
    queue_work_on(cpu, wq, &device_queue->work);
    return 0;
}
순서 보장: per-CPU 워커가 병렬로 암호화를 수행하더라도, 패킷 순서는 peer_queue의 순서 번호로 보장됩니다. 암호화가 완료된 패킷은 peer 큐에서 원래 순서대로 꺼내져 전송됩니다. 이 이중 큐 설계(device-level 분산 + peer-level 순서)가 WireGuard의 멀티코어 성능과 패킷 순서 보장을 동시에 달성하는 핵심입니다.
송신 경로 (TX) 로컬 프로세스 → wg0 wg_xmit(): AllowedIPs LPM GSO 분할 (skb_gso_segment) peer→staged_packet_queue 세션 키 확인 (없으면 핸드셰이크) encrypt_queue (per-CPU 슬롯) ChaCha20-Poly1305 AEAD 암호화 UDP 소켓 송신 (birth ns) 수신 경로 (RX) UDP 소켓 수신 wg_receive(): 메시지 타입 분기 key_idx → index_hashtable 조회 decrypt_queue (per-CPU 슬롯) ChaCha20-Poly1305 AEAD 복호화 nonce 윈도우 확인 (리플레이 방지) AllowedIPs 소스 검증 napi_gro_receive (per-peer) 네트워크 스택 복귀 (평문 skb) per-CPU 병렬화 암복호화 워커 분산 실행
GSO와 NAPI의 대칭 관계: 송신에서 GSO skb를 분할하여 개별 암호화하는 것과, 수신에서 per-peer NAPI/GRO로 복호화된 패킷을 집계하여 스택에 올리는 것은 대칭적 설계입니다. 이 구조 덕분에 WireGuard는 유저스페이스 VPN 대비 컨텍스트 스위칭 없이 고속 처리가 가능합니다.

키 로테이션과 세션 관리

WireGuard의 키 로테이션은 운영자가 수동으로 수행하는 것이 아니라 타이머 기반으로 자동 발생합니다. 이 메커니즘의 전체 그림을 이해해야 "왜 2분마다 핸드셰이크가 보이는가", "키 자료가 언제 메모리에서 제거되는가"를 설명할 수 있습니다.

세션 생명주기

각 peer는 최대 3개의 keypair를 유지할 수 있습니다: current, previous, next. 새 핸드셰이크가 완료되면 nextcurrent로 승격되고, 이전 currentprevious로 내려갑니다.

시간 흐름 (세션 생명주기) T=0: 핸드셰이크 새 keypair 생성 T=120s: REKEY_AFTER_TIME Initiator가 새 핸드셰이크 시작 T=180s: REJECT_AFTER_TIME 이 keypair로 수신 거부 Keypair 슬롯 전환 current (활성) → previous로 강등 next → current 승격 previous 제거 (zeroed) 재키잉 타이머 상세 REKEY_TIMEOUT = 5초: 핸드셰이크 응답 대기 시간, 초과 시 재전송 REKEY_AFTER_TIME = 120초: Initiator가 현재 세션 만료 전에 새 핸드셰이크 시작 REJECT_AFTER_TIME = 180초: 이 시점 이후 해당 keypair로 들어오는 패킷 거부 REJECT_AFTER_TIME * 3 = 540초: keypair 메모리를 0으로 초기화하고 제거 REKEY_AFTER_MESSAGES = 2^60: 메시지 수 기반 재키잉 (시간보다 먼저 도달하는 경우는 극히 드묾)
이벤트트리거결과
데이터 전송 시도 current keypair 없거나 만료 임박 핸드셰이크 initiation 큐잉, 패킷은 staged queue 대기
REKEY_AFTER_TIME 도달 현재 세션이 120초 경과 다음 데이터 전송 시 새 핸드셰이크 시작
핸드셰이크 응답 미수신 REKEY_TIMEOUT(5초) 경과 Initiation 재전송, 최대 MAX_TIMER_HANDSHAKES(18)회
18회 연속 실패 MAX_TIMER_HANDSHAKES 도달 staged queue 패킷 폐기, 잔여 키 자료 정리
REJECT_AFTER_TIME 도달 keypair 생성 후 180초 경과 해당 keypair로 들어오는 패킷 거부
키 자료 제거 REJECT_AFTER_TIME * 3 (540초) 경과 keypair 메모리를 0으로 밀고 해제 (전방 기밀성 보강)

이 타이머 체계의 실무적 의미를 정리하면, WireGuard는 능동적으로 데이터를 보내는 쪽(Initiator)만 새 핸드셰이크를 시작합니다. 유휴 peer는 핸드셰이크를 하지 않습니다. 이것이 "보내야 할 게 없으면 조용히 있는다"는 WireGuard의 기본 동작과 직접 연결됩니다.

운영 함정: REKEY_AFTER_TIME(120초)은 Initiator 측에서만 적용됩니다. Responder는 데이터를 수신만 하는 한 직접 핸드셰이크를 시작하지 않습니다. 따라서 한 방향으로만 트래픽이 흐르는 환경(예: 모니터링 데이터 단방향 전송)에서는 항상 같은 쪽이 Initiator가 됩니다. 이 비대칭 구조를 이해하지 못하면 "왜 서버 측은 절대 핸드셰이크를 시작하지 않는가"라는 의문이 생깁니다.
전방 기밀성 구현: WireGuard는 임시 키를 매 핸드셰이크마다 새로 생성하고, 사용이 끝난 keypair의 메모리를 명시적으로 0으로 초기화합니다(noise_keypair_put()kfree_sensitive()). 공격자가 나중에 정적 비밀키를 탈취하더라도, 임시 키가 이미 소멸된 과거 세션의 데이터는 복원할 수 없습니다.

nonce 카운터와 리플레이 윈도우

데이터 패킷의 counter 필드는 64비트 단조 증가 카운터입니다. 수신 측은 이 카운터를 사용해 리플레이 공격을 탐지합니다. 커널 구현의 리플레이 윈도우 메커니즘을 살펴봅니다.

/* peerlookup.h — 리플레이 카운터 구조 */
struct noise_replay_counter {
    u64 counter;             /* 수신된 최대 카운터 */
    unsigned long backtrack[  /* 비트맵: 윈도우 내 수신 기록 */
        COUNTER_BITS_TOTAL /
        BITS_PER_LONG];
    spinlock_t lock;
};

/* COUNTER_WINDOW_SIZE 계산:
 *   COUNTER_BITS_TOTAL = 8192 (비트)
 *   COUNTER_REDUNDANT_BITS = sizeof(unsigned long) * 8 = 64
 *   COUNTER_WINDOW_SIZE = 8192 - 64 = 8128
 *
 * 즉, 최대 카운터 값으로부터 8128개 이전 nonce까지 추적 */

static bool counter_validate(
    struct noise_replay_counter *counter,
    u64 their_counter)
{
    u64 index;
    bool ret = false;

    spin_lock_bh(&counter->lock);

    /* 새 최대값이면 윈도우 확장 */
    if (likely(their_counter > counter->counter)) {
        index = their_counter >> ilog2(BITS_PER_LONG);
        /* 비트맵에서 해당 위치 표시 */
        bitmap_set(counter->backtrack, index, 1);
        counter->counter = their_counter;
        ret = true;
    }
    /* 윈도우 안에 있고 아직 안 본 nonce면 허용 */
    else if (counter->counter - their_counter
             < COUNTER_WINDOW_SIZE) {
        index = their_counter >> ilog2(BITS_PER_LONG);
        if (!test_and_set_bit(
                their_counter & (BITS_PER_LONG - 1),
                &counter->backtrack[index
                    % (COUNTER_BITS_TOTAL
                       / BITS_PER_LONG)]))
            ret = true; /* 새로운 nonce */
        /* 이미 본 nonce면 ret = false (리플레이) */
    }
    /* 윈도우 밖이면 ret = false (너무 오래된 패킷) */

    spin_unlock_bh(&counter->lock);
    return ret;
}
순서 역전 허용 범위: 리플레이 윈도우 크기는 약 8128개이므로, 네트워크에서 패킷 순서가 크게 뒤바뀌어도 대부분 수용됩니다. 그러나 이 범위를 초과하면 유효한 패킷도 폐기됩니다. 이런 상황은 매우 높은 부하에서 per-CPU 워커 간 처리 순서 차이가 극단적일 때 이론적으로 가능하지만, 실전에서는 거의 발생하지 않습니다.

keypair 관리 코드

/* noise.c — keypair 승격 로직 (단순화) */
void wg_noise_keypair_begin_session(
    struct noise_keypairs *keypairs,
    struct noise_keypair *new_keypair,
    bool is_initiator)
{
    struct noise_keypair *previous, *next, *current;

    spin_lock_bh(&keypairs->keypair_update_lock);

    /* next 슬롯에 새 keypair 배치 */
    next = rcu_dereference_protected(
        keypairs->next_keypair,
        lockdep_is_held(&keypairs->keypair_update_lock));

    if (is_initiator) {
        /* Initiator: 즉시 current로 승격 */
        current = rcu_dereference_protected(
            keypairs->current_keypair, 1);
        previous = rcu_dereference_protected(
            keypairs->previous_keypair, 1);

        /* previous → 해제 */
        rcu_assign_pointer(
            keypairs->previous_keypair, current);
        rcu_assign_pointer(
            keypairs->current_keypair, new_keypair);
        rcu_assign_pointer(
            keypairs->next_keypair, NULL);

        if (previous)
            wg_noise_keypair_put(previous, true);
    } else {
        /* Responder: next에 대기, 첫 데이터 수신 시 승격 */
        rcu_assign_pointer(
            keypairs->next_keypair, new_keypair);
    }

    spin_unlock_bh(&keypairs->keypair_update_lock);
}

성능 비교와 벤치마크 분석

WireGuard의 성능 우위는 "빠르다"라는 단순 표현보다 어디서, 왜 빠른가를 구조적으로 이해하는 것이 중요합니다. 핵심 요인은 세 가지입니다: 커널 데이터 평면, 컨텍스트 스위칭 없음, 고정 알고리즘의 SIMD 최적화.

항목WireGuardIPsec (AES-GCM)OpenVPN
데이터 평면 커널 (ndo_start_xmit) 커널 (xfrm) 유저스페이스 (tun/tap)
컨텍스트 스위칭 없음 없음 매 패킷마다 발생 가능
알고리즘 선택 고정 (ChaCha20-Poly1305) 협상 (주로 AES-GCM) 설정에 따라 다양
GRO/GSO 활용 per-peer NAPI/GRO xfrm offload (NIC 지원 시) 제한적
코드 크기 ~4,000줄 수만 줄 (xfrm + IKE) 수만 줄
AES-NI 환경 처리량 1~3 Gbps (CPU 의존) 2~5 Gbps (HW offload 가능) 0.2~0.5 Gbps
AES-NI 없는 환경 ChaCha20이 유리 AES 소프트웨어 구현은 느림 동일하게 느림
지연 시간 1-RTT 핸드셰이크 IKEv2 2-RTT TLS 핸드셰이크 다수 RTT
벤치마크 해석 주의: 인터넷에서 흔히 보이는 "WireGuard는 IPsec보다 N배 빠르다" 같은 비교는 거의 항상 특정 조건에서만 유효합니다. IPsec이 AES-NI + HW offload를 활용하면 오히려 더 빠를 수 있고, OpenVPN도 DCO(Data Channel Offload) 모듈을 사용하면 격차가 줄어듭니다. WireGuard의 진짜 강점은 "어떤 조건에서도 합리적인 성능 + 작은 코드 + 일관된 동작"입니다.

성능 튜닝 체크리스트

# 1. SIMD 최적화 확인 (x86_64)
grep -r "chacha20" /proc/crypto
grep -r "curve25519" /proc/crypto

# 2. NIC offload 상태 확인
ethtool -k eth0 | grep -E "gso|gro|tso"

# 3. IRQ 분산 확인 (멀티코어)
cat /proc/interrupts | grep eth0
cat /proc/irq/*/smp_affinity_list

# 4. CPU별 암복호화 워커 분포
perf top -g -p $(pgrep -f wireguard)

# 5. UDP 소켓 버퍼 크기 조정
sysctl net.core.rmem_max
sysctl net.core.wmem_max
sysctl -w net.core.rmem_max=26214400
sysctl -w net.core.wmem_max=26214400

# 6. 대역폭 측정 (다중 스트림)
iperf3 -c 10.70.0.1 -P 8 -t 30

# 7. 단일 스트림 지연 시간 측정
iperf3 -c 10.70.0.1 -u -b 1G -l 1400

네트워크 네임스페이스 활용 패턴

WireGuard의 birth namespace 특성을 활용한 실전 네트워크 격리 패턴을 살펴봅니다. 이 절에서는 앞서 소개한 기본 namespace 분리를 넘어, 컨테이너 VPN, 라우팅 격리, 다중 터널 환경을 다룹니다.

패턴 1: 컨테이너 전용 VPN 게이트웨이

호스트에서 생성한 WireGuard 인터페이스를 컨테이너 namespace로 이동시켜, 컨테이너 내부의 모든 트래픽이 VPN을 통해서만 나가도록 강제합니다.

# 1. 네임스페이스 생성
ip netns add vpn-container

# 2. 호스트에서 WireGuard 인터페이스 생성 (birth ns = 호스트)
ip link add wg-vpn type wireguard
wg set wg-vpn private-key /etc/wireguard/container.key \
    peer SERVER_PUB_KEY endpoint vpn.example.com:51820 \
    allowed-ips 0.0.0.0/0,::/0 \
    persistent-keepalive 25

# 3. 인터페이스를 컨테이너 namespace로 이동
ip link set wg-vpn netns vpn-container

# 4. 컨테이너 내부에서 주소와 라우팅 설정
ip -n vpn-container addr add 10.70.0.2/32 dev wg-vpn
ip -n vpn-container link set wg-vpn up
ip -n vpn-container link set lo up
ip -n vpn-container route add default dev wg-vpn

# 5. 컨테이너 namespace에서 프로세스 실행
ip netns exec vpn-container /usr/bin/my-app

패턴 2: 물리 NIC 격리 (역방향 namespace)

WireGuard 공식 namespace 문서가 소개하는 더 극단적인 패턴입니다. 물리 NIC를 별도 namespace로 옮기고, WireGuard 인터페이스만 기본(init) namespace에 남깁니다.

# 1. 물리 NIC를 담을 namespace 생성
ip netns add physical

# 2. WireGuard를 physical namespace에서 생성 (birth ns = physical)
ip -n physical link add wg0 type wireguard

# 3. 물리 NIC를 physical namespace로 이동
ip link set eth0 netns physical
ip -n physical addr add 203.0.113.5/24 dev eth0
ip -n physical link set eth0 up
ip -n physical route add default via 203.0.113.1

# 4. physical namespace에서 WireGuard 설정
ip netns exec physical wg set wg0 \
    private-key /etc/wireguard/host.key \
    listen-port 51820 \
    peer CLIENT_PUB_KEY allowed-ips 10.70.0.0/24

# 5. WireGuard 인터페이스를 init namespace로 이동
ip -n physical link set wg0 netns 1

# 6. init namespace에서 주소와 라우팅
ip addr add 10.70.0.1/24 dev wg0
ip link set wg0 up

# 결과: init namespace에서는 평문 트래픽만 보임
# 물리 NIC와 암호문 UDP는 physical namespace에 격리
패턴 1: 컨테이너 VPN 호스트 namespace (birth ns) eth0 (물리) UDP 소켓 (고정) 라우팅 / NAT / 방화벽 컨테이너 namespace wg-vpn (이동됨, 평문 전용) 앱: 평문만 관찰 소켓은 호스트에 남음 패턴 2: 물리 NIC 격리 physical namespace (birth ns) eth0 (이동됨) UDP 소켓 (고정) 암호문만 처리 init namespace (서비스 실행) wg0 (이동됨, 평문 전용) 서비스: 평문만 관찰 소켓은 physical에 남음
Docker/Podman과의 통합: 컨테이너 런타임에서 WireGuard를 사용할 때는 호스트에서 인터페이스를 생성한 뒤 ip link set wg0 netns <container-pid>로 이동시키는 패턴이 일반적입니다. 이때 NET_ADMIN 능력(capability)이 필요하므로, 보안 정책에 따라 init 컨테이너나 sidecar 패턴을 고려해야 합니다.

패턴 3: 다중 터널과 라우팅 격리

서로 다른 VPN 서버에 대해 별도의 WireGuard 인터페이스를 만들고, 각각을 다른 namespace에 배치하여 트래픽을 완전히 격리하는 패턴입니다.

# 터널 A: 업무용 VPN
ip netns add work-vpn
ip link add wg-work type wireguard
wg set wg-work private-key /etc/wireguard/work.key \
    peer WORK_PUB_KEY endpoint work-vpn.corp.com:51820 \
    allowed-ips 10.0.0.0/8
ip link set wg-work netns work-vpn
ip -n work-vpn addr add 10.70.0.2/32 dev wg-work
ip -n work-vpn link set wg-work up
ip -n work-vpn route add 10.0.0.0/8 dev wg-work

# 터널 B: 개인용 VPN
ip netns add personal-vpn
ip link add wg-personal type wireguard
wg set wg-personal private-key /etc/wireguard/personal.key \
    peer PERSONAL_PUB_KEY endpoint personal.vpn.com:51820 \
    allowed-ips 0.0.0.0/0,::/0
ip link set wg-personal netns personal-vpn
ip -n personal-vpn addr add 10.80.0.2/32 dev wg-personal
ip -n personal-vpn link set wg-personal up
ip -n personal-vpn route add default dev wg-personal

# 특정 앱을 특정 터널로 실행
ip netns exec work-vpn curl https://internal.corp.com
ip netns exec personal-vpn firefox
패턴birth namespacewg 인터페이스 위치사용 사례
기본 (namespace 없음) init init 단순 VPN, 호스트 전체 보호
패턴 1: 컨테이너 VPN init (호스트) 컨테이너 ns 특정 앱/컨테이너만 VPN 경유
패턴 2: 물리 NIC 격리 physical ns init 호스트에서 평문만 노출, 물리 NIC 격리
패턴 3: 다중 터널 init (호스트) 각각 별도 ns 앱별 다른 VPN 터널 사용
namespace 이동 시 주의사항: WireGuard 인터페이스를 namespace로 이동한 후에는 해당 namespace 안에서 wg set으로 설정을 변경해야 합니다. 그러나 wg show는 인터페이스가 있는 namespace에서만 가능합니다. 또한 namespace가 삭제되면 그 안의 WireGuard 인터페이스도 함께 사라지므로, 서비스 재시작이나 namespace 재생성 시 인터페이스를 다시 만들어야 합니다.

systemd와 namespace 통합

systemd-networkd[NetDev] 섹션에서 WireGuard 인터페이스를 선언적으로 관리할 수 있습니다. 이 방식은 wg-quick 대비 namespace 통합과 서비스 의존성 관리가 더 체계적입니다.

# /etc/systemd/network/99-wg0.netdev
[NetDev]
Name=wg0
Kind=wireguard
Description=WireGuard VPN

[WireGuard]
PrivateKey=SERVER_PRIVATE_KEY
ListenPort=51820

[WireGuardPeer]
PublicKey=CLIENT_PUBLIC_KEY
AllowedIPs=10.70.0.2/32
PersistentKeepalive=25

# /etc/systemd/network/99-wg0.network
[Match]
Name=wg0

[Network]
Address=10.70.0.1/24

[Route]
Destination=10.70.0.0/24
Scope=link
# systemd-networkd로 WireGuard 관리
systemctl enable --now systemd-networkd
networkctl status wg0
networkctl list

# 로그 확인
journalctl -u systemd-networkd -f --grep wireguard

ftrace/bpftrace를 활용한 WireGuard 패킷 추적

WireGuard의 커널 내부 동작을 실시간으로 추적하려면 ftrace, bpftrace, perf 같은 커널 트레이싱 도구를 사용합니다. 이 절에서는 WireGuard 특유의 추적 포인트와 실전 디버깅 기법을 다룹니다.

dynamic_debug 활성화

가장 간단한 방법은 WireGuard 모듈의 dynamic debug 메시지를 켜는 것입니다. 이는 공식 Quick Start에서도 권장하는 방법입니다.

# WireGuard 모듈의 모든 dynamic debug 메시지 활성화
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control

# 핸드셰이크 관련 메시지만 필터링
echo 'module wireguard func wg_noise_handshake* +p' \
    > /sys/kernel/debug/dynamic_debug/control

# 결과 확인
dmesg -w | grep wireguard

ftrace로 함수 호출 체인 추적

# 1. WireGuard 송신 경로 함수 그래프 추적
cd /sys/kernel/debug/tracing
echo function_graph > current_tracer
echo 'wg_xmit' > set_graph_function
echo 1 > tracing_on

# 패킷 전송 후 결과 확인
cat trace | head -100

# 2. 특정 함수의 지연 시간 측정
echo 'wg_noise_handshake_create_initiation' > set_ftrace_filter
echo function > current_tracer
echo 1 > tracing_on
cat trace_pipe

# 3. 추적 중지
echo 0 > tracing_on
echo nop > current_tracer

bpftrace 원라이너

# 1. 핸드셰이크 빈도 모니터링
bpftrace -e '
kprobe:wg_noise_handshake_create_initiation {
    @handshakes[comm] = count();
}
interval:s:10 { print(@handshakes); clear(@handshakes); }
'

# 2. 패킷 암호화 지연 시간 히스토그램
bpftrace -e '
kprobe:wg_packet_encrypt_worker { @start[tid] = nsecs; }
kretprobe:wg_packet_encrypt_worker /@start[tid]/ {
    @encrypt_ns = hist(nsecs - @start[tid]);
    delete(@start[tid]);
}
'

# 3. peer별 송수신 바이트 추적
bpftrace -e '
kprobe:wg_xmit {
    @tx_bytes = sum(((struct sk_buff *)arg0)->len);
}
kprobe:wg_packet_receive {
    @rx_bytes = sum(((struct sk_buff *)arg0)->len);
}
interval:s:5 { print(@tx_bytes); print(@rx_bytes); }
'

# 4. AllowedIPs 조회 실패 (peer 없음) 추적
bpftrace -e '
kretprobe:wg_allowedips_lookup_dst /retval == 0/ {
    @lookup_miss = count();
    printf("AllowedIPs miss at %s\n", kstack);
}
'

perf를 활용한 CPU 프로파일링

# 1. WireGuard 관련 CPU 핫스팟 확인
perf top -g --kallsyms=/proc/kallsyms -d 5

# 2. 암호화 연산의 CPU 사이클 분포
perf record -g -a -- sleep 10
perf report --symbol-filter=chacha20

# 3. 특정 WireGuard 함수의 flamegraph 생성
perf record -g -a -F 99 -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > wg-flame.svg

# 4. 캐시 미스 분석 (대용량 트래픽 시)
perf stat -e cache-misses,cache-references,instructions \
    -a -- sleep 10
추적 시 주의: ftrace와 bpftrace는 프로덕션 환경에서도 사용할 수 있지만, function_graph 추적은 상당한 오버헤드를 유발할 수 있습니다. 높은 처리량에서는 kprobe 기반 포인트 추적이나 perf 샘플링이 더 적합합니다. 또한 WireGuard의 암호 연산이 SIMD를 사용할 때는 kernel_fpu_begin()/kernel_fpu_end() 사이에서 추가 오버헤드가 발생할 수 있습니다.

tcpdump로 WireGuard 트래픽 분석

WireGuard 디버깅에서 tcpdump는 가장 즉각적인 도구입니다. 핵심은 바깥쪽(eth0)과 안쪽(wg0)을 동시에 캡처하여 비교하는 것입니다.

# 터미널 1: 바깥쪽 암호문 패킷 캡처
tcpdump -ni eth0 udp port 51820 -X -c 20

# 터미널 2: 안쪽 평문 패킷 캡처
tcpdump -ni wg0 -c 20

# 양쪽을 동시에 파일로 저장하여 비교
tcpdump -ni eth0 udp port 51820 -w /tmp/outer.pcap &
tcpdump -ni wg0 -w /tmp/inner.pcap &

# 패킷 캡처 후 분석
# 바깥쪽에서 패킷이 보이지만 안쪽에서 안 보이면:
#   → 복호화 실패 또는 AllowedIPs 소스 검증 실패
# 안쪽에서 패킷이 보이지만 바깥쪽에서 안 보이면:
#   → 암호화 전 단계 문제 (peer 없음, endpoint 미설정)

# WireGuard 패킷 타입 확인 (첫 바이트)
# 0x01 = Handshake Initiation
# 0x02 = Handshake Response
# 0x03 = Cookie Reply
# 0x04 = Transport Data
tcpdump -ni eth0 udp port 51820 -X | head -40
추적 도구적합한 상황오버헤드주의사항
wg show 기본 상태 확인 (핸드셰이크, 전송량) 무시 가능 Netlink 호출이므로 빈번한 폴링 주의
dynamic_debug 핸드셰이크 문제 진단 낮음 프로덕션에서도 안전, dmesg 버퍼 주의
tcpdump 패킷 도달/미도달 확인 중간 캡처량이 많으면 디스크/CPU 부하
bpftrace (kprobe) 특정 함수 빈도/지연 측정 낮음~중간 BTF 또는 커널 헤더 필요
ftrace (function_graph) 함수 호출 체인 전체 추적 높음 고부하 환경에서는 부적합
perf CPU 핫스팟, 캐시 미스 분석 낮음 (샘플링) 심볼 해석에 kallsyms 필요

eBPF를 활용한 WireGuard 관찰

eBPF/XDP는 WireGuard 패킷을 직접 처리하지는 않지만(WireGuard는 XDP 훅을 사용하지 않음), 관찰 도구로서 패킷 흐름과 성능 지표를 수집하는 데 유용합니다.

/* BPF 프로그램 예시: WireGuard 핸드셰이크 모니터링 (개념) */
SEC("kprobe/wg_noise_handshake_consume_initiation")
int trace_handshake_init(struct pt_regs *ctx)
{
    struct event *e;
    u64 ts = bpf_ktime_get_ns();

    e = bpf_ringbuf_reserve(&events,
        sizeof(*e), 0);
    if (!e)
        return 0;

    e->type = EVENT_HANDSHAKE_INIT;
    e->timestamp = ts;
    e->pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(e->comm, sizeof(e->comm));

    bpf_ringbuf_submit(e, 0);
    return 0;
}

SEC("kretprobe/wg_allowedips_lookup_dst")
int trace_lookup_result(struct pt_regs *ctx)
{
    void *ret = (void *)PT_REGS_RC(ctx);

    if (!ret) {
        /* peer 못 찾음 → AllowedIPs miss 카운트 */
        bpf_map_update_elem(&miss_count,
            &zero, &one, BPF_ANY);
    }
    return 0;
}

문제 해결 심화와 디버깅 체크리스트

WireGuard 장애는 크게 네 범주로 나뉩니다. 각 범주마다 체계적인 진단 순서를 따르면 불필요한 시행착오를 크게 줄일 수 있습니다.

wg show wg0 실행 latest handshake 있는가? 핸드셰이크 실패 1. endpoint 설정 확인 2. UDP 포트 개방 확인 (방화벽) 3. 공개키 일치 확인 (양쪽) 4. 시스템 시간 동기화 확인 아니오 transfer에 rx/tx 있는가? 핸드셰이크 OK, 데이터 안 흐름 1. AllowedIPs 프리픽스 확인 2. ip route get <dst> 확인 3. iptables/nftables 확인 4. wg0 주소 할당 확인 아니오 한 방향만 흐르는가? 한 방향만 흐름 1. 상대 peer의 AllowedIPs에 내 소스 포함? 2. NAT 매핑 만료 (keepalive 필요?) 3. 상대 방화벽의 reverse 경로 확인 대용량 전송에서만 문제 1. ping -M do -s 1372 확인 (IPv4) 2. tracepath로 PMTU 확인 3. 중간 경로의 ICMP 차단 확인 자주 나오는 설정 오류 AllowedIPs에 자기 자신의 터널 IP를 빠뜨림 서버와 클라이언트의 공개키를 서로 바꿔 넣음 wg-quick의 DNS 설정이 resolv.conf를 덮어써 외부 DNS 불가 fwmark 충돌로 정책 라우팅이 꼬임 PostUp/PostDown 스크립트 실행 권한 부재

체계적 디버깅 순서

# === 1단계: 기본 상태 확인 ===
wg show wg0
ip -d link show wg0
ip addr show dev wg0
ip route show table all | grep wg0
ip rule show

# === 2단계: 외부 연결성 확인 ===
# endpoint에 UDP 도달 가능한가?
ss -ulpn | grep 51820
# 바깥쪽 패킷이 나가는가?
tcpdump -ni eth0 udp port 51820 -c 10

# === 3단계: 핸드셰이크 확인 ===
wg show wg0 latest-handshakes
# dynamic debug 활성화
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
dmesg -w | grep wireguard

# === 4단계: 데이터 경로 확인 ===
# 안쪽 평문 패킷이 보이는가?
tcpdump -ni wg0 -c 10
# AllowedIPs와 라우팅 교차 검증
wg show wg0 allowed-ips
ip route get 10.70.0.2 fibmatch

# === 5단계: MTU / 단편화 확인 ===
tracepath 10.70.0.1
ping -M do -s 1372 10.70.0.1

# === 6단계: 카운터 / 통계 확인 ===
wg show wg0 transfer
ip -s link show wg0
cat /proc/net/udp | grep $(printf '%04X' 51820)
증상진단 명령흔한 원인해결책
RTNETLINK: Operation not supported modprobe wireguard 커널 모듈 미로드 또는 커널 미지원 커널 5.6+ 확인, CONFIG_WIREGUARD=y/m 확인
핸드셰이크 후 즉시 끊김 dmesg | grep wireguard 시스템 시간 불일치 (TAI64N 검증 실패) timedatectl 확인, NTP 동기화
SSH는 되지만 SCP는 중간에 멈춤 ping -M do -s 1372 MTU 문제, 중간 경로에서 ICMP 차단 MTU를 1380 또는 1340으로 수동 설정
외부에서 접속 불가 (내부 → 외부는 됨) wg show wg0 endpoints NAT 뒤 peer에 PersistentKeepalive 미설정 PersistentKeepalive = 25 추가
wg-quick: ip rule 오류 ip rule show fwmark 충돌 또는 이전 설정 잔재 wg-quick down 후 정리, Table = off 고려
DNS 해석 실패 resolvectl status wg-quickresolv.conf 덮어씀 PostUp에서 resolvectl 직접 사용
로밍 후 5~10초 불통 wg show wg0 endpoints endpoint 갱신 지연 양쪽 중 한 쪽이 먼저 패킷을 보내야 학습 발생
핵심 진단 원칙: WireGuard 문제 해결에서 가장 중요한 것은 "핸드셰이크 문제인가, 데이터 경로 문제인가"를 먼저 구분하는 것입니다. wg show wg0latest handshake 타임스탬프가 최근(보통 2분 이내)이면 핸드셰이크는 정상입니다. 그 이후는 AllowedIPs, 라우팅, 방화벽, MTU 순서로 확인합니다. 이 순서를 지키지 않으면 "방화벽이 문제일 것"이라는 추측만 반복하게 됩니다.

자동화 모니터링 스크립트

프로덕션 환경에서 WireGuard 상태를 주기적으로 감시하는 스크립트 예시입니다.

#!/bin/bash
# wg-monitor.sh — WireGuard 상태 모니터링
# cron: */5 * * * * /usr/local/bin/wg-monitor.sh

INTERFACE="wg0"
ALERT_THRESHOLD=300  # 5분 이상 핸드셰이크 없으면 경고

check_peer() {
    local pubkey="$1"
    local last_hs

    last_hs=$(wg show "$INTERFACE" latest-handshakes \
        | grep "$pubkey" | awk '{print $2}')

    if [ -z "$last_hs" ] || [ "$last_hs" -eq 0 ]; then
        echo "CRITICAL: peer $pubkey - 핸드셰이크 기록 없음"
        return 1
    fi

    local now
    now=$(date +%s)
    local age=$((now - last_hs))

    if [ "$age" -gt "$ALERT_THRESHOLD" ]; then
        echo "WARNING: peer $pubkey - 마지막 핸드셰이크 ${age}초 전"
        return 1
    fi

    # 전송량 확인
    local transfer
    transfer=$(wg show "$INTERFACE" transfer \
        | grep "$pubkey")
    local rx=$(echo "$transfer" | awk '{print $2}')
    local tx=$(echo "$transfer" | awk '{print $3}')

    echo "OK: peer ${pubkey:0:8}... hs=${age}s rx=$rx tx=$tx"
    return 0
}

# 인터페이스 존재 확인
if ! ip link show "$INTERFACE" > /dev/null 2>&1; then
    echo "CRITICAL: $INTERFACE 인터페이스 없음"
    exit 2
fi

# 모든 peer 확인
wg show "$INTERFACE" peers | while read -r pubkey; do
    check_peer "$pubkey"
done

# 시스템 리소스 확인
echo "--- 시스템 상태 ---"
echo "UDP 드롭: $(cat /proc/net/snmp \
    | grep Udp: | tail -1 | awk '{print $4}')"
echo "소프트IRQ 시간: $(cat /proc/softirqs \
    | grep NET_RX)"

흔한 설정 실수 패턴

실수 패턴증상진단 방법수정
양쪽 모두 endpoint 미설정 핸드셰이크가 영원히 시작되지 않음 wg show에서 endpoint 필드 확인 최소 한 쪽은 endpoint 설정 필요
비밀키 대신 공개키를 PrivateKey에 넣음 핸드셰이크 실패, 오류 메시지 없음 wg show에서 공개키가 예상과 다름 키 쌍 재생성: wg genkey | tee priv | wg pubkey
AllowedIPs에 자기 터널 IP 미포함 핸드셰이크 성공하지만 ping 실패 wg show에서 allowed-ips 확인 peer의 AllowedIPs에 터널 IP 프리픽스 추가
전체 터널에서 fwmark 미설정 0.0.0.0/0 설정 후 네트워크 전체 불통 ip route get로 라우팅 루프 확인 wg-quick 사용 또는 수동 fwmark + ip rule 설정
ListenPort 미개방 (방화벽) 서버 측에서 핸드셰이크 수신 못함 ss -ulpn, iptables -L INPUT UDP 51820 (또는 설정 포트) 개방
DNS 경로 충돌 (wg-quick DNS) VPN 연결 후 이름 해석 불가 resolvectl status, cat /etc/resolv.conf PostUp에서 resolvectl dns wg0 DNS_IP 사용
SaveConfig=true와 수동 변경 충돌 wg-quick down 시 수동 변경 사항 덮어써짐 설정 파일과 런타임 상태 비교 SaveConfig=false로 변경하거나 wg syncconf 사용

커널 로그 분석 가이드

WireGuard의 dynamic debug 메시지를 활성화한 후 dmesg에서 볼 수 있는 주요 로그 패턴과 해석 방법입니다.

# dynamic debug 활성화 후 핵심 로그 패턴

# 정상: 핸드셰이크 완료
# wireguard: wg0: Receiving handshake initiation from peer N
# wireguard: wg0: Sending handshake response to peer N
# → 양쪽이 정상적으로 Initiation/Response를 교환함

# 문제: 리플레이 타임스탬프 거부
# wireguard: wg0: Invalid timestamp (대략적 표현)
# → 시스템 시간이 틀려 TAI64N 검증 실패
# 해결: timedatectl set-ntp true

# 문제: peer를 찾을 수 없음
# wireguard: wg0: No peer found for given public key
# → Initiation에 포함된 정적 공개키가 등록된 peer와 불일치
# 해결: 양쪽 공개키 설정 교차 확인

# 문제: mac1 검증 실패
# → 패킷이 변조되었거나 잘못된 키로 계산됨
# 해결: 네트워크 경로에서 패킷 변조 요소 확인

# 문제: 과부하 모드 진입
# wireguard: wg0: Under load, rate limiting...
# → cookie 기반 DoS 완화가 활성화됨
# 해결: 정상 동작이며, 부하가 줄면 자동 해제
보안 주의: WireGuard의 dynamic debug 메시지에는 peer 공개키나 endpoint 주소 같은 민감한 정보가 포함될 수 있습니다. 프로덕션 환경에서 디버깅 후에는 반드시 echo module wireguard -p > /sys/kernel/debug/dynamic_debug/control로 비활성화하고, 필요에 따라 로그를 정리하세요. 또한 wg showconf의 출력에는 비밀키가 포함되므로 화면 공유나 로그 수집 시 각별히 주의해야 합니다.