이 글은 개발자의 Cloudflare 인프라 딥다이브 시리즈의 네 번째 편이다.
EP.03에서 손으로 깔았던 게이트웨이 구성을 반복 실행 가능한 스크립트로 옮긴다.
들어가며
EP.03 마지막에 이렇게 썼다. "다음 편에서는 이 복잡한 게이트웨이 설정을 Vultr Startup Script로 자동화하는 방법을 알아보자."
그 예고를 반쯤만 지키게 됐다. 자동화는 했는데 Startup Script로는 안 했다. 시도했다가 접었고, 접은 이유가 이 편에서 가장 쓸모 있는 내용이다.
그리고 넉 달이 지났다. 그동안 인프라가 EP.03에서 그려둔 그림보다 커졌다. 자동화의 대상 자체가 달라졌다. 그 변화부터 정리하고 시작한다.
그동안 바뀐 것
EP.03은 홈 네트워크 하나 + Vultr VPC 하나를 WireGuard로 묶는 데서 끝났다. 게이트웨이는 두 대였고 둘 다 손으로 세팅했다.
지금은 VPC가 여러 개다. 리전과 환경(prod/dev)별로 VPC를 나누고, 각 VPC마다 게이트웨이 한 대를 두고, 그 뒤에 k3s 클러스터가 붙는 구조가 됐다.
flowchart LR
subgraph EP03["EP.03 시점"]
H1[홈 10.10.0.0/16] --- C1((CF 백본))
C1 --- V1[VPC 10.20.0.0/16]
end
flowchart TB
CF((Cloudflare 백본))
L[랩탑 WARP 클라이언트]
subgraph P["prod VPC"]
GP[gw .0.1] --- NP[k3s 노드]
end
subgraph D["dev VPC"]
GD[gw .0.1] --- ND[k3s 노드]
GD --- MD[일반 VM]
end
H[홈 네트워크]
L --- CF
CF --- GP
CF --- GD
CF --- H
게이트웨이가 늘어나니 손으로 세팅하는 방식이 무너졌다. 같은 작업을 리전마다 반복하는데 매번 조금씩 다르게 깔렸다. 어디를 어떻게 고쳤는지는 기억에 의존하게 됐다.
Startup Script를 접은 이유
부팅 시점 자동화가 오히려 방해가 됐다.
Vultr는 인스턴스를 만들 때 cloud-init으로 VPC 인터페이스에 정적 IP를 박는다. 문제는 이 설정이 일회성이 아니라는 것이다. cloud-init은 매 부팅 네트워크 설정을 다시 렌더링한다.
Startup Script로 VPC NIC을 게이트웨이 IP(.1)로 바꿔놓으면 그 순간에는 먹는다. 그런데 재부팅하면 cloud-init이 원래 값으로 되돌린다. 부팅 순서를 조정해서 이기려고 해봤자 두 주체가 같은 파일을 두고 싸우는 구조라 안정적이지 않다.
그래서 방향을 바꿨다.
| 항목 | 바뀐 방식 |
|---|---|
| 인스턴스 생성 | API로 생성하되 user_data를 비워둔다 |
| VPC IP 고정 | cloud-init이 박게 두고, 부팅 이후 스크립트가 제자리 수정 |
| 실행 경로 | scp로 스크립트를 올리고 ssh로 실행 |
| 재실행 | 멱등하게 만들어서 몇 번을 돌려도 같은 상태 |
"부팅 시 한 번 자동 실행"을 포기하고 "언제 몇 번을 돌려도 같은 결과"를 택했다. 쓰다 보니 이쪽이 훨씬 편했다. 설정을 바꿔서 다시 돌리는 일이 생각보다 자주 생기기 때문이다.
CIDR을 추론에서 계산으로 바꿨다
EP.03에서는 대역을 그때그때 정했다. 홈은 10.10.0.0/16, Vultr는 10.20.0.0/16 식이었다. VPC가 두 개일 때는 문제가 없었다.
늘어나니까 겹쳤다. 그래서 두 번째 옥텟을 계산으로 뽑는 규칙을 만들었다.
2번째 옥텟 = 클라우드 베이스 + (리전 인덱스 × 2) + 환경 오프셋
클라우드: vtr=16, gcp=32, aws=48, azr=64
리전: icn=0, nrt=1, sin=2 …
환경: prod=0, dev=1
dev + icn + vtr이면 16 + (0×2) + 1 = 17, 즉 10.17.0.0/16이다. 이름만 보면 대역이 나오고 대역만 보면 이름이 나온다.
VPC 안쪽 슬롯도 규칙으로 고정했다.
| 용도 | 대역 | 비고 |
|---|---|---|
| 게이트웨이 | <net>.0.1 | 항상 .1 |
| DHCP 풀 | <net>.0.10 ~ <net>.127.250 | 대역의 앞 절반만 |
| pod CIDR | <net>.128.0/18 | 뒤 절반 |
| service CIDR | <net>.192.0/18 | 뒤 절반 |
DHCP 풀을 앞 절반으로 제한한 건 뒤 절반을 클러스터 대역으로 비워두기 위해서다.
이 규칙이 생긴 계기는 사고였다. k3s는 service CIDR 기본값이 10.43.0.0/16인데, primary service CIDR은 클러스터를 만든 뒤에 바꿀 수 없다. 클러스터를 여러 개 만들었더니 전부 같은 대역을 쓰고 있었다. 서로 겹치니 mesh 라우팅이 불가능했다. 재구축 말고는 방법이 없었다.
설치 시점에 못 잡으면 되돌릴 수 없는 값이 있다. CIDR 설계를 문서 첫 장으로 올린 이유다.
스크립트를 역할별로 쪼갰다
한 덩어리로 만들지 않고 실행 위치 기준으로 나눴다.
| 스크립트 | 실행 위치 | 하는 일 |
|---|---|---|
provision.sh | 로컬 | VPC·인스턴스 생성, 현황 조회 (클라우드 API) |
gateway/setup.sh | 게이트웨이 | WireGuard mesh + dnsmasq DHCP + 포워딩 |
gateway/add-node.sh | 게이트웨이 | 노드 고정 IP + 클러스터 대역 라우트 |
gateway/add-peer-vpc.sh | 기존 게이트웨이들 | 새 VPC 대역을 알림 |
client/setup.sh | 노드 | VPC NIC을 정적에서 DHCP로 전환 |
전부 두 가지 공통 규칙을 지킨다.
- 멱등 — 이미 적용된 항목은 건너뛴다. 재실행이 중복 라우트를 만들지 않는다
DRY_RUN=1— 실제 변경 없이 감지된 값만 출력한다
DRY_RUN이 특히 유용했다. 게이트웨이 스크립트는 NIC 이름, VPC 대역, DHCP 범위를 전부 메타데이터에서 자동 감지한다. 그런데 자동 감지는 틀렸을 때 조용히 틀린다. 적용 전에 감지 결과를 눈으로 보는 단계가 필요하다.
게이트웨이 부트스트랩이 하는 일
gateway/setup.sh 한 번이면 EP.03에서 손으로 하던 게 전부 끝난다. 순서는 이렇다.
hostname -f에서 FQDN을 뽑아 네트워크 이름을 추출 (FQDN이 아니면 즉시 종료)wireguard-tools·dnsmasq설치- 메타데이터로 VPC/공인 NIC과 대역 자동 감지
wgcf-connector로wg0.conf생성 후AllowedIPs를 교체- 터널 기동 + 부팅 자동 시작 등록
- IP 포워딩 활성화
- firewalld trusted zone에
wg0과 VPC NIC 추가 - VPC NIC IP를
.1로 고정 - dnsmasq를 DHCP 전용으로 설정
- 검증 요약 출력
EP.03과 달라진 지점이 두 개 있다.
방화벽을 iptables에서 firewalld로 옮겼다. Rocky Linux 10 기본이 firewalld다. 개별 룰을 나열하는 대신 인터페이스를 trusted zone에 넣는 방식이 멱등하게 만들기 쉬웠다. --add-interface는 이미 있으면 no-op다.
dnsmasq를 DHCP 전용으로 돌린다. port=0으로 DNS 기능을 끈다. 라우트 배포만 필요하고 DNS는 다른 데서 처리하기 때문이다.
# /etc/dnsmasq.d/vpc-dhcp.conf
interface=enp8s0
bind-dynamic # 부팅 시 NIC이 아직 준비 안 됐어도 나중에 bind
port=0 # DNS 끄고 DHCP만
dhcp-range=10.17.0.10,10.17.127.250,255.255.0.0,12h
dhcp-option=option:router,10.17.0.1
dhcp-option=121,10.255.0.0/16,10.17.0.1,10.16.0.0/16,10.17.0.1
dhcp-hostsfile=/var/lib/dnsmasq/dhcp-hosts.list
log-dhcp
dhcp-hostsfile을 /etc/dnsmasq.d/ 바깥에 둔 건 의도적이다. 그 디렉토리는 conf-dir로 자동 파싱되기 때문에, 고정 MAC 매핑을 그 안에 넣으면 매번 재시작이 필요하다. 밖에 두면 SIGHUP만으로 다시 읽는다.
노드는 정적 IP를 DHCP로 되돌린다
게이트웨이가 DHCP 서버가 됐으니 나머지 서버는 클라이언트가 돼야 한다. 그런데 클라우드가 이미 정적 IP를 박아둔 상태다.
여기서 함정을 하나 밟았다. nmcli con add로 DHCP 프로파일을 추가하는 방식이었다.
# 이렇게 하면 안 된다
nmcli connection add type ethernet ifname enp8s0 ipv4.method auto
같은 NIC에 프로파일이 둘 생긴다. 그리고 정적 프로파일 쪽은 cloud-init이 매 부팅 재생성한다. 우선순위가 뒤집히거나 새로 만든 프로파일이 사라지는 순간 정적 IP로 돌아간다. mesh 라우트도 그때 함께 사라진다. MTU 같은 클라우드 고유 설정이 새 프로파일에는 없다는 문제도 있다.
올바른 방식은 기존 프로파일을 제자리 수정하는 것이다. 순서가 중요하다.
# 1) 먼저 cloud-init의 네트워크 렌더링을 끈다
echo 'network: {config: disabled}' \
| sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
# 2) 그다음 기존 connection을 제자리에서 DHCP로 바꾼다
sudo nmcli connection modify "$CONN" \
ipv4.method auto \
ipv4.addresses "" ipv4.gateway "" ipv4.routes "" ipv4.dns ""
sudo nmcli connection up "$CONN"
1번을 먼저 해야 2번이 재부팅 후에도 살아남는다. 순서를 바꾸면 cloud-init이 다시 덮어쓴다.
삽질 기록
핸드셰이크가 안 잡힌다
터널을 올렸는데 handshake가 안 된다. 설정은 맞았다.
원인은 클라우드의 stateful 방화벽이었다. 아웃바운드가 먼저 나가야 되돌아오는 경로를 열어준다. 가만히 있으면 상대가 먼저 말을 걸 수 없는 구조다.
터널을 올린 직후 상대 대역으로 ping을 반복해서 흘려보내는 워밍업 단계를 넣어서 해결했다.
# wg up 직후 burst ping — 응답 여부는 상관없다
IFACE="${1:-wg0}"; TARGET="${2:-10.255.0.1}"
for i in $(seq 20); do
ping -c1 -W1 -I "$IFACE" "$TARGET" >/dev/null 2>&1
sleep 0.5
done
응답은 안 와도 된다. 나가는 패킷이 conntrack 항목을 만드는 것 자체가 목적이다.
게이트웨이는 되는데 그 뒤 서버만 안 된다
새 VPC를 만들고 나서 이런 증상이 나왔다.
| 경로 | 결과 |
|---|---|
| 새 게이트웨이 → 기존 게이트웨이 | 성공 |
| 기존 게이트웨이 → 새 게이트웨이 | 실패 |
| 새 VPC의 서버 → 기존 VPC | 실패 |
| 랩탑(WARP) → 새 VPC 전체 | 성공 |
게이트웨이끼리는 되는데 그 뒤 호스트만 안 되는 비대칭이 특징이다.
원인은 MASQUERADE를 쓰지 않는 설계에 있다. 컨테이너 로그에 원본 IP를 남기려고 일부러 NAT을 안 한다. 그래서 포워딩된 패킷의 출발지는 원래 서버 IP 그대로다. 상대 VPC 입장에서는 처음 보는 대역이다. 돌아갈 경로가 없으니 응답이 사라진다.
sequenceDiagram
participant N as 새 VPC 서버<br/>10.17.0.10
participant G1 as 새 게이트웨이
participant CF as CF 백본
participant G2 as 기존 게이트웨이
participant S as 기존 VPC 서버
N->>G1: dst=10.16.0.10 (src 유지)
G1->>CF: wg0 터널
CF->>G2: Private Network 매칭
G2->>S: 전달 성공
S-->>G2: 응답 dst=10.17.0.10
Note over G2: AllowedIPs에 10.17/16 없음<br/>→ 여기서 소멸
해결은 기존 게이트웨이 각각에서 새 대역을 알려주는 것이다. wg0의 AllowedIPs와 dnsmasq의 option-121을 함께 갱신해야 한다. 한쪽만 하면 절반만 뚫린다.
이 작업은 운영 중인 mesh를 건드린다. 그래서 무중단 적용(wg syncconf)을 쓴다. 적용 후 기존 상대와의 도달성이 깨지면 자동으로 되돌리게 만들었다.
같은 VPC 안의 클러스터 대역만 안 된다
cross-VPC는 되는데 같은 VPC 안의 pod/service 대역만 안 되는 증상이 있었다.
처음에는 "VPC 클라이언트는 기본 게이트웨이 경유로 도달하니 게이트웨이 라우트만으로 충분하다"고 전제했다. 틀린 전제였다.
VPC NIC이 /16으로 온링크 연결돼 있다. 그래서 같은 /16 안의 주소는 기본 게이트웨이로 가지 않고 온링크로 판단해 ARP를 쏜다. 아무도 응답하지 않는다. 다른 /16인 cross-VPC는 option-121에 명시돼 있어서 된다. 같은 대역 안이라 오히려 안 되는 상황이었다.
VPC에 클러스터 노드밖에 없을 때는 드러나지 않았다. 일반 VM을 한 대 넣으면서 발견했다.
해결은 option-121에 클러스터 대역을 추가하되 노드는 제외하는 것이다.
# 노드가 아닌 클라이언트에게만 클러스터 대역 라우트를 준다
dhcp-option=tag:!k8snode,121,10.17.128.0/18,10.17.0.1,10.17.192.0/18,10.17.0.1
노드까지 이 라우트를 받으면 "자기 pod 대역을 자기 자신 경유로" 가는 항목이 생겨서 CNI 로컬 라우트와 충돌한다. dnsmasq는 태그가 붙은 옵션을 태그 없는 옵션보다 우선하므로 이렇게 분리할 수 있다.
API 응답을 근거로 쓰지 않는다
라우트가 제대로 등록됐는지 확인하려고 Cloudflare API의 teamnet/routes를 조회했다. 200과 함께 빈 배열이 왔다.
라우트가 없다고 판단하고 한참 헤맸다. 실제로는 mesh가 정상 동작하고 있었다. WARP Connector 경로는 그 API에 나오지 않는다.
API 응답이 비었다는 사실만으로 부재를 단정하면 안 된다. 실제 도달성을 직접 확인하는 게 빠르다.
그리고 새 대역을 만든 직후 랩탑에서 안 닿는다면, Cloudflare 설정을 의심하기 전에 WARP를 먼저 재시작한다. 기존 WARP 세션의 라우팅 테이블에는 신규 대역이 반영되지 않는다.
잠기지 않는 순서
게이트웨이 세팅이 끝나면 공인 경로를 막고 싶어진다. 여기서 순서를 지켜야 한다.
# 이게 먼저 성공해야 한다
ssh user@10.17.0.10 'echo ok'
사설망 SSH가 실제로 되는 것을 확인한 다음에 공인 인바운드를 차단한다. 반대로 하면 공인은 닫혔는데 사설망도 안 되는 상태가 된다. 인스턴스에 들어갈 방법이 없어진다.
부트스트랩 자동화에서 되돌릴 수 없는 작업은 이것 하나였다. 그래서 이 단계만 스크립트에서 빼고 수동으로 남겨뒀다.
정리
EP.03의 수동 절차가 이렇게 바뀌었다.
| EP.03 (수동) | EP.04 (자동) |
|---|---|
| 대역을 그때그때 결정 | 이름에서 계산으로 산출 |
apt install wireguard 후 conf 수동 배치 | 스크립트가 설치·추출·치환까지 |
iptables -A FORWARD 나열 | firewalld trusted zone에 인터페이스 등록 |
| dnsmasq conf 직접 작성 | 메타데이터 감지 후 자동 생성 |
| 서버마다 반복 | scp + ssh 한 번, 멱등 재실행 |
이번에 배운 걸 남겨둔다.
부팅 시점 자동화가 항상 정답은 아니다. 클라우드가 이미 그 시점을 점유하고 있으면 싸움이 된다. 부팅 이후에 멱등하게 적용하는 편이 안정적이다.
되돌릴 수 없는 값은 설계 첫 단계로 올린다. 클러스터 service CIDR처럼 나중에 못 바꾸는 값은 자동 감지에 맡기지 말고 규칙으로 계산한다.
자동 감지는 틀렸을 때 조용히 틀린다. DRY_RUN으로 감지 결과를 눈으로 확인하는 단계가 반드시 필요하다.
다음 편에서는 이 게이트웨이들 사이에 BGP를 얹어서 터널 하나가 죽어도 경로가 살아남게 만드는 과정을 다룬다. 방화벽 정책을 세 레이어(클라우드 엣지, 호스트, Cloudflare Gateway)에 일관되게 배포하는 문제도 함께 본다.