0. Contents
1. Overview
1장과 2장의 범위
- 1장은 선 위의 이야기였다
- curl 한 번이 어떤 모양의 패킷이 되는지 tcpdump 로 찍어서 뜯어봤다
- 2장은 그 패킷이 만들어지기 전과 도착한 뒤, 리눅스 커널 안에서 무슨 일이 일어나는지를 본다
책의 입장: Pod 는 리눅스 프로세스
- Pod 는 마법이 아니라 리눅스 프로세스다
- Pod 의 네트워크는 커널 기능을 조합한 것이다
- 그래서 커널의 네트워킹을 모르면 Pod 네트워크 장애를 못 읽는다
- 01-2 마지막에 “Pod, Service, CNI 는 새로운 네트워크가 아니라 조합이다” 라고 썼다 → 2장은 그 조합의 부품 목록이다
2장의 두 편
- 이 글(02-1): 커널이 가진 정적인 것들을 우분투 VM 에서 하나씩 만들어 본다
- 소켓 — 프로그램이 커널에 부탁하는 창구
- 인터페이스 — 패킷이 드나드는 문
- Network Namespace — 문과 주소와 라우팅이 통째로 격리된 방
- 02-2: 그 문들을 지나는 패킷을 커널이 어떻게 검문하고(Netfilter), 기억하고(Conntrack), 바꾸는지(NAT, 라우팅)
실습 환경
- 오라클 클라우드의 Ubuntu 22.04 VM 에서 했다
- 1장의 macOS 에는
strace,ip netns,nsenter가 없어서 리눅스가 필요하다 - 명령과 출력은 전부 실제 것이다
- 호스트 주소와 MAC 은 가렸다
2. Sockets — 프로그램은 커널에 부탁만 한다
2.1 syscall 과 FD — 손잡이 하나로
curl 이 할 수 있는 일: 부탁뿐
- curl 은 네트워크 카드를 만질 수 없다
- 일반 프로그램은 유저 공간에서 돌고, 하드웨어는 커널의 권한이다
- 그래서 curl 이 할 수 있는 건 커널에게 부탁하는 것뿐이다
- “이 주소로 연결해 줘”
- “이 바이트 보내 줘”
부탁의 창구: 시스템 호출(syscall)
socket(),connect(),write(),read()같은 함수 이름으로 보인다- 부르면 CPU 가 커널 모드로 넘어간다 → 커널 코드가 일을 한 뒤 결과를 들고 돌아온다
손잡이: 소켓과 FD
- 연결 하나에 부탁은 여러 번 오간다 — 연결해라, 보내라, 받아라, 닫아라
- 매번 “127.0.0.1 의 8080 에” 라고 다시 말하지 않는다
- 처음에
socket()으로 손잡이를 하나 받는다 - 이후 부탁은 전부 그 손잡이에 대고 한다
- 처음에
- 그 손잡이가 소켓이다
- 프로그램 쪽에서는 파일 디스크립터(FD)라는 정수 하나로 보인다
책의 문장: “연결이 파일로 표현된다”
- 네트워크 연결이 디스크 파일과 같다는 뜻은 아니다
- 리눅스가 파일, 파이프, 소켓을 전부 같은 모양의 손잡이(FD)로 통일했다는 뜻이다 →
read()/write()를 똑같이 쓸 수 있다 - 2.4 에서
/proc을 열어 보면 그 손잡이가socket:[번호]로 보인다
2.2 네 단계 — socket, bind, listen, accept4
도구: strace
- 프로세스와 커널 사이의 창구에 서서 한 줄씩 적어 주는 도구다
- 무슨 부탁을 했는지
- 커널이 뭐라고 답했는지
- 필터를 걸어 서버가 뜰 때의 부탁 세 가지만 본다
strace -e trace=socket,bind,listen python3 -m http.server 8080 --bind 127.0.0.1
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
listen(3, 5) = 0
서버의 준비 단계: 세 줄이 전부
socket()— “TCP(SOCK_STREAM)용 IPv4(AF_INET) 손잡이 하나 줘”- 커널이 3번을 준다
- 아직 주소도 포트도 없는 빈 손잡이다
bind(3, 127.0.0.1:8080)— “3번은 이제 이 주소, 이 포트 담당”= 0은 성공- 다른 프로세스가 이미 8080 을 잡고 있으면 여기서 실패한다 →
address already in use가 나는 자리다 htons(8080)은 포트를 네트워크 바이트 순서로 적는다는 뜻인데 몰라도 된다 — 그냥 8080 이다
listen(3, 5)— “3번으로 오는 연결을 받아서 줄 세워 둬. 줄은 최대 5명”- 이때부터 커널은 SYN 이 오면 서버 프로그램 몰래 1장의 3-way handshake 를 끝낸다
- 완성된 연결은 대기열에 넣어 둔다
- 5 가 그 대기열(backlog) 길이다 — Go 는 128 이었다
사이에 끼인 AF_UNIX 소켓 4번
- 두 번 만들어졌다 닫힌다
- 같은 컴퓨터 안의 프로세스끼리 쓰는 유닉스 도메인 소켓이다
- Python 이 시작하면서 내부 용도로 만든 것이라 무시한다
- strace 는 내가 시키지 않은 부탁도 전부 보여 준다는 것만 알면 된다
3번 소켓: 연결을 받는 전용
- 준비가 끝나면 3번 소켓은 연결을 받는 일만 한다
- 데이터는 절대 이걸로 주고받지 않는다
- 요청이 올 때 무슨 일이 일어나는지는 떠 있는 서버에 strace 를 붙여서 봤다
sudo strace -f -p <서버 PID> -e trace=accept4,recvfrom,sendto,close
accept4(3): 손님 전용 손잡이
- 서버는 여기서 멈춰 기다린다
- 대기열에 연결이 들어오면 커널이 깨우고, 그 연결 전용 손잡이로 4번을 준다
- 상대는
127.0.0.1:40962, curl 이 받은 임시 포트다(1장 4.1) - 3번은 여전히 문지기로 서 있고, 4번이 이 손님 전용이다
읽고, 쓰고, 반납하기
recvfrom(4)로 요청 78바이트를 읽는다sendto(4)로 응답 헤더 156바이트와 본문 367바이트를 쓴다- 1장에서 tcpdump 로 본
GET / HTTP/1.1과200 OK가 서버 쪽에서는 이렇게 보인다 - 그리고
close(4)— 4번 손잡이를 반납한다
프로그램의 몫과 커널의 몫
- 여기까지 프로그램이 한 일은 손잡이에 대고 부탁한 것뿐이다
- SYN 을 만들고, 세그먼트로 자르고, 헤더를 붙이고, 재전송하는 건 전부 커널이다
- 1장에서 tcpdump 로 본 패킷 모양은 전부 커널이 만든 것이다
2.3 FD 4 가 네 번 — 번호는 돌려쓴다 [실습]
curl 네 번의 기록
- curl 을 네 번 보냈다
- strace 의
accept4줄만 모으면 이렇다
accept4(3, {... sin_port=htons(40962) ...}) = 4
accept4(3, {... sin_port=htons(40968) ...}) = 4
accept4(3, {... sin_port=htons(40980) ...}) = 4
accept4(3, {... sin_port=htons(40996) ...}) = 4
FD 번호: 닫히면 돌려쓴다
- 네 번 다 4 다. 그렇다고 같은 연결이 아니다
- 첫 손님을
close(4)로 닫는 순간 4번은 빈 자리가 된다 - 다음
accept4는 비어 있는 가장 작은 번호를 준다 → 다시 4 가 된다 - FD 번호는 프로세스 안에서만 의미가 있고, 닫히면 돌려쓴다
연결을 구분하는 열쇠: 손님의 포트
- 연결을 구분하는 건 FD 가 아니라 손님의 포트다
- 40962, 40968, 40980, 40996 — 전부 다르다
- 1장 4.1 의 임시 번호표가 여기서 연결을 구분하는 열쇠가 된다
- 02-2 의 Conntrack 이 연결을 기억하는 키도 이 포트다
Go 예제의 손님 FD 5
- Go 예제에서 손님 FD 가 5 였던 것도 같은 이유다
- Go 런타임이 epoll 용으로 4번을 먼저 쓰고 있었다
- 번호 자체에 의미는 없다
2.4 같은 소켓을 세 방향에서 — strace, ss, /proc [실습]
지금 상태 보기: ss
- strace 가 “만드는 과정” 이었다면, 이쪽은 지금 상태를 본다
- 쓴 옵션
- 리스닝 소켓만(
-l) - 이름 변환 없이(
-n) - TCP 만(
-t) - 프로세스와 함께(
-p)
- 리스닝 소켓만(
ss -lntp | grep 8080
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=2595202,fd=3))
LISTEN 줄 읽기
LISTEN은 문지기 상태127.0.0.1:8080은 bind 한 주소0.0.0.0:*는 아직 상대가 없다는 뜻fd=3은 strace 에서 본 그 3번0 5는 Recv-Q 와 Send-Q 인데, LISTEN 소켓에서는 뜻이 특수하다- Send-Q 5 가
listen(3, 5)의 backlog 다 - Recv-Q 0 은 지금 대기열에서 accept 를 기다리는 연결이 0개라는 뜻이다
- Send-Q 5 가
inode 까지 보기
-e를 더하면 inode 가 나온다
ss -lntpe | grep 8080
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=2595202,fd=3)) uid:1001 ino:56686806 ...
inode: 커널 쪽 소켓 식별자
- 이 번호가 커널이 소켓 객체에 붙인 식별자다
- 프로세스 쪽 손잡이(FD)와 커널 쪽 객체(inode)를 잇는 고리를
/proc에서 확인할 수 있다 /proc/PID/fd/는 그 프로세스가 쥐고 있는 FD 를 심볼릭 링크로 보여 주는 가상 디렉터리다
ls -l /proc/2595202/fd
FD 목록: 세 도구가 본 한 소켓
- 0, 1, 2 는 표준 입출력(파일로 보냈다)
- 3 은
socket:[56686806]— ss 의ino:와 같은 번호다 - 아래 셋이 한 소켓이다
- strace 의
socket() = 3 - ss 의
fd=3 ino:56686806 /proc의3 -> socket:[56686806]
- strace 의
세 번호의 역할: PID·FD·inode
- PID 는 프로세스를 가리킨다(시스템 전체에서 유일)
- FD 는 그 프로세스 안의 손잡이를 가리킨다(프로세스 안에서만 의미)
- inode 는 커널 안의 소켓 객체를 가리킨다(프로세스와 무관하게 유일)
- inode 가 따로 필요한 이유: 소켓 하나를 여러 프로세스가 공유할 수 있다
- 서버가 fork 하면 자식도 같은 3번을 물려받는다
- 둘의 3번은 같은 inode 를 가리킨다
손님 쪽 소켓
- 연결을 하나 열어 둔 채로 보면 손님 쪽 소켓도 보인다
-l을 빼면 접속 중인 연결이 나온다
ss -ntp | grep 8080
ESTAB 0 0 127.0.0.1:45286 127.0.0.1:8080 users:(("bash",pid=2595194,fd=3))
ESTAB 0 0 127.0.0.1:8080 127.0.0.1:45286 users:(("python3",pid=2595202,fd=4))
연결 하나, 두 줄
- 손님 쪽 끝(bash 의 3번)과 서버 쪽 끝(python3 의 4번)
- 같은 컴퓨터라 양쪽 다 보인다
- 이때 서버의
/proc/PID/fd에는4 -> socket:[56685485]가 하나 더 생겨 있다 - 02-2 에서 Conntrack 이 같은 연결을 한 줄에 두 방향으로 보여 주는 것과 비교하게 된다
2.5 epoll — 손님이 많을 때
문제: 한 번에 한 부탁에서만 멈춘다
- 2.2 에서 서버가
accept4(3)에서 멈춰 기다린다고 했다 read(4)도 마찬가지다 — 4번에 온 데이터가 없으면 거기서 멈춘다- 손님이 한 명이면 상관없는데, 손님이 둘이면 문제가 된다
read(4)에서 멈춰 있는 동안 5번 손님이 데이터를 보내도 받아 줄 수가 없다
- 프로세스는 한 번에 한 부탁에서만 멈출 수 있다
옛 해법: 손님마다 스레드
- 각자 자기 FD 에서 멈춰 있으면 된다
- 단순하지만 손님 1만 명이면 스레드 1만 개다
- Python 의 http.server 가 이 방식이다
- strace 에
-f를 주면 요청마다 다른 스레드 번호가 보인다
- strace 에
epoll: 멈추는 자리는 하나
- 옛 해법을 뒤집은 것이다
- FD 들을 커널의 장부에 등록해 두고, 프로세스는
epoll_wait()한 곳에서만 멈춘다 - 커널은 등록된 FD 중 어느 것이든 준비되면 그 목록을 들고 프로세스를 깨운다
- 프로세스는 목록에 있는 FD 만 처리하고 다시 잠든다
- 멈추는 자리는 하나인데 지켜보는 FD 는 수만 개가 된다
Go 서버의 동작
- 문지기 3번과 모든 손님 FD 를 장부에 올려 두고
epoll_wait()에서 잔다 - 새 손님이면 “3번 준비됨” 하고 깨어나
accept4(3)을 부른다 - 기존 손님 데이터면 “5번 준비됨” 하고 깨어나
read(5)를 부른다 - Go 가 적은 스레드로 많은 연결을 버티는 방식이다
- 고루틴은 이 위에 얹힌 것이다
Python 실습과의 차이: epoll_wait 없음
- Python 실습의 strace 에 안 보인 이유가 이것이다
- 손님이 한 번에 한 명이라고 가정하고
accept4에서 그냥 기다린다 - 틀린 게 아니라 다른 방식이다
전체 명령과 출력 — 01-sockets
3. Interfaces — 컴퓨터에 달린 문
3.1 문마다 주소 — IP 는 컴퓨터가 아니라 문에 붙는다
인터페이스: 패킷이 지나는 문
- 커널이 만든 패킷은 문을 지나 밖으로 나간다
- 그 문이 인터페이스다
- 문마다 이름이 있고, 문마다 IP 가 붙는다
ip -br addr
VM 한 대의 문 넷
lo— 자기 자신에게 보내는 문ens3— 진짜 이더넷 카드docker0과br-8abe— 도커가 만든 가상 스위치- 그래서 “이 컴퓨터의 IP” 라는 말은 부정확하다 → “이 문의 IP” 가 맞다
- 1장 3.1 에서
0.0.0.0이 “모든 문” 이었던 이유다
문의 두 종류: 진짜 문과 가짜 문
- 진짜 문: 선이나 전파가 실제로 붙어 있는 랜카드
- 가짜 문: 커널이 소프트웨어로 만든 것 —
lo,veth,bridge, 터널, VPN - 커널은 둘을 똑같이 다룬다
- 패킷을 문으로 밀어 넣으면 진짜 문은 선으로 내보낸다
- 가짜 문은 커널 안 어딘가로 보낸다
쿠버네티스 노드의 문 수십 개
- 쿠버네티스 노드에 문이 수십 개 보이는 이유가 이것이다
- 물리 카드는 한두 개이고 나머지는 전부 가짜 문이다
- Pod 가 생길 때마다 하나씩 늘어난다
- 4장에서 그 문을 직접 만든다
책의 ifconfig 출력에서 읽을 것 셋
UP, RUNNING— 켜져 있고(UP) 실제로 연결돼 있다(RUNNING)- UP 만 있고 RUNNING 이 없으면 선이 안 꽂힌 상태다
inet/inet6— 이 문의 IPv4 / IPv6 주소ether— 이 문의 MAC 주소, 1장 5.1 의 “이번 한 칸” 주소- 가짜 문에도 MAC 이 있다 → 이것 때문에 Pod 끼리도 ARP 가 된다
3.2 lo — 받은 것과 보낸 것이 같다 [실습]
자기한테 되돌아오는 패킷
lo로 내보낸 패킷은 밖으로 안 나가고 바로 자기한테 되돌아온다- 커널이 송신 경로에서 “목적지가 나 자신이네” 하고 수신 경로로 돌려 넣는다
- 2장 실습의 curl 요청이 Python 서버에 닿은 길이 이것이다 — 선을 한 번도 안 탔다
- 01-1 에서 macOS 의
lo0으로 캡처했던 그 문이다
숫자로 확인
ip -s link show lo
RX·TX 비교
- RX 와 TX 가 15,073,184 패킷, 5,623,076,770 바이트로 정확히 같다 → 내가 보낸 걸 내가 받으니까
ens3은 RX 와 TX 가 다르다 → 진짜 상대가 있다
주소 범위와 MTU
- 127 로 시작하는 주소는 전부 이 문이다
127.0.0.1/8이니127.0.0.2도 자기 자신이다- IPv6 에서는
::1이 같은 역할이다 mtu 65536은 선이 없으니 크게 잡아도 된다는 뜻이다
주의: 127.0.0.1 바인딩과 보안
127.0.0.1에 바인딩했다고 보안 경계가 생기는 건 아니다- 사례: CVE-2020-8558
- 2020년에 kube-proxy 가 넣은 규칙 하나 때문에 노드의 localhost 전용 서비스에 밖에서 닿을 수 있었다
- 02-2 에서 패킷이 커널 안에서 어떤 길을 가는지 본 다음에 설명한다
- 지금은 “lo 바인딩은 설정이지 인증이 아니다” 까지만
3.3 파이프의 반대쪽 끝 — ens3, veth, lo
문을 나누는 기준: 파이프의 반대쪽 끝
- 랜카드의 구멍은 파이프의 끝이다
- 그러면 반대쪽 끝은 어디인가 — 문의 종류를 이 질문 하나로 나눌 수 있다
ens3: 반대쪽 끝은 선 너머
- 스위치나 공유기의 포트다
- 내 커널은 이 문으로 프레임을 밀어 넣는 것까지만 한다
- 그 뒤는 전기 신호가 되어 선을 탄다
- 반대편에서 뭐가 받는지 내 커널은 모른다
veth: 양쪽 끝이 다 내 커널 안
- 선이 없다 → 커널이 한쪽에 넣은 프레임을 메모리 복사로 반대쪽에 옮긴다
- 진짜 문은 선이 반대편을 정해 준다
- 가짜 문은 선이 없다 → 커널이 “이 문의 반대편은 저 문” 이라고 명시적으로 짝을 지어 줘야 한다
- 그래서 veth 는 항상 둘이 한 세트로 만들어지고, 하나만 만들 수는 없다
- 핵심: 두 끝을 서로 다른 방에 넣을 수 있다 — 4.3 에서 한다
lo: 양 끝이 같은 문
- 넣으면 자기한테 나온다
- RX 와 TX 가 같았던 이유다
전체 명령과 출력 — 02-interfaces
4. Namespaces — 격리된 방
4.1 빈 방 — lo 하나, 라우팅 없음
네임스페이스: 프로세스의 시야 자르기
- 프로세스가 보는 세상을 좁히는 커널 기능이다
- 커널은 하나인데 프로세스마다 “너는 이것만 보여” 하고 시야를 잘라 준다
- 자르는 대상마다 종류가 있다
pid— 프로세스 번호(방 안에서는 자기가 1번인 줄 안다)mnt— 파일시스템(자기 루트가 진짜 루트인 줄 안다)uts— hostnamenet— 인터페이스, IP, 라우팅, 포트, 방화벽 규칙(이 장의 주인공)
- 컨테이너는 이 네임스페이스 몇 개를 겹쳐 씌운 프로세스다
빈 방 만들기
- 방을 하나 만들어 본다
- 새로 만든 방 안에서만 일어나는 일이라 원격 서버에서 해도 SSH 가 끊기지 않는다
sudo ip netns add net1
sudo ip netns exec net1 ip -br addr
sudo ip netns exec net1 ip route
sudo ip netns exec net1 ss -lnt
새 방의 상태
ip netns exec net1 <명령>은 “그 방 안에서 이 명령을 실행해라” 다- 결과
- 문 하나(
lo, 그것도 꺼져 있다) - 라우팅 0줄
- 듣는 소켓 0개
- 문 하나(
- 호스트의
ens3도,docker0도, 기본 경로도 없다 - 네트워크가 통째로 새로 생겼다
4.2 포트 공간 — 호스트의 8080 과 방 안의 8080
격리 확인: 같은 포트 두 번 열기
- 방이 격리됐다는 걸 가장 실감나게 보여 주는 건 포트다
- 호스트에서 8080 을 쓰고 있는 채로 방 안에서도 8080 을 연다
python3 -m http.server 8080 --bind 127.0.0.1 &
sudo ip netns exec net1 python3 -m http.server 8080 --bind 127.0.0.1 &
ss -lntp | grep 8080
sudo ip netns exec net1 ss -lntp
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=2594502,fd=3))
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=2594507,fd=3))
결과: 둘 다 성공
- 2.2 에서
bind()가address already in use로 실패한다고 했다 - 그 “이미 쓰는 중” 판단이 방 단위로 이루어진다
- 그래서 Pod 50개가 전부 8080 에 바인딩해도 충돌이 없다
- 노드의
ss -lntp에는 Pod 의 8080 이 안 보인다 → 방이 다르니까
이름이 같은 다른 것: Kubernetes Namespace
- 이름이 같아서 생기는 혼동이다 — Kubernetes Namespace 는 이것과 무관하다
- 그건 API 서버의 폴더다
default,dev,prod처럼 리소스를 묶어 관리하는 이름표다- 네트워크를 격리하지 않는다
dev네임스페이스의 Pod 가prod네임스페이스의 Pod IP 로 그냥 통신할 수 있다- 막으려면 NetworkPolicy 를 따로 써야 한다
4.3 veth — 방 둘을 파이프로
방에 문 달기: veth
- 방이 격리되어 있으니 밖과 통하려면 문을 달아 줘야 한다
- 3.3 의 veth 다
- 한 줄로 두 방에 끝을 하나씩 넣는다
2: veth1@if2: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN ...
link/ether ... link-netns net2
veth
link-netns net2: 반대쪽 끝의 위치
- 이 문의 반대쪽 끝이
net2방에 있다고 커널이 알고 있다 - 이름 뒤의
@if2는 짝의 인터페이스 번호다 - 주소를 붙이고 켠다
주소를 붙인 뒤
- 라우팅 한 줄이 저절로 생겼다 — 1장 3.1 의 “직접 연결된 네트워크” 줄이다
- ping 은 0.03ms 에 돌아온다 → 선이 없으니까
호스트에서는 안 보이는 방
- 호스트는 이 방들을 모른다
- 호스트에서
ip -br addr을 쳐도veth1은 안 보인다 - 호스트에서
ping 10.0.0.2는 100% 손실이다
- 호스트에서
- 파이프의 양 끝이 둘 다 방 안에 들어가 있다 → 호스트에는 아무것도 남지 않았다
파이프 한쪽 내리기
- 파이프의 한쪽을 내리면 반대쪽도 영향을 받는다
net2에서veth2를 down 하면net1의veth1에NO-CARRIER가 뜬다- 선이 빠진 것과 같은 상태다
4.4 bridge — 파이프 끝 셋을 스위치에
파이프 끝이 많아질 때
- veth 로 방과 방을 이었다
- 그런데 Pod 가 50개면 파이프 끝이 50개다
- 방 A 가 방 B 에게 보내려면 A 의 파이프 끝에서 나온 프레임을 B 의 파이프 끝으로 넣어 줘야 한다
- 누가 그걸 하는가
브리지: 가상 스위치
- 그 일을 브리지가 한다
- 1장 5장의 스위치처럼 포트 여러 개를 가진다
- 프레임의 목적지 MAC 을 보고 어느 포트로 내보낼지 정한다
- 노드에
cni0,docker0같은 이름으로 보이는 게 이것이다 - 이번에는 파이프의 한 끝만 방에 넣고 다른 끝은 호스트에 남겨 브리지에 꽂는다
21: veth1-br@if22: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding
23: veth2-br@if24: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding
25: veth3-br@if26: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding
브리지 포트 상태
master br0가 “이 인터페이스를 br0 의 포트로 꽂아라” 다state forwarding은 그 포트가 프레임을 전달 중이라는 뜻- 책의
brctl addif가 이것이고, 요즘은ip와bridge명령으로 한다
브리지의 판단 근거: MAC 표
- 브리지가 어느 포트로 보낼지 아는 근거는 MAC 표다
- 스위치의 MAC 테이블과 같은 것이다
- 프레임이 지나갈 때 출발지 MAC 을 보고 배운다
bridge fdb show br br0 | grep -v permanent
f6:bd:...:32 dev veth1-br master br0
d6:80:...:14 dev veth2-br master br0
ea:1f:...:f4 dev veth3-br master br0
MAC 표의 세 줄
- 세 방의 veth MAC 이 각각 어느 포트 뒤에 있는지 적혀 있다
- 3.1 에서 “가짜 문에도 MAC 이 있다” 고 한 게 여기서 쓰인다
- 브리지는 MAC 으로 판단한다
주의: 물리 인터페이스는 브리지에 꽂지 않는다
- 책의 예제 2-3 에는
ip link set eth0 master br0가 있다 → 원격 서버에서 이 줄을 치면 안 된다 - 물리 인터페이스를 브리지 포트로 바꾸는 순간 거기 붙어 있던 IP 가 의미를 잃고 SSH 가 끊긴다
- 실습에서는 veth 끝만 꽂고
ens3은 건드리지 않았다 - 그림에서
ens3이 브리지 밖에 따로 있는 이유다
4.5 ARP 는 되는데 ping 은 안 된다 — 02-2 의 예고 [실습]
실험: 브리지 너머로 ping
- 브리지에 셋을 꽂고
net1에서net3으로 ping 을 보냈다
sudo ip netns exec net1 ping -c 2 -W 1 10.0.0.3
sudo ip netns exec net1 ip neigh
2 packets transmitted, 0 received, 100% packet loss
10.0.0.3 dev veth1 lladdr ea:1f:...:f4 REACHABLE
결과: ARP 는 성공, ICMP 는 실패
- ping 은 전부 실패했는데
ip neigh는REACHABLE이다 - 1장 5.2 의 ARP 는 성공해서 상대 MAC 을 알아냈다
- 브리지의 MAC 표에도 세 줄이 적혔다
- 그런데 ICMP 만 안 간다 → 브리지는 제대로 동작한 것이다
원인: 브리지 바깥
sysctl net.bridge.bridge-nf-call-iptables
sudo iptables -S FORWARD
브리지 트래픽도 iptables 로
bridge-nf-call-iptables = 1은 “브리지를 지나는 프레임도 IP 면 iptables 를 거쳐라” 는 설정이다- 이 VM 의 FORWARD 체인은 끝이 REJECT 다
- 브리지는 2계층 장치라 방화벽과 무관할 것 같다
- 그러나 이 설정이 켜져 있으면 브리지 안에서 포트 사이를 오가는 IP 패킷까지 커널의 검문소를 지난다
- ARP 는 IP 가 아니라서 안 걸렸다(그래서 MAC 은 배웠다)
- ICMP 는 IP 라서 거절됐다
sudo iptables -I FORWARD -i br0 -o br0 -j ACCEPT
sudo ip netns exec net1 ping -c 2 10.0.0.3
64 bytes from 10.0.0.3: icmp_seq=1 ttl=64 time=0.040 ms
64 bytes from 10.0.0.3: icmp_seq=2 ttl=64 time=0.050 ms
해결: br0 안의 트래픽만 통과
- br0 안에서 br0 로 가는 것만 통과시키는 규칙 한 줄을 넣자 통했다
- 02-2 의 첫 장에서 다룰 것
- 이 검문소가 무엇인지
- 왜 브리지 트래픽까지 잡는지
- FORWARD 가 다섯 검문소 중 어디인지
- 여기서는 “문과 방과 파이프를 다 제대로 이어도, 그 사이를 지나는 패킷은 커널의 검문을 받는다” 까지만 가져간다
전체 명령과 출력 — 03-veth-netns
5. Containers — 도커도 방이다
5.1 ip netns list 에는 없다 — nsenter [실습]
도커가 대신 만든 방
- 4장에서 손으로 만든 “방 + 파이프 + 브리지” 를 도커가 대신 만들어 준 것이 컨테이너 네트워크다
- 확인하려고 컨테이너를 하나 띄웠다
- 포트는 호스트에 publish 하지 않았다
docker run -d --name ch02-web nginx:alpine
ip -br link
bridge link show
컨테이너를 띄운 직후의 호스트
- 호스트에
veth9a2d62c가 생겼다 - 꺼져 있던
docker0이 UP 됐다 - 그 veth 가
docker0에 꽂혔다 - 4.4 와 똑같은 구조다 — 그러면 방은 어디 있는가
ip netns list
(빈 출력)
목록에 없는 방
- 없다
ip netns list는/var/run/netns/에 이름이 등록된 방만 보여 준다- 도커와 containerd 는 거기 등록을 안 한다
- 노드에서 이 명령을 쳤는데 비어 있다고 Pod 에 방이 없는 게 아니다
- 방을 보려면 그 안에서 도는 프로세스의 PID 로 들어가야 한다
CPID=$(docker inspect -f '{{.State.Pid}}' ch02-web)
readlink /proc/self/ns/net
sudo readlink /proc/$CPID/ns/net
sudo nsenter -t $CPID -n ip -br addr
sudo nsenter -t $CPID -n ip route
방 번호 확인: readlink /proc/PID/ns/net
- 그 프로세스가 어느 방에 있는지 번호로 알려 준다
- 2.4 의 소켓 inode 와 같은 발상이다
- 호스트와 컨테이너의 번호가 다르다 → 다른 방이다
방에 들어가기: nsenter -t PID -n <명령>
- “그 PID 의 네트워크 방에 들어가서 이 명령을 실행해라” 다
- 들어가 보면
lo와eth0뿐이다 eth0@if27의 27 은 호스트 쪽veth9a2d62c의 번호다 → 4.3 의 파이프와 같은 구조다- 라우팅은
default via 172.17.0.1—docker0의 주소다 - 방 안의 프로그램은 자기가 독립된 컴퓨터인 줄 안다
5.2 포트 80 은 방 안에만 [실습]
컨테이너 nginx 의 80
- 컨테이너의 nginx 는 80 을 듣는다
- 어디서 보이는가
sudo nsenter -t $CPID -n ss -lntp
ss -lnt | grep ':80 '
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:80/
curl -s -o /dev/null -w '%{http_code}\n' http://172.17.0.2/
sudo nsenter -t $CPID -n curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/
결과: 방마다 따로인 80
- 방 안의
ss에는 80 이 있고 호스트의ss에는 없다 → 4.2 의 포트 공간 분리다 - 호스트에서
127.0.0.1:80은 거절(000)172.17.0.2:80은 200 — 브리지를 통해 방의 문으로 들어간 것이다
- 방 안에서
127.0.0.1:80은 200- 방마다
lo가 따로 있다 → 방 안의 127.0.0.1 은 그 방 자신이다
- 방마다
publish 와 DNAT
docker run -p 8080:80으로 publish 하면 호스트의 8080 이 방 안의 80 으로 이어진다- 그건 iptables 의 DNAT 규칙으로 하는 일이다
- 02-2 의 NAT 장에서 같은 규칙을 손으로 넣어 본다
5.3 Pod — 방 하나를 여러 컨테이너가
컨테이너의 네임스페이스 확인
ls -l /proc/$CPID/ns를 호스트의/proc/self/ns와 나란히 보면 된다user만 같은 번호다net,mnt,pid,uts,ipc,cgroup,time은 전부 다르다
도커 단독과 Pod 의 차이
- 도커 단독: 컨테이너 하나가 방 한 세트를 가진다
- 네트워크, 파일시스템, 프로세스 번호, hostname 전부 따로다(
user만 기본값으로 호스트와 같다)
- 네트워크, 파일시스템, 프로세스 번호, hostname 전부 따로다(
- 쿠버네티스 Pod: 네트워크 방은 Pod 에 하나다
- 그 안의 컨테이너들이 나눠 쓴다
pause 컨테이너: 방을 붙잡는 역할
- Pod 가 생길 때 kubelet 은 먼저 pause 라는 빈 컨테이너를 하나 띄운다
- 아무것도 안 하고 잠만 자는 프로세스다
- 역할: 네트워크 방을 만들어서 붙잡고 있는 것
- 그 다음 앱 컨테이너와 사이드카를 띄울 때 “새 방 만들지 말고 pause 의 방에 들어가라” 고 한다
- 노드에서
crictl ps를 치면 Pod 하나당 컨테이너가 spec 보다 하나 더 보인다 → 그게 pause 다
같은 Pod 안: 나눠 쓰는 것과 따로인 것
- 같은
eth0, 같은 IP, 같은 포트 공간을 쓴다- 서로
localhost로 통신한다 - 둘이 같은 포트를 열면 충돌한다
- 서로
mnt와pid는 컨테이너마다 따로다- 앱 컨테이너에서 사이드카의 파일이 안 보인다
ps를 쳐도 사이드카 프로세스가 안 보인다
- “컨테이너 하나 = 방 하나” 는 종류별로 다르다
설계 이유: 사이드카 패턴
- 앱 옆에 로그 수집기나 프록시를 붙일 때 둘이 localhost 로 통하고 같은 IP 를 쓰는 게 편하다
- 네트워크를 Pod 단위로 묶으면 그게 공짜로 된다
- 파일시스템은 따로 두어 이미지는 독립적으로 유지한다
- 다른 Pod 는 같은 Kubernetes Namespace 에 있어도 방이 다르다
전체 명령과 출력 — 04-docker-netns
6. Discussion
6.1 도구 지도 — 질문에서 명령으로
도구를 나누는 세 질문
- 프로그램이 커널에 무엇을 부탁했나
- 지금 커널의 상태는 어떤가
- 그 상태를 어디서 만들었나
- 하나가 다른 하나를 대신하지 못한다
strace -e trace=network누가 8080 을 듣나 →
ss -lntp소켓의 inode 는 →
ss -lntpeFD 3 이 뭘 가리키나 →
ls -l /proc/PID/fdip -br addr문의 통계·MAC →
ip -s link show파이프의 짝은 →
ip -d link show브리지에 뭐가 꽂혔나 →
bridge link show브리지의 MAC 표 →
bridge fdb showreadlink /proc/self/ns/net이름 붙은 방 목록 →
ip netns list방 안에서 실행 →
ip netns exec컨테이너의 방에 들어가기 →
nsenter -t PID -n권한 문제와 네트워크 문제의 구분
- root 가 아니면 다른 프로세스의 FD 나 방이 안 보인다
ss -p에서 프로세스 이름이 빈칸이거나nsenter가 거부되면 권한 문제지 네트워크 문제가 아니다
핵심 메시지
커널 안에서 curl 한 번을 따라가면, 프로그램이 한 일은 손잡이에 대고 부탁한 것뿐이고 나머지는 전부 커널이다. 그 커널이 가진 문과 방을 손으로 만들어 보면, 노드에서
ip -br addr이 수십 줄 나오는 것도ip netns list가 비어 있는 것도 더 이상 이상하지 않다. 다음 편은 그 문과 방 사이를 지나는 패킷이 커널 안에서 어떤 검문을 받고, 어떻게 기억되고, 어디서 주소가 바뀌는지 — 4.5 에서 ping 을 막았던 그 검문소부터 시작한다.
7. References
"Network namespaces provide isolation of the system resources associated with networking: network devices, IPv4 and IPv6 protocol stacks, IP routing tables, firewall rules, ... port numbers (sockets), and so on."
"Packets transmitted on one device in the pair are immediately received on the other device."
"The epoll API performs a similar task to poll(2): monitoring multiple file descriptors to see if I/O is possible on any of them."
"Containers within a Pod share an IP address and port space, and can find each other via localhost."
