작성일 :

컨테이너는 어떻게 네트워크를 격리하는가

전통적인 서버 환경에서는 하나의 물리 머신이 하나의 OS를 실행하고, 모든 애플리케이션이 같은 네트워크 스택을 공유했습니다. IP 주소, 라우팅 테이블, 포트 공간이 모두 하나였습니다.

가상 머신(VM)이 등장하면서 하나의 물리 서버에서 여러 OS를 실행할 수 있게 되었지만, 각 VM마다 완전한 OS를 올려야 합니다. VM 하나에 수백 MB의 메모리가 필요하고, 부팅에도 수십 초가 걸립니다.

이 문제를 다르게 접근한 것이 컨테이너(Container)입니다. 컨테이너는 애플리케이션과 실행에 필요한 라이브러리, 설정 파일을 하나로 묶어 격리된 환경에서 실행하는 단위입니다.

VM이 하이퍼바이저로 하드웨어를 가상화하는 것과 달리, 컨테이너는 호스트 OS의 커널이 직접 격리를 제공합니다. Linux 커널의 Namespace(프로세스, 네트워크, 파일시스템 등의 격리)와 cgroups(CPU, 메모리 등의 자원 제한)가 그 기능입니다.

게스트 OS가 필요 없으므로 하나의 서버에서 수십, 수백 개의 컨테이너를 동시에 실행할 수 있습니다.

그런데 커널을 공유하면서도 각 컨테이너가 자신만의 IP 주소와 네트워크 인터페이스를 갖는다고 했습니다. 커널은 하나인데 네트워크 스택은 어떻게 분리할까요?

앞에서 언급한 Namespace 중 네트워크 네임스페이스(Network Namespace)가 이를 담당합니다. 커널 안에 독립된 네트워크 스택을 여러 개 만들어, 각 네임스페이스에 별도의 네트워크 인터페이스, IP 주소, 라우팅 테이블, iptables 규칙을 부여합니다. 컨테이너 하나가 네트워크 네임스페이스 하나에 대응하므로, 각 컨테이너는 서로의 네트워크를 볼 수 없습니다.

이 글에서는 네트워크 네임스페이스의 동작 원리와, 격리된 컨테이너들이 서로 그리고 외부와 통신하는 방법을 살펴봅니다. 네트워크 통신의 원리 시리즈에서 다룬 IP, 라우팅, 인터페이스 개념을 기반으로 합니다.


가상화와 컨테이너의 차이

두 방식의 구조를 비교하면 차이가 드러납니다.

VM (가상 머신)

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────┐
│                     호스트 OS                       │
├─────────────────────────────────────────────────────┤
│                    하이퍼바이저                     │
├─────────────────┬───────────────┬───────────────────┤
│      VM 1       │      VM 2     │       VM 3        │
│  ┌───────────┐  │  ┌───────────┐│  ┌───────────┐    │
│  │ 게스트 OS │  │  │ 게스트 OS ││  │ 게스트 OS │    │
│  ├───────────┤  │  ├───────────┤│  ├───────────┤    │
│  │    앱     │  │  │    앱     ││  │    앱     │    │
│  └───────────┘  │  └───────────┘│  └───────────┘    │
└─────────────────┴───────────────┴───────────────────┘

OS 커널은 하드웨어를 직접 제어합니다. CPU 시간을 프로세스에 배분하고, 메모리 영역을 할당하고, 네트워크 카드를 통해 패킷을 송수신합니다.

하나의 물리 서버에서 여러 OS를 실행하려면 여러 커널이 동시에 동작해야 합니다. 두 커널이 같은 메모리 영역에 데이터를 쓰거나, 같은 네트워크 카드를 동시에 제어하면 충돌이 발생합니다.

하이퍼바이저(Hypervisor)가 이 충돌을 방지합니다. 실제 하드웨어 위에서 동작하면서, 각 VM에 가상 CPU, 가상 메모리, 가상 네트워크 카드를 제공합니다. 각 VM의 커널은 가상 하드웨어를 제어하고, 하이퍼바이저가 이를 실제 하드웨어에서 처리합니다.


컨테이너

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────────────────────┐
│                     호스트 OS                       │
│                    (커널 공유)                      │
├─────────────────┬───────────────┬───────────────────┤
│   컨테이너 1    │   컨테이너 2  │    컨테이너 3     │
│  ┌───────────┐  │  ┌───────────┐│  ┌───────────┐    │
│  │    앱     │  │  │    앱     ││  │    앱     │    │
│  │   (격리)  │  │  │   (격리)  ││  │   (격리)  │    │
│  └───────────┘  │  └───────────┘│  └───────────┘    │
└─────────────────┴───────────────┴───────────────────┘

컨테이너는 여러 커널을 실행하는 대신, 호스트 커널 하나를 공유합니다.

커널이 하나이므로 하드웨어 충돌이 발생하지 않고, 하이퍼바이저가 필요 없습니다. 호스트 커널이 직접 CPU, 메모리, 네트워크를 관리합니다.

다만 하나의 커널에서 동작하는 프로세스들은 기본적으로 시스템 자원을 공유합니다. 서로의 프로세스 목록을 볼 수 있고, 같은 IP 주소와 포트를 사용하며, 같은 파일시스템에 접근합니다.

컨테이너 격리는 이 공유를 제한하는 방식으로 동작합니다. 커널의 Namespace가 컨테이너마다 프로세스 목록, 네트워크 스택, 파일시스템을 분리하고, cgroups가 CPU와 메모리 사용량을 제한합니다. 각 컨테이너는 자신에게 할당된 자원만 볼 수 있으므로, 커널을 공유하면서도 독립된 환경으로 동작합니다.


Linux Network Namespace

앞에서 Namespace가 컨테이너마다 자원을 분리한다고 했습니다. Linux는 격리 대상에 따라 여러 종류의 네임스페이스를 제공합니다.

  • PID: 프로세스 ID
  • Mount: 파일시스템 마운트
  • UTS: 호스트명
  • IPC: 프로세스 간 통신
  • User: 사용자/그룹 ID
  • Network: 네트워크 스택

Docker는 컨테이너를 생성할 때 이들을 조합하여 격리 환경을 구성합니다.


이 글에서 다루는 Network Namespace는 네트워크 인터페이스, IP 주소, 라우팅 테이블, iptables 규칙, 소켓을 격리합니다.

네임스페이스 실험

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 'red'라는 이름의 새 네트워크 네임스페이스 생성
sudo ip netns add red

# 시스템에 존재하는 네임스페이스 목록 확인
ip netns list
# red

# red 네임스페이스 내에서 네트워크 인터페이스 확인
sudo ip netns exec red ip link
# 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN
#    (루프백 인터페이스만 존재하며, 아직 활성화되지 않은 상태)

# 호스트의 네트워크 인터페이스와 비교
ip link
# 1: lo: ...
# 2: eth0: ...
# (호스트의 인터페이스는 red 네임스페이스에서 보이지 않음)

새로 만든 red 네임스페이스에는 루프백(자기 자신에게 패킷을 보내기 위한 가상 인터페이스, 127.0.0.1)만 존재합니다. 네트워크 인터페이스는 한 번에 하나의 네임스페이스에만 속하므로, 호스트가 외부 통신에 사용하는 eth0는 red에서 보이지 않습니다. 외부로 나가는 인터페이스가 없으므로, red는 호스트나 다른 네트워크와 통신할 수 없습니다.


veth (Virtual Ethernet) 페어

앞에서 red 네임스페이스가 외부와 통신할 수 없다고 했습니다. 격리된 네임스페이스를 연결하는 것이 veth(Virtual Ethernet) 페어입니다.

veth는 항상 두 개가 쌍으로 생성되는 가상 네트워크 인터페이스입니다.

1
2
3
4
5
6
7
8
┌─────────────────┐         ┌─────────────────┐
│  Namespace A    │         │  Namespace B    │
│                 │         │                 │
│   ┌─────────┐   │         │   ┌─────────┐   │
│   │ veth-a  │◄──┼─────────┼──►│ veth-b  │   │
│   └─────────┘   │  veth   │   └─────────┘   │
│                 │  pair   │                 │
└─────────────────┘         └─────────────────┘

veth-a를 Namespace A에, veth-b를 Namespace B에 배치하면, Namespace A에서 veth-a로 보낸 패킷은 Namespace B의 veth-b에 도착합니다. 반대 방향도 동일합니다. 격리된 두 네임스페이스가 veth 페어를 통해 연결됩니다.

veth 페어 생성

앞에서 만든 red 네임스페이스에 veth 페어를 연결합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# veth 페어 생성: veth-red와 veth-red-br 두 개의 인터페이스가 쌍으로 만들어짐
sudo ip link add veth-red type veth peer name veth-red-br

# veth-red를 red 네임스페이스로 이동 (이제 호스트에서는 안 보임)
sudo ip link set veth-red netns red

# red 네임스페이스 내에서 veth-red에 IP 주소 할당 (192.168.1.10, 서브넷 /24)
sudo ip netns exec red ip addr add 192.168.1.10/24 dev veth-red

# veth-red 인터페이스를 활성화 (UP 상태로 전환)
sudo ip netns exec red ip link set veth-red up

# 루프백 인터페이스도 활성화 (localhost 통신에 필요)
sudo ip netns exec red ip link set lo up
명령 역할
ip link add ... type veth peer name ... veth 페어 생성 (인터페이스 두 개가 쌍으로)
ip link set ... netns ... 인터페이스를 특정 네임스페이스로 이동
ip addr add ... 인터페이스에 IP 주소 부여
ip link set ... up 인터페이스 활성화

이제 red 네임스페이스에는 IP 주소(192.168.1.10)가 할당된 veth-red가 있습니다. 쌍의 다른 쪽인 veth-red-br는 아직 호스트 네임스페이스에 남아 있습니다. 이 인터페이스를 어디에 연결할지는 다음 섹션에서 다룹니다.


Linux Bridge

veth 페어는 두 네임스페이스를 1:1로 연결합니다. 모든 컨테이너가 서로 통신하려면 컨테이너 쌍마다 veth 페어가 필요합니다. 컨테이너가 3개이면 A↔B, A↔C, B↔C로 3쌍, 10개이면 45쌍이 됩니다. 컨테이너가 늘어날수록 필요한 연결 수가 급격히 증가합니다.

하나의 장치에 모든 컨테이너를 연결하면, 컨테이너마다 veth 하나만 있으면 됩니다. 10개의 컨테이너도 45쌍이 아닌 10쌍으로 충분합니다. 장치가 패킷을 받아 목적지 컨테이너로 전달합니다.

Linux Bridge가 이 역할을 합니다. 물리 네트워크의 스위치를 커널이 소프트웨어로 구현한 가상 네트워크 장치입니다.

1
2
3
4
5
6
7
8
9
10
11
12
              Linux Bridge (docker0)
          ┌───────────┬───────────┐
          │           │           │
     veth-1-br   veth-2-br  veth-3-br
          │           │           │
      veth pair   veth pair   veth pair
          │           │           │
       veth-1      veth-2     veth-3
          │           │           │
    ┌─────┴───┐ ┌─────┴───┐ ┌─────┴───┐
    │컨테이너1│ │컨테이너2│ │컨테이너3│
    └─────────┘ └─────────┘ └─────────┘

Bridge 생성

앞에서 호스트 네임스페이스에 남아 있던 veth-red-br를 브릿지에 연결합니다.

1
2
3
4
5
6
7
8
9
10
11
12
# docker0이라는 이름의 브리지(가상 스위치) 생성
sudo ip link add docker0 type bridge

# 브리지에 IP 할당 - 이 IP가 컨테이너들의 게이트웨이가 됨
sudo ip addr add 172.17.0.1/16 dev docker0

# 브리지 활성화
sudo ip link set docker0 up

# veth의 호스트 측 끝(veth-red-br)을 브리지에 연결
sudo ip link set veth-red-br master docker0
sudo ip link set veth-red-br up

veth-red-br가 브릿지에 연결되었으므로, red에서 veth-red로 보낸 패킷은 veth-red-br를 거쳐 브릿지에 도착합니다. 다른 컨테이너의 veth도 같은 브릿지에 연결되어 있으면, 브릿지가 패킷을 해당 컨테이너로 전달합니다.

브릿지에 할당한 IP(172.17.0.1)는 컨테이너가 외부 네트워크로 나갈 때 거치는 게이트웨이입니다. Docker는 컨테이너를 생성할 때 이 과정(브릿지 생성, veth 페어 연결, IP 할당)을 자동으로 수행합니다.


Docker 네트워크 모드

앞에서 Network Namespace, veth, Bridge를 살펴봤습니다. Docker는 이 기반 기술들을 조합하여 여러 네트워크 모드를 제공합니다.

bridge (기본)

--network bridge는 Docker의 기본 네트워크 모드입니다. 앞에서 직접 구성한 Network Namespace, veth 페어, Bridge를 Docker가 자동으로 설정합니다.

1
docker run --network bridge nginx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
          호스트
┌───────────────────────────────────────────────┐
│                                               │
│       Linux Bridge (docker0, 172.17.0.1)      │
│       ┌──────────┬──────────┬──────────┐      │
│       │          │          │          │      │
│    veth-1-br  veth-2-br  veth-3-br    │      │
│       │          │          │          │      │
│   veth pair  veth pair  veth pair     │      │
│       │          │          │          │      │
│    veth-1     veth-2     veth-3       │      │
│       │          │          │    eth0──┼─ 외부
│  ┌────┴───┐ ┌────┴───┐ ┌────┴───┐    │      │
│  │ nginx  │ │ mysql  │ │ redis  │    │      │
│  │ .0.2   │ │ .0.3   │ │ .0.4   │    │      │
│  └────────┘ └────────┘ └────────┘    │      │
│                                               │
└───────────────────────────────────────────────┘

컨테이너를 생성하면 Docker는 veth 페어를 만들고, 한쪽을 컨테이너에, 다른 쪽을 docker0 브릿지에 연결합니다. 각 컨테이너에는 172.17.0.x 대역에서 고유 IP가 할당됩니다.

같은 브릿지에 연결된 컨테이너끼리는 브릿지를 통해 직접 통신할 수 있습니다. 외부 네트워크와의 통신은 호스트의 eth0를 거쳐야 하므로, 컨테이너의 사설 IP를 호스트의 공인 IP로 변환하는 NAT가 필요합니다. Docker는 iptables 규칙으로 이 NAT를 자동 설정합니다.

외부에서 컨테이너에 접근하려면 포트 매핑이 필요합니다. -p 8080:80으로 실행하면, 호스트의 8080번 포트로 들어온 트래픽이 컨테이너의 80번 포트로 전달됩니다.


host

bridge 모드는 컨테이너마다 별도의 Network Namespace를 생성했습니다. host 모드는 Network Namespace를 생성하지 않고, 컨테이너가 호스트의 네트워크 인터페이스, IP 주소, 포트를 그대로 사용합니다.

1
docker run --network host nginx
1
2
3
4
5
6
7
8
호스트 (192.168.1.100)
┌─────────────────────────────────────┐
│                                     │
│   eth0                              │
│     │                               │
│   nginx 컨테이너 → 포트 80 직접 사용│
│                                     │
└─────────────────────────────────────┘

별도의 Namespace가 없으므로 veth 페어나 브릿지도 필요 없고, 사설 IP를 공인 IP로 변환하는 NAT도 거치지 않습니다. 패킷이 변환 없이 eth0로 직접 나가므로 bridge 모드보다 네트워크 지연이 적습니다.

대신 컨테이너와 호스트가 같은 포트 공간을 공유합니다. nginx 컨테이너가 포트 80을 사용하면, 호스트나 다른 컨테이너는 포트 80을 쓸 수 없습니다.


none

앞에서 Network Namespace를 생성하면 루프백만 존재하고 외부와 통신할 수 없다고 했습니다. none 모드가 이 상태입니다. Network Namespace는 생성하지만, veth 페어나 브릿지 연결을 설정하지 않습니다.

1
docker run --network none alpine

컨테이너는 자기 자신에게만 패킷을 보낼 수 있고, 호스트나 다른 컨테이너와 통신할 수 없습니다. 외부 연결 없이 데이터를 처리하는 작업이나, 네트워크 접근 자체를 차단해야 하는 경우에 사용합니다.


container

bridge 모드는 컨테이너마다 별도의 Network Namespace를 생성했습니다. container 모드는 새 Namespace를 만들지 않고, 기존 컨테이너의 Network Namespace에 합류합니다.

1
docker run --network container:nginx alpine

이 명령은 alpine 컨테이너를 nginx 컨테이너의 Network Namespace 안에서 실행합니다. 두 컨테이너가 같은 Namespace에 있으므로 네트워크 인터페이스, IP 주소, 포트 공간을 공유합니다.

1
2
3
4
5
6
7
8
9
┌──────────────────────────────────┐
│   Network Namespace (공유)       │
│   IP: 172.17.0.2                 │
│                                  │
│  ┌──────────┐   ┌──────────┐    │
│  │  nginx   │   │  alpine  │    │
│  │  :80     │   │          │    │
│  └──────────┘   └──────────┘    │
└──────────────────────────────────┘

alpine에서 localhost:80으로 요청하면 같은 Namespace 안의 nginx에 도달합니다. veth 페어나 브릿지를 거치지 않고 Namespace 내부에서 직접 통신합니다.

host 모드와 마찬가지로 포트 공간을 공유하므로, nginx가 포트 80을 사용하면 alpine은 포트 80을 쓸 수 없습니다. Kubernetes의 Pod가 이 방식을 사용합니다. 같은 Pod 안의 컨테이너들은 하나의 Network Namespace를 공유하고, localhost로 서로 통신합니다.


컨테이너에서 외부로

앞의 bridge 모드에서 외부 통신에 NAT가 필요하다고 했습니다. 이 과정을 구체적으로 살펴봅니다.

컨테이너의 IP(172.17.0.2)는 호스트 내부에서만 유효한 사설 주소입니다. 외부 라우터는 사설 주소로 향하는 패킷의 경로를 알지 못하므로, 응답을 돌려보낼 수 없습니다. 컨테이너에서 나가는 패킷의 출발지 주소를 호스트의 IP로 바꿔야 외부와 통신할 수 있습니다.

Linux 커널의 iptables가 이 변환을 수행합니다. iptables는 커널을 통과하는 패킷에 규칙을 적용하여 필터링하거나 주소를 변환하는 도구입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
컨테이너 (172.17.0.2)
         │
         │ 출발지: 172.17.0.2
         ▼
     docker0 브릿지
         │
         ▼
     iptables NAT
     출발지: 172.17.0.2 → 192.168.1.100
         │
         │ 출발지: 192.168.1.100
         ▼
       eth0 → 외부 네트워크

Docker는 컨테이너 생성 시 다음 iptables 규칙을 자동으로 추가합니다.

1
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

이 규칙은 172.17.0.0/16 대역에서 출발한 패킷이 docker0가 아닌 다른 인터페이스(eth0)로 나갈 때, 출발지 주소를 해당 인터페이스의 IP로 변환합니다. 같은 브릿지 안의 컨테이너끼리 통신하는 패킷(-o docker0)은 변환하지 않습니다.


외부에서 컨테이너로

앞에서 살펴본 MASQUERADE는 컨테이너에서 외부로 나가는 패킷의 출발지를 변환했습니다. 반대 방향은 다른 문제입니다. 외부 클라이언트는 컨테이너의 사설 IP(172.17.0.2)를 알지 못하므로, 호스트의 IP와 포트로 요청을 보냅니다. 이 요청을 컨테이너까지 전달하려면 목적지 주소를 변환해야 합니다.

Docker의 -p 옵션이 이 포트 매핑을 설정합니다.

1
docker run -p 8080:80 nginx

이 명령은 호스트의 8080번 포트를 nginx 컨테이너의 80번 포트에 연결합니다. 외부에서 호스트의 8080번 포트로 요청이 들어오면, iptables가 목적지를 컨테이너의 172.17.0.2:80으로 변환하여 전달합니다.

1
2
3
4
5
6
7
8
9
10
11
외부 요청 → eth0 (192.168.1.100:8080)
                    │
                    ▼
               iptables DNAT
               목적지: 192.168.1.100:8080 → 172.17.0.2:80
                    │
                    ▼
              docker0 브릿지
                    │
                    ▼
             nginx 컨테이너 (:80)

Docker는 -p 8080:80 옵션을 다음 iptables 규칙으로 변환합니다.

1
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

호스트의 8080번 포트로 들어오는 TCP 패킷의 목적지를 172.17.0.2:80으로 변환하는 규칙입니다. 앞의 MASQUERADE가 출발지를 변환했다면, DNAT(Destination NAT)는 목적지를 변환합니다.


마무리

  • Network Namespace가 컨테이너마다 네트워크 인터페이스, IP, 라우팅 테이블을 분리합니다.
  • veth 페어가 격리된 Namespace를 연결합니다.
  • Linux Bridge가 여러 컨테이너를 하나의 가상 스위치로 묶습니다.
  • iptables NAT가 컨테이너의 사설 IP와 호스트의 IP 사이에서 주소를 변환합니다.

이 네 가지는 모두 Linux 커널이 이미 가지고 있던 기능입니다. Docker는 새로운 네트워크 기술을 만든 것이 아니라, 커널의 기존 기능을 조합하여 컨테이너 네트워킹을 구성합니다.

하지만 이 구조는 하나의 호스트 안에서만 동작합니다. docker0 브릿지는 같은 호스트의 컨테이너만 연결할 수 있고, 다른 호스트의 브릿지와는 연결되지 않습니다. 여러 호스트에 분산된 컨테이너들은 어떻게 통신할까요?

Part 2에서는 호스트 간 컨테이너 통신을 위한 오버레이 네트워크를 살펴봅니다. VXLAN을 이용한 터널링, Docker Swarm의 ingress 네트워크, 그리고 멀티 호스트 환경에서의 서비스 디스커버리를 다룹니다.



관련 글

시리즈

Tags: Bridge, Docker, Namespace, veth, 네트워크, 컨테이너

Categories: ,