본문 바로가기

클라우드와 컨테이너 네트워크

클라우드의 VPC를 서브넷으로 나누고 예약 주소를 뺀 주소 수를 계산하며, 인터넷 게이트웨이와 NAT 게이트웨이, 상태를 기억하는 보안 그룹과 기억하지 않는 NACL의 차이를 확인합니다. Docker가 컨테이너를 네트워크 네임스페이스와 브리지로 잇는 방식과, Kubernetes가 Pod에 주소를 주고 Service와 EndpointSlice로 묶는 방식도 함께 정리합니다.

지금은 대부분의 서비스가 클라우드 위에서 돌아갑니다. AWS, GCP, Azure의 네트워크도 결국 4장의 서브넷과 5장의 라우팅, 4장 3편의 NAT 위에 있지만, 물리 장비 대신 설정으로 만드는 가상의 장치라는 점이 다릅니다. Docker와 Kubernetes는 그 위에 컨테이너를 위한 또 한 겹의 가상 네트워크를 얹습니다. 이 글에서는 AWS를 예로 클라우드의 네트워크를 보고, 이어서 컨테이너의 네트워크를 봅니다.

VPC와 서브넷

VPC(virtual private cloud)는 클라우드 안에 만드는 나만의 가상 사설망입니다. VPC를 만들 때 10.0.0.0/16 같은 주소 범위를 정하면, 그 안에서 65,536개의 IPv4 주소를 쓸 수 있습니다. 이 범위를 다시 서브넷으로 나누어 쓰는데, AWS의 서브넷은 하나의 가용 영역(availability zone, AZ)에 속하므로, 장애에 대비하려면 같은 역할의 서브넷을 여러 AZ에 하나씩 만듭니다.

서브넷의 주소를 모두 쓸 수는 없습니다. AWS는 서브넷마다 다섯 개의 주소를 예약합니다. 첫 주소(네트워크 주소), 그다음 셋(VPC 라우터, DNS, 앞으로의 용도), 그리고 마지막 주소입니다. 다음 코드는 /16 VPC를 /24 서브넷으로 나누어 두 AZ에 공개 서브넷과 사설 서브넷을 하나씩 배치하는 계획을 계산합니다.

from ipaddress import ip_network

vpc = ip_network("10.0.0.0/16")
subnets = list(vpc.subnets(new_prefix=24))
plan = {"public-a": subnets[1], "public-c": subnets[2], "private-a": subnets[11], "private-c": subnets[12]}
for name, net in plan.items():
    reserved = [net[0], net[1], net[2], net[3], net[-1]]     # AWS가 서브넷마다 예약하는 다섯 주소
    print(f"{name:<10} {net}  쓸 수 있는 주소 {net.num_addresses - len(reserved)}개, 첫 주소 {net[4]}")
print("VPC 안의 /24 개수:", len(subnets))
public-a   10.0.1.0/24  쓸 수 있는 주소 251개, 첫 주소 10.0.1.4
public-c   10.0.2.0/24  쓸 수 있는 주소 251개, 첫 주소 10.0.2.4
private-a  10.0.11.0/24  쓸 수 있는 주소 251개, 첫 주소 10.0.11.4
private-c  10.0.12.0/24  쓸 수 있는 주소 251개, 첫 주소 10.0.12.4
VPC 안의 /24 개수: 256

/24 서브넷 하나에는 256개의 주소가 있지만 다섯 개를 빼면 251개이고, 서버에 줄 수 있는 첫 주소는 .4부터입니다. 서브넷의 크기는 나중에 바꾸기 어려우므로 넉넉하게 잡습니다. 특히 아래에서 볼 AWS VPC CNI처럼 컨테이너마다 VPC의 주소를 주는 방식에서는, 서버 수보다 훨씬 많은 주소가 필요해 /24가 금방 바닥납니다. 회사의 다른 망이나 다른 VPC와 나중에 이을 가능성이 있다면, 처음부터 서로 겹치지 않는 범위를 고르는 것도 중요합니다.

인터넷으로 나가는 길

서브넷이 공개인지 사설인지는 이름이 아니라 라우팅 표가 정합니다. 공개 서브넷(public subnet)은 0.0.0.0/0으로 가는 경로가 인터넷 게이트웨이(internet gateway, IGW)를 가리키는 서브넷입니다. 다만 경로만으로는 부족해서, 서버가 인터넷에서 접속을 받으려면 공개 IPv4 주소나 IPv6 주소가 있어야 하고 아래의 보안 그룹과 NACL도 허락해야 합니다. 웹 서버 앞의 로드 밸런서는 공개 서브넷에, 데이터베이스와 애플리케이션 서버는 사설 서브넷에 두는 것이 보통입니다.

사설 서브넷(private subnet)의 서버도 패키지를 받거나 바깥 API를 부르려면 인터넷에 나가야 합니다. 이때는 공개 서브넷에 NAT 게이트웨이를 두고, 사설 서브넷의 0.0.0.0/0 경로를 NAT 게이트웨이로 향하게 합니다. NAT 게이트웨이는 4장 3편의 NAT처럼 안에서 시작한 연결과 그 응답만 통과시키므로, 인터넷에서 사설 서버로 새 연결을 시작할 수는 없습니다. AZ마다 NAT 게이트웨이를 따로 두면 한 AZ의 장애가 다른 AZ로 번지지 않으며, AWS는 여러 AZ로 자동으로 넓혀지는 지역 단위의 NAT 게이트웨이도 제공합니다.

보안 그룹과 NACL

AWS에는 방화벽이 두 겹 있습니다. 보안 그룹(security group)은 서버의 네트워크 인터페이스마다 붙는 상태 기반 방화벽이고, NACL(network ACL)은 서브넷마다 붙는 상태 없는 규칙 목록입니다.

구분붙는 곳규칙응답 트래픽
보안 그룹네트워크 인터페이스허용 규칙만 있고 모두 합쳐 적용한다허용한 연결의 응답은 자동으로 통과한다
NACL서브넷허용과 거부가 있고 번호가 작은 규칙부터 처음 맞는 것을 쓴다응답도 반대 방향의 규칙으로 따로 허용해야 한다

차이가 가장 크게 드러나는 것은 응답입니다. 보안 그룹은 10장 4편의 상태 기반 방화벽처럼 연결을 기억하므로, 서버가 바깥으로 보낸 요청의 응답은 들어오는 규칙이 없어도 통과합니다. NACL은 패킷마다 따로 판단하므로, 응답이 돌아오는 임시 포트까지 열어 주어야 합니다.

def nacl_allows(rules, port):
    for number, action, lo, hi in sorted(rules):             # 번호가 작은 규칙부터 처음 맞는 것
        if lo <= port <= hi:
            return action == "허용"
    return False                                              # 마지막의 기본 규칙: 모두 거부

inbound = [(100, "허용", 443, 443)]                             # 웹 요청만 들인다
reply_port = 51234                                              # 서버가 바깥 API를 부를 때 쓴 임시 포트
print("바깥 API의 응답이 들어오는가:", nacl_allows(inbound, reply_port))
inbound.append((110, "허용", 1024, 65535))                      # 임시 포트 범위를 열어 준다
print("임시 포트 범위를 연 뒤:", nacl_allows(inbound, reply_port))
바깥 API의 응답이 들어오는가: False
임시 포트 범위를 연 뒤: True

서버가 바깥 API를 부를 때는 6장 1편의 임시 포트를 출발지로 쓰고, 응답은 그 포트로 돌아옵니다. 들어오는 규칙에 443만 있으면 응답이 막혀, 요청은 나갔는데 응답이 오지 않는 장애가 됩니다. 임시 포트의 범위는 운영체제와 NAT 게이트웨이마다 다르므로 보통 1024부터 65535까지를 엽니다. 그래서 대부분의 접근 제어는 보안 그룹으로 하고, NACL은 서브넷 전체에서 특정 주소를 막는 것 같은 굵은 규칙에만 씁니다. 규칙 번호를 100, 110처럼 띄워 두면 나중에 사이에 규칙을 끼워 넣기 쉽습니다. 기본으로 만들어진 NACL은 모두 허용하지만 새로 만든 NACL은 규칙을 넣기 전까지 모두 거부한다는 점도 기억해 둡니다.

Docker 네트워크

컨테이너는 리눅스의 네트워크 네임스페이스(network namespace)로 호스트와 분리된 자기만의 인터페이스와 라우팅 표, 포트를 갖습니다. 네임스페이스 자체는 운영체제 교재의 컨테이너와 Docker에서 자세히 다룹니다. 기본 bridge 네트워크에서는 각 컨테이너의 가상 인터페이스가 호스트의 가상 브리지에 이어지고, 컨테이너는 사설 주소를 받아 호스트의 NAT를 거쳐 바깥으로 나갑니다.

드라이버연결 방식쓰는 곳
bridge호스트 안의 가상 브리지에 잇고 NAT로 나간다한 호스트 안의 컨테이너
host호스트의 네트워크를 그대로 쓴다네트워크 성능이 중요하고 격리가 필요 없을 때
none루프백만 남긴다네트워크가 필요 없는 작업
overlay여러 호스트에 걸친 가상 네트워크를 만든다Docker Swarm 같은 여러 호스트의 클러스터
macvlan컨테이너에 MAC 주소를 주어 물리망에 바로 붙인다물리망의 장비처럼 보여야 할 때
docker network create app-net                                 # 사용자 정의 bridge 네트워크
docker run -d --name db --network app-net -e POSTGRES_PASSWORD=devpass postgres:16   # 공식 이미지는 이 값이 없으면 바로 종료된다
docker run -d --name api --network app-net -p 127.0.0.1:8080:3000 my-api
docker network inspect app-net                                # 붙은 컨테이너와 주소

기본 bridge 네트워크보다 이처럼 사용자 정의 네트워크를 만들어 쓰는 편이 좋습니다. 사용자 정의 네트워크에서는 Docker의 내장 DNS가 컨테이너 이름을 주소로 풀어 주므로, api 컨테이너는 db라는 이름으로 데이터베이스에 접속할 수 있고, 네트워크마다 컨테이너를 나누어 격리할 수도 있습니다.

-p 127.0.0.1:8080:3000은 호스트의 8080번 포트로 온 연결을 컨테이너의 3000번 포트로 넘깁니다. 주소를 빼고 -p 8080:3000으로 쓰면 호스트의 모든 인터페이스에 포트가 열리는데, Docker는 이를 위해 iptables 규칙을 직접 넣으므로 호스트에서 ufw 같은 방화벽을 설정해 두었더라도 그 규칙을 거치지 않고 열릴 수 있습니다. 개발용 데이터베이스가 인터넷에 그대로 드러나는 사고가 이렇게 생기므로, 바깥에 열 필요가 없는 포트는 127.0.0.1에만 엽니다.

Kubernetes 네트워크 모델

Kubernetes는 한 단계 더 높은 추상화를 둡니다. Pod와 Service 같은 객체가 무엇이고 어떻게 관리되는지는 운영체제 교재의 컨테이너 오케스트레이션과 실무에서 다루고, 여기서는 그 네트워크를 봅니다. 일반적인 Pod는 클러스터 안에서 고유한 IP를 받고, 한 Pod 안의 컨테이너들은 네트워크를 함께 써서 localhost로 서로를 부릅니다. 어느 노드에 있든 Pod끼리는 NAT 없이 서로의 주소로 통신할 수 있다는 것이 Kubernetes 네트워크 모델의 약속이며, 실제로 그렇게 이어 주는 일은 CNI(container network interface) 플러그인이 맡습니다.

CNI 구현특징
Calico라우팅과 BGP로 Pod의 경로를 알리고, 네트워크 정책을 지원한다
Flannel노드 사이에 Pod의 트래픽을 나르는 단순한 구현이며, 정책은 따로 갖춰야 한다
Cilium리눅스 커널의 eBPF로 데이터 경로와 정책, 관측을 처리한다
AWS VPC CNIPod에 VPC의 주소를 바로 준다. 인스턴스마다 붙일 수 있는 주소 수가 한도가 된다

Pod는 언제든 사라지고 새로 생기며 그때마다 주소가 바뀌므로, 클라이언트가 Pod의 주소를 직접 쓰기는 어렵습니다. 그래서 Service가 같은 일을 하는 Pod들을 라벨 선택자로 묶고, 변하지 않는 이름과 가상 IP(ClusterIP)를 줍니다. 선택자에 맞는 Pod의 주소와 준비 상태는 EndpointSlice에 기록되고, 각 노드의 kube-proxy나 CNI가 이 정보로 iptables, IPVS, eBPF 같은 규칙을 만들어 ClusterIP로 온 연결을 실제 Pod로 보냅니다.

Kubernetes · Service와 엔드포인트

EndpointSlice는 선택 정보이며, 요청이 통과하는 프록시는 아닙니다

선택자가 있는 일반 ClusterIP Service의 관계입니다. EndpointSlice가 주소·준비 상태를 제공하고, 실제 데이터 경로는 연결·흐름의 대상을 고릅니다. headless·ExternalName·선택자 없는 Service는 제외합니다.

선택자가 있는 Service와 선택된 Pod 엔드포인트일반적인 ClusterIP Service에서 선택자에 맞는 Pod의 엔드포인트가 EndpointSlice로 관리되는 관계입니다. 연결·흐름은 데이터 경로 구현이 선택한 사용 가능한 엔드포인트로 전달됩니다. EndpointSlice는 요청이 통과하는 프록시가 아니며 headless·ExternalName·선택자 없는 Service는 제외합니다.EndpointSlice선택된 주소 · 준비 상태Service안정된 ClusterIPPod A엔드포인트Pod B엔드포인트선택 정보연결·흐름의 대상 선택
선택자가 있는 Service와 선택된 Pod 엔드포인트일반적인 ClusterIP Service에서 선택자에 맞는 Pod의 엔드포인트가 EndpointSlice로 관리되는 관계입니다. 연결·흐름은 데이터 경로 구현이 선택한 사용 가능한 엔드포인트로 전달됩니다. EndpointSlice는 요청이 통과하는 프록시가 아니며 headless·ExternalName·선택자 없는 Service는 제외합니다.EndpointSlice선택된 주소준비 상태Service안정된 ClusterIPPod A엔드포인트Pod B엔드포인트선택 정보대상선택

점선은 엔드포인트 선택 정보, 아래 실선은 연결·흐름의 대상 관계입니다. Service는 자체 수명 동안 안정된 가상 IP를 제공하며 실제 kube-proxy/eBPF 등 구현은 생략했습니다. HTTP keep-alive 요청마다 다른 Pod를 고른다는 뜻은 아닙니다.

EndpointSlice는 요청이 지나가는 프록시가 아니라 선택된 주소의 목록입니다. 다음 코드는 선택자와 준비 상태로 EndpointSlice의 내용과 새 연결의 후보를 고르는 과정을 흉내 냅니다.

pods = [
    {"name": "A", "labels": {"app": "web"}, "ready": True},
    {"name": "B", "labels": {"app": "web"}, "ready": False},
    {"name": "C", "labels": {"app": "api"}, "ready": True},
]
selector = {"app": "web"}
selected = [p for p in pods if all(p["labels"].get(k) == v for k, v in selector.items())]
endpoints = [(p["name"], "ready" if p["ready"] else "not ready") for p in selected]    # EndpointSlice에 담기는 것
targets = [p["name"] for p in selected if p["ready"]]                                    # 새 연결의 후보
print("EndpointSlice:", endpoints)
print("새 연결의 후보:", targets)
EndpointSlice: [('A', 'ready'), ('B', 'not ready')]
새 연결의 후보: ['A']

선택자에 맞는 A와 B가 모두 EndpointSlice에 기록되지만, 준비되지 않은 B는 새 연결의 후보에서 빠집니다. 13장 2편의 readiness가 여기서 쓰이는 것입니다. 라벨이 다른 C는 아예 기록되지 않습니다. 대상은 보통 연결 단위로 고르므로, HTTP Keep-Alive로 한 번 맺은 연결의 요청들은 계속 같은 Pod로 갑니다. Pod를 늘렸는데 부하가 새 Pod로 가지 않는다면, 클라이언트가 예전에 맺은 연결을 계속 쓰고 있는지 봅니다.

Service 종류주소생각할 점
ClusterIP클러스터 안에서만 쓰는 가상 IP기본값이다. clusterIP: None인 headless Service는 가상 IP 없이 DNS로 Pod의 주소를 직접 알려 준다
NodePort모든 노드의 정해진 포트바깥에서 닿으려면 노드의 주소와 방화벽이 허락해야 한다
LoadBalancer클라우드의 로드 밸런서클라우드나 컨트롤러가 지원해야 실제로 만들어진다
ExternalName바깥의 DNS 이름DNS의 CNAME만 돌려주고 프록시를 만들지 않는다

Service는 클러스터 안의 DNS에 web.default.svc.cluster.local 같은 이름으로 등록되므로, 같은 네임스페이스의 Pod는 web이라는 이름만으로 부를 수 있습니다. 바깥의 HTTP 트래픽을 경로에 따라 여러 Service로 나누는 규칙은 Ingress로 적고, 그 규칙을 실제로 집행하는 Ingress 컨트롤러가 따로 있어야 합니다. 오래 쓰여 온 커뮤니티의 Ingress NGINX는 2026년 3월에 유지보수가 끝나 더는 버그 수정이나 보안 패치가 나오지 않으므로, 새로 구성한다면 다른 컨트롤러나, Ingress보다 표현력이 큰 Gateway API를 검토합니다.

더 깊이: 바뀌고 있는 kube-proxy의 모드

ClusterIP로 온 연결을 Pod로 보내는 규칙을 kube-proxy가 만든다면, 그 규칙을 어떤 커널 기능으로 만들지는 kube-proxy의 모드가 정합니다. Kubernetes 문서에 따르면 Linux 노드에는 iptables, ipvs, nftables 세 모드가 있고, 모드를 지정하지 않으면 Kubernetes 1.37에서는 iptables를 씁니다. iptables 모드는 Service마다, 엔드포인트의 IP마다 규칙을 몇 개씩 만들기 때문에, Pod와 Service가 수만 개인 클러스터에서는 규칙도 수만 개로 늘어 Service나 EndpointSlice가 바뀔 때 커널의 규칙을 고치는 데 오래 걸릴 수 있습니다.

nftables 모드는 iptables의 후속인 nftables API로 같은 일을 하며, 문서는 엔드포인트의 변화를 더 빠르고 효율적으로 반영하고 커널 안의 패킷 처리도 더 효율적이라고 설명합니다. 그래서 앞으로의 버전에서 기본 모드가 nftables로 바뀔 예정입니다. 반대로 ipvs 모드는 1.35부터 지원 중단(deprecated) 상태이며, 1.40부터 기본으로 꺼지고 1.43에서 없어질 예정입니다. 문서는 업그레이드하면서 모드가 뜻하지 않게 바뀌지 않도록 클러스터마다 kube-proxy의 모드를 설정에 명시해 두라고 권합니다.

정리

  • VPC는 클라우드 안의 가상 사설망이고, AZ마다 서브넷을 나누며, AWS는 서브넷마다 다섯 주소를 예약하므로 /24에서는 251개를 씁니다.
  • 공개 서브넷은 0.0.0.0/0 경로가 인터넷 게이트웨이를 가리키는 서브넷이며, 사설 서브넷은 NAT 게이트웨이로 안에서 시작한 연결만 나갑니다.
  • 보안 그룹은 연결을 기억해 응답을 자동으로 통과시키고, NACL은 상태가 없어 응답이 돌아오는 임시 포트까지 열어야 합니다.
  • Docker는 네트워크 네임스페이스와 가상 브리지, NAT로 컨테이너를 잇고, 사용자 정의 네트워크는 이름으로 컨테이너를 찾게 하며, 포트는 필요한 주소에만 엽니다.
  • Kubernetes의 Pod는 NAT 없이 서로 통신하는 고유한 주소를 받고, Service는 라벨 선택자와 EndpointSlice로 준비된 Pod에 연결을 보냅니다.
  • 대상은 연결 단위로 고르므로 Keep-Alive 연결은 같은 Pod에 머물고, 바깥의 HTTP 트래픽은 Ingress나 Gateway API와 그 컨트롤러가 나눕니다.

연습 문제

1. 더 큰 서브넷

AWS의 /16 VPC를 /20 서브넷으로 나누면 서브넷은 몇 개가 되고, 서브넷 하나에서 서버에 줄 수 있는 주소는 몇 개인가요? 컨테이너마다 VPC의 주소를 주는 클러스터라면 왜 큰 서브넷이 필요할까요?

해설 보기

/16에서 /20으로 네 비트를 더 쓰므로 2⁴ = 16개의 서브넷이 생깁니다. /20 하나에는 2¹² = 4,096개의 주소가 있고, AWS가 예약하는 다섯 개를 빼면 4,091개를 쓸 수 있습니다. 컨테이너마다 VPC의 주소를 주면 서버 한 대가 수십 개의 주소를 쓰고, 배포하는 동안에는 새 Pod와 예전 Pod가 함께 있어 더 많이 씁니다. /24의 251개로는 서버 몇 대만으로도 바닥나 새 Pod가 주소를 받지 못하므로, 처음부터 큰 서브넷을 잡아 둡니다.

2. 응답이 막힌 웹 서버

웹 서버가 있는 서브넷의 NACL이 들어오는 방향으로 443만, 나가는 방향으로도 443만 허용합니다. 보안 그룹은 들어오는 443을 허용합니다. 사용자가 이 웹 서버에 접속하면 어떻게 되나요?

해설 보기

사용자의 요청은 443번 포트로 들어오므로 NACL과 보안 그룹을 모두 통과합니다. 하지만 서버의 응답은 출발지가 443이고 목적지가 사용자의 임시 포트, 예를 들어 52000번입니다. NACL의 나가는 규칙은 목적지 포트가 443인 것만 허용하므로 응답이 막히고, 사용자는 연결이 맺어지지 않는 장애를 겪습니다. 보안 그룹은 연결을 기억하므로 응답을 막지 않지만, NACL은 패킷마다 따로 판단하기 때문입니다. 나가는 규칙에 1024부터 65535까지의 포트를 허용해야 합니다.

3. 준비된 Pod와 이미 맺은 연결

본문의 예에서 B가 나중에 준비 상태가 되었습니다. 이미 A와 HTTP Keep-Alive 연결을 맺어 둔 클라이언트의 다음 요청은 B로 갈 수 있나요? 새 연결은 어떻게 되나요?

해설 보기

B가 준비되면 EndpointSlice의 B가 ready로 바뀌고 A와 B가 새 연결의 후보가 됩니다. 하지만 대상은 연결을 맺을 때 고르므로, 이미 A와 맺어 둔 Keep-Alive 연결의 요청들은 그 연결이 끊길 때까지 계속 A로 갑니다. 새로 맺는 연결만 A와 B 가운데 하나로 갑니다. 그래서 Pod를 늘린 직후에는 새 Pod로 부하가 천천히 옮겨 가며, 오래 유지되는 연결을 쓰는 클라이언트가 많다면 연결의 최대 수명을 정해 주기적으로 다시 맺게 하는 방법을 씁니다.

다음 장 트러블슈팅 방법론에서는 네트워크 문제를 계층과 구간으로 나누어 원인을 좁혀 가는 방법과, 가설을 세우고 확인하는 순서를 살펴봅니다.