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를 통해 컨테이너가 시스템을 독점하지 못하도록 제어하는 법을 공부할 것이다.
'개발일기 > Docker' 카테고리의 다른 글
7. 쉬어가는 코너: 확장을 위한 구조로 코드 리팩토링하기(+ go, linux, container) (1) | 2025.07.01 |
---|---|
5. pivot_root로 루트 파일 시스템 바꾸기 (0) | 2025.06.16 |
4. Mount 네임스페이스로 파일시스템 분리하기 (0) | 2025.05.30 |
3. UTS 네임스페이스로 호스트명 분리하기 (0) | 2025.05.29 |
2. PID 네임스페이스로 프로세스 격리하기 (심화) (1) | 2025.05.29 |
댓글