개발일기/Docker

6. Network 네임스페이스로 독립 네트워크 구성하기

ignuy 2025. 6. 26.

Go로 "컨테이너스러운" 프로세스를 만들어보자.

 

지난 포스팅에서 pivot_root()를 사용해 Mount 네임스페이스를 완전히 격리하였다. 오늘은 컨테이너 격리의 마지막 단계, Network 네임스페이스 분리하기이다. 본 챕터를 이용해서 독립된 네트워크 네임스페이스를 생성하고 컨테이너 안에서는 고유한 네트워크 인터페이스와 IP를 사용하며 호스트와는 완전히 격리된 네트워크 공간을 확보해보도록 하자.

 

🔍 Network 네임스페이스?

Network 네임스페이스란 리눅스 시스템 내에서 네트워크 자원(IP, 포트, 라우팅 테이블 등)을 독립적으로 분리할 수 있게 해주는 기능이다.

🔧 리눅스는 네트워크 자원을 공유

리눅스에서는 기본적으로 시스템에 존재하는 모든 네트워크 자원(예: 인터페이스, IP 주소, 포트 등)을 모든 프로세스가 공유하여 사용한다. 아래 특징을 확인하자.

  • 모든 프로세스가 같은 eth0 인터페이스를 사용
  • 같은 IP 주소, 같은 포트 공간을 공유
  • iptables, routing table, ARP table 등도 전역적으로 관리됨

이건 마치 한 사무실에서 여러 팀이 같은 전화번호, 같은 네트워크 설정을 쓰는 것과 같다.

리눅스 네임스페이스는 이런 공유 자원들을 각 프로세스 그룹이 독립적으로 갖게 해주는 기술인 것이다.

🔧네트워크 환경의 구성

1. 자기만의 lo (loopback) 장치

새로운 네트워크 네임스페이스엔 항상 lo 인터페이스만 기본으로 들어 있다.

2. 별도의 eth0 등 네트워크 인터페이스

3. 자기만의 라우팅 테이블, iptables, ARP 테이블

각 네임스페이스는 라우팅 테이블을 따로 관리한다. iptables로 방화벽 규칙을 걸어도 다른 네임스페이스엔 영향이 없게 된다. arp, ip link, ip route도 모두 분리되어 있다.

🧪 실험: CLONE_NEWNET만 추가한 상태

sysProcAttr 속성 중 CLONE_NEWNET 플래그를 사용하면 해당 프로세스는 자기만의 네트워크 환경을 가지게 된다.

🛠️ run() 함수 수정

func run() {
	fmt.Printf("Parent: Running %v as PID %d\n", os.Args[2:], os.Getpid())

	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// isolation namespace
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID |
			syscall.CLONE_NEWUTS |
			syscall.CLONE_NEWNS |
			syscall.CLONE_NEWNET, // Add network NS
	}

	must(cmd.Run())
}

🧪 실행

(본 과정은 5. pivot_root로 루트 파일 시스템 바꾸기 과정을 선행해야 수행된다. debootstrap으로 Ubuntu 20.04 (focal)의 루트 파일시스템을 /tmp/ubuntufs에 설치한 후 수행하자.)

아래 명령어로 빌드 후 containerM을 실행하자.

go build -o containerM
./containerM run /bin/bash

 

컨테이너 안에서 네트워크 환경을 살피기 위해서 아래 명령어를 입력해보자.

ip a

실험 결과

network 네임스페이스를 분리한 직후엔 lo 만 존재한다.

network namespace를 생성만 해서는 안된다. 아무것도 없는 네트워크 환경에 인터페이스를 만들어주고 브릿지에 붙여줘야 진짜 네트워크가 작동하게 된다.

🏗️ 컨테이너용 veth 페어 만들기

🧠 네트워크 인터페이스

네트워크 인터페이스(interface)는 컴퓨터가 네트워크에 연결되는 "출입구"이다. 흔히 볼 수 있는 eth0, wlan0, lo 같은 이름이 붙게 된다. 네트워크 케이블을 꽂는 랜카드(NIC)도 하나의 인터페이스이며 가상 머신이나 컨테이너에선 가상의 인터페이스도 만들 수 있다.

🧠 브릿즈(Bridge)

브릿지는 여러 네트워크 인터페이스를 하나로 묶는 "가상의 스위치"이다. 집에서 공유기가 여러 기기에 IP를 주듯이 브릿지는 여러 인터페이스를 묶고 IP도 할당할 수 있다. 도커에서는 기본적으로 docker0이라는 브릿지를 만들어 놓고 컨테이너마다 거기에 연결된 가상 인터페이스를 하나씩 준다.

🧠 가상의 인터페이스(veth) 만들기

따라서, 독립된 환경의 컨테이너 내부에서 외부로 통신이 가능하게 하기 위해서는 가상의 인터페이스(veth)를 만들어야 한다. 아래와 같은 순서를 따라가며 veth를 만들어보자.

1. veth (Virtual Ethernet) 쌍 만들기

  • veth0, veth1은 마치 두 개의 랜선이 연결된 것처럼 짝으로 동작

2. veth의 한쪽(veth1)을 컨테이너에 연결

  • veth1을 컨테이너의 네트워크 네임스페이스로 이동
  • ip link set veth1 netns <PID>로 네임스페이스 안으로 "이사"시킴
  • 컨테이너 안에서 veth1의 이름을 eth0으로 변경한 후 IP를 설정

3. veth0은 브릿지(br0 등)에 붙이기

이 과정 후엔 호스트 ↔ 컨테이너 간 통신 가능하다.

⚙️ 인터페이스 설정

과정은 알았으니 직접 인터페이스를 설정해보자. 이 인터페이스 설정은 Go에서 직접 하려면 netlink를 사용해야 해서 복잡하다. 따라서, 호스트에서 외부 스크립트를 실행해서 설정하도록 하자.

🧰 1. veth 및 브릿지 생성 스크립트 (setup_host_net.sh)

호스트 환경에서 실행되는 이 스크립트는 다음과 같은 역할을 한다.

  • 브릿지(br0)가 없다면 생성
  • veth 쌍을 만들고
  • 한쪽은 컨테이너 네임스페이스로 보내고
  • 다른 한쪽은 브릿지에 붙인다
  • NAT 설정을 통해 외부 네트워크와도 통신 가능하게 만든다
#!/bin/bash
set -e

CONTAINER_PID=$1
VETH_HOST=veth_host_$CONTAINER_PID
VETH_CONT=veth_cont_$CONTAINER_PID
BRIDGE=br0

# Create bridge if it does not exist
if ! ip link show $BRIDGE > /dev/null 2>&1; then
  ip link add name $BRIDGE type bridge
  ip addr add 10.0.0.1/24 dev $BRIDGE
  ip link set $BRIDGE up
fi

# Create veth pair
ip link add $VETH_HOST type veth peer name $VETH_CONT

# Move container side veth to container network namespace
ip link set $VETH_CONT netns $CONTAINER_PID

# Bring up host side veth and add to bridge
ip link set $VETH_HOST up
ip link set $VETH_HOST master $BRIDGE

# Enable IP forwarding
sysctl -w net.ipv4.ip_forward=1

# Setup NAT using iptables
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 ! -o br0 -j MASQUERADE

containerM을 실행하면 호스트 프로세스에서 위 setup_host_net.sh 스크립트를 실행할 것이다. 주석과 위 이론을 참고한다면 흐름을 따라올 수 있다.

 

💡

다만, 제일 밑에서 실행되는 두 줄은 NAT(Network Address Translation) 마스커레이드(Masquerade) 설정이다. 이는 사설 네트워크 내의 여러 장비가 하나의 공인 IP 주소를 사용하여 인터넷과 통신할 수 있도록 하는 네트워크 주소 변환 기술의 한 종류이다. 컨테이너 브리지(br0)를 통해 외부로 나가는 패킷이 호스트의 외부 네트워크 인터페이스로 제대로 전달되어야 하는데, 기본적으로 Linux는 브리지-호스트 간 포워딩을 허용하지 않는다. 

따라서, 위 NAT 설정을 해주어야 br0 브릿지의 10.0.0.0/24 대역(컨테이너 서브넷)에서 나가는 패킷에 대해 소스 IP가 호스트 IP로 변환된다.

 

🧰 2. 컨테이너 내부 네트워크 초기화 (setup_container_net.sh)

이제 컨테이너 안에서 네트워크 인터페이스 이름을 eth0으로 바꾸고, IP 주소와 라우팅 설정을 해보자. 이 작업은 컨테이너 안에서 실행되어야 하며, 네임스페이스 안으로 들어간 veth를 활성화하는 역할을 한다.

#!/bin/bash
set -e

# Wait up to 5 seconds for veth interface to appear
for i in {1..50}; do
  VETH=$(ip -o link | awk -F': ' '{print $2}' | grep '@' | cut -d@ -f1 | head -n 1)
  if [[ -n "$VETH" ]]; then
    break
  fi
  sleep 0.1
done

# Exit if no veth found
if [[ -z "$VETH" ]]; then
  echo "[ERROR] VETH interface not found"
  exit 1
fi

# Rename interface and configure
ip link set "$VETH" name eth0
ip link set lo up
ip link set eth0 up
ip addr add 10.0.0.2/24 dev eth0
ip route add default via 10.0.0.1

setup_container_net.sh는 컨테이너 안의 / 루트에 미리 복사되어 있어야 하며, child() 함수 안에서 자동으로 실행되도록 되어 있다.

 

💡

컨테이너의 실행이 호스트에서 비동기로 이루어지고 있기 때문에, 스크립트 실행 시점에서 가상 인터페이스가 올라올지 안 올라올지는 미지수이다. 따라서 5초간 wait을 걸어둔다.

🧰 3. 실행 순서에 맞게 main.go 수정

  • run() 수정
func run() {
	fmt.Printf("Parent: Running %v as PID %d\n", os.Args[2:], os.Getpid())

	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// isolation namespace
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWPID |
			syscall.CLONE_NEWUTS |
			syscall.CLONE_NEWNS |
			syscall.CLONE_NEWNET,
	}

	// setting Environment variable
	cmd.Env = append(os.Environ(), "SETUP_NET=1")

	must(cmd.Start())

	// network setup: run host-side network setup script with child PID
	must(exec.Command("bash", "setup_host_net.sh", fmt.Sprintf("%d", cmd.Process.Pid)).Run())

	// wait for child process to finish
	must(cmd.Wait())
}

이제 호스트 환경에서 컨테이너의 실행은 비동기로 이루어지며, 컨테이너 실행 직후 호스트 네트워크 설정 스크립트가 실행된다.

  • child() 수정
// make .pivot_root directory
must(os.MkdirAll(putOld, 0700), "create putOld")

must(exec.Command("cp", "setup_container_net.sh", newRoot+"/setup_container_net.sh").Run(), "copy setup_net.sh")

// pivot_root(new_root, put_old)
must(syscall.PivotRoot(newRoot, putOld), "pivot_root")

pivot_root를 실행하여 마운트 네임스페이스를 분리하기 전에 setup_container_net 스크립트를 새로운 루트 경로에 복사해두자.

 

// mount /dev
must(syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""), "mount /dev")

devNull := (1 << 8) | 3
syscall.Mknod("/dev/null", syscall.S_IFCHR|0666, devNull)

마운트 네임스페이스를 분리할 때, /proc, /sys, /dev는 우리가 직접 설정해서 집어넣었던 것을 기억할 것이다. 이들은 모두 가상 파일 시스템(pseudo-filesystem)으로, 시스템과 커널, 장치에 접근하기 위한 인터페이스 역할을 수행한다. 실제로 디스크에 저장된 데이터는 아니지만, 파일 시스템처럼 접근할 수 있어 매우 유용하게 사용된다.

 

그중에 /dev는 마운트 할 때, tmpfs라는 메모리 기반의 임시 파일 시스템을 기반으로 마운트를 해두었다. 이 공간은 정말 메모리의 빈 공간으로 아무것도 없다. 따라서, /dev에 속한 디바이스들은 우리가 만들어서 넣어줘야 하는데, 이번 시간에 필요한 /dev/null만을 우선적으로 만든 코드이다. 추후 코드를 정리하는 포스팅에서 자세하게 다룰 것이다. 일단 넘어가자.

 

다양한 리눅스 패키지들이 이 /dev/ 안에 있는 디바이스를 활용하는 방법으로 구현되어 있다. 추후 실험에서 사용할 iproute2 패키지의 ip 명령어가 /dev/null을 사용하고 있으므로 반드시 위 과정으로 /dev/null 디바이스를 만들어두자.

// network configuration
if os.Getenv("SETUP_NET") == "1" {
    pid := fmt.Sprintf("%d", syscall.Getpid())
    cmd := exec.Command("/setup_container_net.sh", pid)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    must(cmd.Run(), "run setup_net.sh")
}

이제 네트워크 설정을 수행할 스크립트 실행 코드를 넣고 run()에도 환경 변수를 설정하자.

cmd.Env = append(os.Environ(), "SETUP_NET=1")

🧪 최종 실험

sudo ./containerM run /bin/bash

컨테이너를 실행한 후 네트워크 인터페이스를 조회해보자.

ip a

test 하는 환경에 따라 세부적인 정보는 다르겠지만 이제 eth0라고 하는 가상 인터페이스가 컨테이너 내부에 생긴 것을 볼 수 있다.

 ping 8.8.8.8

외부 DNS에 접근해보자.

위처럼 정상적으로 동작한다면 컨테이너는 완전히 격리된 네트워크 환경에서 자체 인터페이스를 사용하며, 브릿지를 통해 외부와도 통신할 수 있게 된 것이다. 이제 성공적으로 컨테이너가 외부로 요청을 보낼 수 있다.

🔜 다음 이야기

이제 네트워크마저 분리했다.
다음 파트에서는 진짜 리눅스 컨테이너처럼 CPU, 메모리 등 리소스 제한을 구현해보자. cgroups를 통해 컨테이너가 시스템을 독점하지 못하도록 제어하는 법을 공부할 것이다.

댓글