[OpenStack] 공인 IP 하나로 인스턴스 2,000대 — 포트 인코딩 PNAT 재설계기
교육용 OpenStack 클라우드를 운영하다 보면 흔한 제약을 만난다. 프로젝트(테넌트)마다 공인 IPv4는 딱 하나인데, 그 안에서 학생 수백 명이 저마다 VM을 만들어 SSH로 접속해야 한다. 우리 환경은 이 문제를 오래전부터 "포트 번호에 내부 IP를 새기는" 포트 포워딩 관례로 풀고 있었다. 그런데 이 관례가 인스턴스 수를 약 250대에서 잘라먹는다는 사실이 드러났고, 한도를 열 배 가까이 늘리는 재설계를 하게 됐다. 이 글은 그 검토 여정의 기록이다 — 선택지를 어디까지 뒤졌고, 왜 대부분을 기각했으며, 최종 설계가 어떤 모습인지.
250대의 벽 — 원인은 두 겹
기존 구조는 이렇다. 프로젝트의 Neutron 라우터가 공인 IP 하나를 게이트웨이로 갖고, 라우터 네임스페이스(qrouter netns) 안에 iptables DNAT 규칙을 심어 포트 번호로 VM을 다중화한다. 포트 체계는 사람이 암산할 수 있게 설계돼 있었다:
공인 포트 = 접두사 + 내부 IP 마지막 옥텟 (3자리)
19xxx → 10.0.0.xxx:7777 (ssh)
18xxx → 10.0.0.xxx:80 (http)
17xxx → 10.0.0.xxx:443 (https)
13xxx → 10.0.0.xxx:3000 (node 개발용)
10xxx → 10.0.0.xxx:8080 (http-alt)
예를 들어 내부 IP가 10.0.0.42인 VM의 SSH는 공인IP:19042다. 포트만 보면 내부 주소가 나오니 운영·안내가 편했다. 문제는 이 체계의 수용 한도다.
- 테넌트망이
/24라 호스트 주소가 약 253개뿐이다. - 포트가 마지막 옥텟 하나만 인코딩한다. 서브넷을 넓혀도 인코딩이 3자리인 한 250대 벽은 그대로다.
여기서 중요한 관찰 하나: 공인 IP가 하나라는 것 자체는 병목이 아니다. IP 하나에는 포트가 6만 개 넘게 있다. 병목은 인코딩 규칙과 /24의 결합이다.
선택지 공간은 논리적으로 세 갈래뿐
해법을 찾기 전에 공간을 닫아 두는 편이 좋았다. 패킷에는 목적지 IP와 포트만 실려 온다. DNS 이름은 클라이언트에서 IP로 해석된 뒤 도착하므로, "이름별로 다르게 라우팅"은 네트워크 계층에서는 성립하지 않는다. 그러면 VM 수천 대를 구별하는 방법은 다음 세 갈래의 변주로 전부 닫힌다.
- 주소를 늘린다 — 공인 IPv4 추가(희소하다), IPv6(상위망 지원 확인 필요), 또는 프로젝트를 쪼개서 IP를 하나씩 더 받는 방법.
- 포트로 다중화한다 — 지금 방식의 확장. 정적 전수 규칙이든, Neutron port forwarding 같은 온디맨드 방식이든.
- 이름을 읽는 관문을 세운다 — 웹은 Host/SNI 리버스 프록시, SSH는 배스천(ProxyJump)이나 사용자명 라우팅 게이트웨이, 전 프로토콜은 VPN. 에이전트가 밖으로 연결을 걸어 두는 역방향 터널(Cloudflare Tunnel·AWS SSM 방식)도 이 갈래의 변주다.
참고로 AWS도 이 세 갈래 밖의 마법을 쓰지 않는다. 주소는 IPv4를 대량 매입해 인스턴스마다 1:1로 주고(2024년부터 유료화), 웹은 ALB(Host/SNI), 관리 접속은 SSM(역방향 터널)로 각각 푼다.
기각의 기록
sshpiper — 매력적이지만 비용이 크다
사용자명으로 SSH를 라우팅하는 리버스 프록시 sshpiper를 진지하게 검토했다. ssh vm135@gateway처럼 접속하면 게이트웨이가 사용자명을 보고 내부 VM으로 이어 주는 방식이다. 문서를 파 보니 실무 제약이 두 가지 나왔다.
# sshpiper yaml 플러그인 — VM마다 pipe 1항목이 필요하다
version: "1.0"
pipes:
- from:
- username: "vm135"
authorized_keys:
- /etc/sshpiper/keys/vm135_authorized_keys
to:
host: 10.0.2.135:7777
username: "ubuntu"
private_key: /etc/sshpiper/piper_key
첫째, 정규식 캡처는 to.username에만 쓸 수 있고 to.host에는 못 쓴다. "이름을 파싱해 IP를 계산"하는 한 줄 규칙이 불가능해서, VM 목록을 API에서 읽어 설정을 재생성하는 자동화가 따로 필요하다. 둘째, SSH 공개키 서명은 세션에 묶여 있어 프록시를 통과하지 못한다. 학생→게이트웨이, 게이트웨이→VM의 2단 인증 구조(키 재서명)가 강제되고, 게이트웨이가 별도 개인키를 보관해야 한다. 게이트웨이 서버 운영·VM 이름 유일성 강제·키 관리 체계까지 얹으면, "포트 암기를 없앤다"는 UX 이득 하나에 비해 비용이 과했다. 보류.
Neutron port forwarding — 주소가 갈라진다
OpenStack에는 포워딩 규칙을 DB로 관리하는 내장 기능이 있다. 규칙이 API로 영속하고 콘솔에도 보이니 정공법처럼 보였다. 그런데 결정적 제약이 있다: port forwarding은 Floating IP에만 걸 수 있고, 라우터 게이트웨이 주소에는 걸 수 없다. 채택하면 인바운드용 FIP를 따로 받아 프로젝트 공인 주소가 2개로 갈라진다. "프로젝트 IP는 하나"라는 운영 원칙의 단순함이 규칙 관리의 편의보다 중요하다고 판단해 기각했다.
역방향 터널 — 문제를 바꿔치기하는 교환
frp·Cloudflare Tunnel류의 자가 구축도 검토했다. VM 안 에이전트가 릴레이로 아웃바운드 연결을 걸어 인바운드 노출을 0으로 만드는 방식인데, 이건 인바운드 경로를 통제하지 못하는 상황(가정 공유기 NAT, CGNAT)의 해법이다. 우리는 라우터와 NAT를 직접 소유해 인바운드가 이미 가능하다. 이 방식을 쓰면 이미 있는 연결성을 "전 VM 에이전트 설치·유지 + 릴레이 서버 경유"로 재구축하는 셈이 된다. 네트워크 설정 문제를 VM 수천 대의 소프트웨어 관리 문제로 바꾸는 교환이라 기각했다.
최종 설계 — 인코딩을 5자리로 넓힌다
돌고 돌아 답은 기존 방식의 정직한 확장이었다. 테넌트망을 /21로 시공하고, 포트 공식을 한 자리 늘린다:
내부 IP 10.0.a.bcd → 공인 포트 = S×10000 + a×1000 + bcd
S (서비스 자리): 1=ssh(7777) 2=http(80) 3=https(443) 4=node(3000) 5=http-alt(8080)
a (셋째 옥텟): 0~7 (/21)
bcd(넷째 옥텟): 000~255
예) 10.0.2.135 → ssh 12135, http 22135, https 32135
검산하면: 최대 포트 57255로 유효 범위(65535) 안이고, a는 십진 한 자리라 공식 자체는 /20 수준(약 2,540대)까지 버틴다. "포트만 보고 내부 IP를 암산"하는 기존 관례도 살아남는다. 한도는 250대 → 약 2,040대.
구현 디테일에서 얻은 것들:
- 규칙 수는 5종 × (8×256 − 예약 3주소) = 10,225개. 네트워크·게이트웨이·브로드캐스트 주소로 가는 규칙은 할당 풀 밖이라 무의미해 제외했다. 라우터 netns 안에 와일드카드로 리슨하는 프로세스(메타데이터 프록시용 haproxy)가 있다는 실측도 제외를 거들었다 — 지금 당장 노출되는 건 아니지만, 게이트웨이 주소로 가는 DNAT를 애초에 안 만드는 편이 안전하다.
- 서비스별 하위 체인 5개로 선형 탐색을 1/5로. 메인 체인에는 dport 범위 점프 5개만 두고, 규칙 1만 개는 하위 체인에 나눈다.
Chain PNAT (1 references)
target prot dpts
PNAT-w1 tcp 10000:17255 # → :7777
PNAT-w2 tcp 20000:27255 # → :80
PNAT-w3 tcp 30000:37255 # → :443
PNAT-w4 tcp 40000:47255 # → :3000
PNAT-w5 tcp 50000:57255 # → :8080
- 검증은 자기일관 검사를 믿지 마라. 처음 만든 verify는 "규칙 수 == 기대치"만 봤는데, 리뷰에서 두 가지 맹점이 지적됐다: 하위 체인끼리 개수가 상쇄되면(한쪽 +1, 한쪽 −1) 합계는 맞고, 표본 1건짜리 sentinel은 다른 서비스 체인의 오염을 못 잡는다. 하위 체인별 기대값 개별 비교 + 서비스별 sentinel 5건으로 고치고, 실제로 결함을 주입해 검출되는지까지 확인했다.
성능은 문제였나
규칙 1만 개가 걱정돼 실측했다. 결과는 "걱정할 것 없음"이었다.
전량 재주입(체인 flush 후) : real 1.902 ~ 2.632 s (n=3, iptables-restore 일괄 주입)
멱등 no-op 점검 : real 1.655 ~ 1.743 s (n=3, 매분 타이머가 치르는 고정비)
신규 TCP 연결 수립 : 0.6 ~ 2.1 ms (n=3, 보강 n=10에서 0.64~0.84 ms)
2,045행짜리 하위 체인을 선형 탐색해도 연결 수립 시간은 밀리초 미만 수준에 머문다. 커널 iptables의 매칭 비용은 이 규모에서는 체감 대상이 아니었다. 오히려 눈에 띈 것은 멱등 점검의 고정비(분당 1.7초)였는데, 이는 규칙 매칭이 아니라 상태 점검 명령들의 비용이다.
교훈
- 선택지 공간을 먼저 닫아라. "패킷에는 IP와 포트만 온다"는 사실 하나로 세 갈래가 전부임이 증명되면, 새 도구가 나타나도 어느 갈래의 변주인지만 물으면 된다. 검토가 수렴한다.
- 우아함보다 관례의 관성이 쌀 때가 있다. 이름 기반 라우팅(sshpiper)은 분명 더 예쁜 UX였지만, 게이트웨이·키 재서명·이름 유일성이라는 새 운영면 세 개를 만든다. 기존 도구의 계산식을 한 자리 넓히는 쪽은 새 운영면이 0개다.
- 정적 전수 규칙은 생각보다 싸다. "규칙 1만 개"는 숫자만 보면 무거워 보이지만, 일괄 주입 2초·매칭 1ms 미만이 실측이다. 규칙 수 자체를 겁내 온디맨드 복잡도를 사는 결정은 실측 후에 해도 늦지 않다.
- 검증 로직의 자기일관 함정. 기대치를 같은 코드가 산출하면 코드가 틀려도 검사는 통과한다. 외부 앵커(경계값 스팟체크)와 부분별 개별 비교를 섞고, 가능하면 결함을 일부러 주입해 검출을 확인하라.
댓글
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.