soobook
NETWORK

Linux Bridge Networking

network namespace(netns)는 Linux kernel이 network stack을 나누는 단위다. netns마다 interface, routing table, ARP cache, iptables 규칙이 따로 있어서, 서로 다른 netns는 상대의 interface를 보지 못한다. veth pair는 두 netns에 하나씩 놓인 interface 사이로 frame을 넘기고, Linux bridge는 여러 veth를 하나의 L2 network로 묶는다. bridge에 IP address를 주고 forwarding과 NAT를 켜면 host가 그 network의 router가 된다. VM은 veth 대신 tap으로 bridge에 붙는다.

Intro

L2 Switching and L3 Routing에서는 물리 장비 사이에서 frame과 packet이 전달되는 과정을 봤다. switch는 MAC table을 보고 frame을 내보낼 port를 고르고, router는 다른 subnet으로 가는 packet을 받아 다음 link의 frame에 다시 담아 보낸다.

근데 container나 VM에서는 어떨까?

container나 VM을 띄우면 Linux kernel 안에도 이런 일을 하는 장치들이 생겨야한다.

이번에도 아래와 같은 상황 하나를 정해 두자.

flowchart TB
  accTitle: 이 글에서 쓰는 host H와 A, B, C, V
  accDescr: 사무실 LAN의 gateway(192.168.1.1)에 host H의 eth0(192.168.1.10/24)가 연결되어 있다. H 위에는 container A, B, C와 VM V가 있고, 이들이 서로, 그리고 H 바깥과 어떻게 이어지는지는 아직 정하지 않았다.
  subgraph H["host H"]
    A["container A"]
    B["container B"]
    C["container C"]
    V["VM V"]
    ETH0["eth0<br/>192.168.1.10/24"]
  end
  GW["office LAN<br/>gateway 192.168.1.1"]
  A ~~~ C
  B ~~~ V
  C ~~~ ETH0
  V ~~~ ETH0
  ETH0 --- GW

Linux host H 한 대가 사무실 LAN에 연결되어 있다. H의 물리 NIC에 붙은 interface eth0의 address는 192.168.1.10/24이고, LAN의 gateway는 192.168.1.1이다.

이 H 위에 container A, B, C를 띄우고, 뒤에서는 VM V도 띄운다. 조건을 하나씩 바꿔 가며 A, B, C, V가 서로, 그리고 H 바깥과 어떻게 통신하는지 따라가 보자.

Network Namespace

container A를 띄우면 container runtime은 A를 위해 network namespace(netns)를 하나 만든다.

netns는 Linux kernel이 network 자원을 나누는 단위다. 모든 netns는 같은 kernel 위에 있지만, netns마다 다음 자원을 따로 가진다.

자원netns마다 따로 가지는 것
interfaceip link에 보이는 interface 목록. 물리 NIC의 interface도 한 번에 한 netns에만 속한다
routing tableip route에 보이는 route
ARP cacheIP address와 MAC address의 대응 (ip neigh)
iptables, nftables 규칙packet filtering과 NAT 규칙
port 번호TCP, UDP socket이 쓰는 port 번호

host가 boot할 때 kernel이 처음 만드는 netns를 root netns(initial netns)라고 부른다.

H의 eth0는 root netns에 있고, netns를 따로 지정하지 않은 프로세스도 모두 root netns를 쓴다.

container runtime은 container마다 netns를 하나씩 더 만들고, container의 프로세스를 그 netns 안에서 실행한다.

namespace 전반과 다른 종류의 namespace는 cgroup and namespace에서 다뤘다. Kubernetes에서는 pod 하나가 netns 하나를 쓰는데, 같은 pod의 container끼리 localhost로 통신할 수 있는 이유가 이것이다.

netns가 같다는 말은 이 자원들을 모두 같이 쓴다는 뜻이다. 바라보는 interface/routing table이 같다. 수정을 해도 같이 영향 받는다.

반대로 netns가 다르다는건 정확히 반대이며, 서로 다른 host 두 대처럼 interface를 통해 frame을 주고받아야한다.

다만 두 netns를 잇는 장치는 NIC와 cable이 아니라 veth pair다. veth pair는 interface 두 개로 이루어지고, 한쪽 interface로 내보낸 frame은 반대쪽 interface에서 바로 수신된다

Case 1: A looks for eth0

runtime이 아직 아무 interface도 넣지 않은, 막 만든 netns 안에서 ip link를 실행하면 다음과 같이 나온다.

$ ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

loopback interface lo 하나뿐이고, 그마저 꺼져 있다(state DOWN). H에는 eth0가 분명히 있지만, kernel은 interface 목록을 물은 프로세스의 netns에 속한 interface만 보여 준다.

참고로 lo는 같은 netns 안의 프로세스끼리 127.0.0.1로 통신할 때 쓰는 interface다. lo로 보낸 packet은 어느 NIC로도 나가지 않고, kernel이 바로 같은 netns의 수신 쪽으로 넘긴다. kernel은 netns를 만들 때마다 그 안에 lo를 하나씩 만들어 넣는다.

이 netns에서 eth0를 켜려고 하면 kernel은 “eth0? 이 netns에는 그런 interface가 없는데?” 하고 Cannot find device "eth0" 오류를 돌려준다.

eth0은 interface인데 device라고 한다. Linux kernel은 interface 하나를 network device(struct net_device) 하나로 관리하고, ip 명령도 interface를 device라고 부르기 때문이다. 여기서 device는 물리 NIC가 아니라 interface 자체를 가리킨다.

Case 2: A pings H

이번에는 A 안에서 lo를 켠 뒤 H의 address 192.168.1.10으로 ping을 보내 보자. 결과는 ping: connect: Network is unreachable이다.

kernel은 packet을 보내기 전에, 보내는 프로세스가 속한 netns의 routing table에서 dst IP를 찾는다.

A의 routing table에는 loopback 말고는 route가 없으므로, kernel은 “192.168.1.10으로 가는 route가 없네. 어느 interface로도 보낼 수 없다” 하고 바로 오류를 낸다.

H의 routing table에는 192.168.1.0/24 dev eth0 route가 있지만, 그것은 root netns의 routing table이라서(위에서 말했듯이, container는 자기만의 netns를 가지니까) A가 보내는 packet에는 쓰이지 않는다.

Case 3: A and B both listen on port 80

port 번호도 netns마다 따로 관리된다. 그래서 A와 B에서 각각 web server를 띄우고 둘 다 80 port를 열어도 충돌하지 않는다. A와 B가 같은 netns에 있었다면, 두 번째 server는 Address already in use 오류를 내고 시작하지 못했을 것이다.

정리하면 netns는 interface부터 port 번호까지 서로 격리되어 있다. 그래서 아무것도 연결하지 않으면 A, B, C는 서로는 물론이고 H와도 통신할 수 없다. 이제 netns 사이를 잇는 장치가 필요하다.

veth

veth(virtual Ethernet) interface는 항상 두 개가 한 쌍으로 만들어진다. 한쪽 interface로 내보낸 frame은 곧바로 반대쪽 interface에서 수신된다.

두 interface는 처음에 같은 netns에 생기지만 각각 다른 netns로 옮길 수 있고, 둘 중 하나가 꺼지면 둘 다 link가 끊긴 상태가 된다.

veth pair를 하나 만들어 양 끝을 A와 B의 netns에 하나씩 넣고, 둘 다 이름을 eth0로 바꿔 보자.

그리고 A의 eth0에는 10.0.0.2/24를, B의 eth0에는 10.0.0.3/24를 준다. container 안에서 보이는 eth0는 실제로 이렇게 만든 veth의 한쪽 끝인 경우가 많다.

flowchart LR
  accTitle: veth pair 하나로 연결한 netns A와 netns B
  accDescr: netns A의 eth0(10.0.0.2/24, MAC 02:00:00:00:00:0a)와 netns B의 eth0(10.0.0.3/24, MAC 02:00:00:00:00:0b)가 veth pair의 양 끝이다. 한쪽 eth0로 내보낸 frame은 반대쪽 eth0에서 수신된다.
  subgraph NA["netns A"]
    A0["eth0<br/>10.0.0.2/24<br/>MAC …:0a"]
  end
  subgraph NB["netns B"]
    B0["eth0<br/>10.0.0.3/24<br/>MAC …:0b"]
  end
  A0 ===|veth pair| B0

Case 1: A pings B

A에서 10.0.0.3으로 ping을 보낸다. A의 routing table에는 eth0에 address를 줄 때 kernel이 넣은 10.0.0.0/24 dev eth0 route가 있다. kernel은 “10.0.0.3은 내 subnet이네. eth0로 직접 보내면 되겠다” 하고, B의 MAC address를 모르니 먼저 ARP request를 broadcast로 eth0에 내보낸다.

이 frame은 veth pair를 지나 B의 eth0에서 그대로 수신된다. 중간에 switch가 없으므로 broadcast frame을 받는 interface도 B의 eth0 하나뿐이다. B는 “10.0.0.3? 그거 나야” 하고 ARP reply를 보내고, 이후 ping은 L2 Switching and L3 Routing 글의 Case 1처럼 unicast frame으로 오간다.

Case 2: C joins

이제 C도 A, B와 통신해야 한다. veth pair는 끝이 정확히 두 개라서, A와 B를 잇는 pair에 C를 끼워 넣을 수는 없다. 그래서 A와 C, B와 C를 잇는 pair를 따로 만들어야 하고, A에는 B로 가는 interface와 C로 가는 interface가 따로 생긴다. container가 nn개면 pair가 n(n−1)2\frac{n(n-1)}{2}개 필요하고, container 하나에 interface가 n−1n-1개씩 붙는다. container가 10개면 pair는 45개다.

flowchart TB
  accTitle: veth pair 세 개로 서로 연결한 netns A, B, C
  accDescr: netns A, B, C를 서로 직접 잇기 위해 veth pair 세 개를 만든다. A는 eth0로 B와, eth1로 C와 연결된다. B는 eth0로 A와, eth1로 C와 연결된다. C는 eth0로 A와, eth1로 B와 연결된다. netns마다 interface가 두 개씩 필요하다.
  A["netns A<br/>eth0: to B<br/>eth1: to C"]
  B["netns B<br/>eth0: to A<br/>eth1: to C"]
  C["netns C<br/>eth0: to A<br/>eth1: to B"]
  A ===|veth pair| B
  A ===|veth pair| C
  B ===|veth pair| C

물리 network에서도 host끼리 cable을 하나하나 잇지 않고, 모든 host를 switch 하나에 연결한다. netns도 같은 방법으로 연결하며, 이때 쓰는 것이 kernel 안에서 switch와 같은 동작을 하는 Linux bridge다.

Linux Bridge

Linux bridge는 L2 Switching and L3 Routing 글의 switch와 같은 동작을 kernel 안에서 하는 interface다. bridge를 만든 뒤 다른 interface를 bridge에 붙이면(enslave), 붙인 interface 하나가 bridge port 하나가 된다. bridge는 port로 frame이 들어올 때마다 다음 일을 한다.

  1. src MAC과 frame이 들어온 port를 MAC table에 기록한다(MAC learning). Linux는 이 MAC table을 FDB(forwarding database)라고 부른다.
  2. dst MAC이 FDB에 있으면 그 port로만 내보내고, broadcast이거나 FDB에 없으면 들어온 port를 뺀 모든 port로 복사한다(flooding).
  3. 일정 시간 동안 frame이 들어오지 않은 MAC의 entry를 FDB에서 지운다(aging). 기본값은 300초다.

Switch가 하던 일이랑 똑같다.

이제 A, B, C를 bridge br0에 연결해 보자. container마다 veth pair를 하나씩 만들고, 한쪽 끝은 container의 netns에 넣어 eth0로 이름을 바꾼다. 다른 쪽 끝인 veth-a, veth-b, veth-c는 root netns에 남겨 두고 br0의 port로 붙인다. 즉 bridge port는 veth의 root netns 쪽 끝이고, 각 veth pair는 container의 netns와 root netns의 경계를 넘어 두 netns를 잇는다.

flowchart LR
  accTitle: root netns의 br0에 veth로 연결한 netns A, B, C
  accDescr: netns A, B, C에는 각각 eth0가 있고, address는 10.0.0.2, 10.0.0.3, 10.0.0.4다. 각 eth0와 짝을 이루는 veth의 반대쪽 끝 veth-a, veth-b, veth-c는 host H의 root netns에 있으며, 셋 모두 Linux bridge br0의 port다. 굵은 선은 netns 경계를 넘는 veth pair이고, 가는 선은 bridge port 연결이다.
  subgraph NA["netns A"]
    A0["eth0<br/>10.0.0.2<br/>MAC …:0a"]
  end
  subgraph NB["netns B"]
    B0["eth0<br/>10.0.0.3<br/>MAC …:0b"]
  end
  subgraph NC["netns C"]
    C0["eth0<br/>10.0.0.4<br/>MAC …:0c"]
  end
  subgraph ROOT["root netns (host H)"]
    VA["veth-a"]
    VB["veth-b"]
    VC["veth-c"]
    BR["br0"]
  end
  A0 === VA
  B0 === VB
  C0 === VC
  VA --- BR
  VB --- BR
  VC --- BR

Case 1: A sends its first frame to B

A가 B(10.0.0.3)에게 처음으로 ping을 보낸다. A는 B의 MAC address를 모르니 ARP request를 broadcast로 eth0에 내보내고, 이 frame은 veth pair를 지나 root netns의 veth-a에서 수신된다. veth-a는 br0의 port이므로 이 frame은 br0가 처리한다.

br0는 먼저 “src MAC이 …:0a인 frame이 veth-a로 들어왔네. …:0a는 veth-a 쪽에 있다고 기록해 두자” 하고 FDB에 entry를 넣는다. 그다음 dst MAC이 broadcast이므로, 들어온 port를 뺀 veth-b와 veth-c로 frame을 복사한다. 복사된 frame은 각 veth pair를 지나 B와 C의 eth0에서 수신된다. C는 ARP request 안의 IP를 보고 “내 IP가 아니네” 하고 넘어가고, B만 ARP reply를 보낸다.

B의 ARP reply는 dst MAC이 …:0a인 unicast frame이다. br0는 이 frame의 src MAC …:0b를 veth-b 쪽으로 기록하고, FDB에서 …:0a를 찾아 veth-a로만 내보낸다. 이 시점부터 A와 B 사이의 frame은 C 쪽 port로 복사되지 않는다.

Case 2: H pings A

이번에는 H 자신이 A에게 ping을 보낸다. H의 root netns routing table에는 10.0.0.0/24로 가는 route가 없으므로, kernel은 “10.0.0.2? default route에 걸리네. 192.168.1.1에게 넘기자” 하고 packet을 eth0로 내보낸다. 사무실 gateway도 10.0.0.0/24가 H 뒤에 있다는 것을 모르므로, 이 ping은 응답 없이 끝난다.

br0가 같은 host 안에 있는데도 H가 A에게 닿지 못하는 이유는, 지금의 br0가 port 사이에서 frame을 전달하기만 하기 때문이다. br0에도, veth-a 같은 port에도 IP address가 없으므로 H의 IP stack은 10.0.0.0/24 network에 참여하지 않는다.

Bridge as Gateway

Case 1: br0 gets an address

br0에 10.0.0.1/24를 준다. bridge도 interface이므로 address를 가질 수 있고, kernel은 다른 interface에 address를 줄 때와 마찬가지로 10.0.0.0/24 dev br0 route를 root netns의 routing table에 넣는다.

$ ip route
default via 192.168.1.1 dev eth0
10.0.0.0/24 dev br0 proto kernel scope link src 10.0.0.1
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.10
flowchart TB
  accTitle: address를 받은 br0
  accDescr: host H의 root netns에서 IP stack이 eth0(192.168.1.10/24)와 br0(10.0.0.1/24, MAC …:b0)에 연결되어 있다. routing table의 192.168.1.0/24 route와 default route는 eth0를, 10.0.0.0/24 route는 br0를 가리킨다. br0의 port인 veth-a, veth-b, veth-c는 각각 netns A, B, C의 eth0와 veth pair로 이어진다.
  subgraph ROOT["root netns (host H)"]
    ETH0["eth0<br/>192.168.1.10/24"]
    IP["IP stack<br/>routing table"]
    BR["br0<br/>10.0.0.1/24<br/>MAC …:b0"]
  end
  A["netns A<br/>10.0.0.2"]
  B["netns B<br/>10.0.0.3"]
  C["netns C<br/>10.0.0.4"]
  ETH0 ---|"192.168.1.0/24<br/>default"| IP
  IP ---|"10.0.0.0/24"| BR
  BR ===|veth-a| A
  BR ===|veth-b| B
  BR ===|veth-c| C

이제 H가 A에게 ping을 보내면 kernel은 두 번째 route를 골라 packet을 br0로 내보낸다. ARP request는 br0의 모든 port로 나가고, A는 br0의 MAC address(…:b0)를 dst MAC으로 적어 ARP reply를 보낸다. 이 reply가 veth-a로 들어오면 br0는 “dst MAC이 내 거네? 다른 port로 내보낼 frame이 아니라 H에게 온 frame이다” 하고 frame을 H의 IP stack으로 올려 보낸다.

그래서 address를 받은 br0는 두 가지 일을 함께 한다. port 사이에서 frame을 전달하는 bridge이면서, H가 10.0.0.0/24 network에 참여하는 interface이기도 하다.

Case 2: A pings H’s eth0 address

A에서 H의 eth0 address 192.168.1.10으로 ping을 보내면, A의 routing table에는 10.0.0.0/24 route만 있으므로 Network Namespace의 Case 2와 같은 Network is unreachable 오류가 난다. A의 netns에 br0의 address를 gateway로 쓰는 default route를 넣어 보자.

$ ip route
default via 10.0.0.1 dev eth0
10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.2

이제 A의 kernel은 “192.168.1.10은 내 subnet이 아니네. gateway 10.0.0.1에게 넘기자” 하고, dst MAC에 br0의 MAC address를 적어 frame을 보낸다. br0는 이 frame을 H의 IP stack으로 올려 보내고, H는 dst IP 192.168.1.10이 자기 address이므로 ping에 응답한다.

192.168.1.10은 eth0의 address인데, packet은 br0로 들어왔다. Linux는 dst IP가 host의 address 중 하나이면, 그 packet이 어느 interface로 들어왔는지와 관계없이 host 자신에게 온 packet으로 받는다. 그래서 이 경우에는 forwarding 설정이 필요 없다.

Case 3: C moves to another bridge

이번에는 C를 br0에서 떼어 새 bridge br1(10.0.1.1/24, MAC …:b1)에 붙이고, C의 address를 10.0.1.2/24로, default route의 gateway를 10.0.1.1로 바꾼다. 이제 A와 C는 서로 다른 subnet에 있다.

flowchart TB
  accTitle: host H가 router로 잇는 두 bridge
  accDescr: host H의 root netns에 bridge 두 개가 있다. br0(10.0.0.1/24)에는 netns A(10.0.0.2)와 netns B(10.0.0.3)가 veth로 붙어 있고, br1(10.0.1.1/24)에는 netns C(10.0.1.2)가 붙어 있다. H는 ip_forward가 켜져 있어서 br0와 br1 사이에서 packet을 routing한다.
  A["netns A<br/>10.0.0.2"]
  B["netns B<br/>10.0.0.3"]
  subgraph H["host H"]
    BR0["br0<br/>10.0.0.1/24"]
    BR1["br1<br/>10.0.1.1/24"]
  end
  C["netns C<br/>10.0.1.2"]
  A --- BR0
  B --- BR0
  BR0 -.-|"routing, ip_forward = 1"| BR1
  BR1 --- C

A가 C(10.0.1.2)에게 ping을 보내면, A의 kernel은 “10.0.1.2는 내 subnet이 아니네” 하고 gateway인 br0의 MAC address를 dst MAC에 적어 보낸다. br0는 이 frame을 H의 IP stack으로 올려 보내는데, 이번에는 dst IP가 H의 address가 아니다.

여기서 H가 하는 일은 net.ipv4.ip_forward 설정에 따라 달라진다. Linux의 기본값은 0이고, 이때 H는 “내 IP가 아닌데, forwarding은 꺼져 있네. 버리자” 하고 packet을 버린다. 값을 1로 바꾸면 H는 L2 Switching and L3 Routing 글의 router R과 같은 순서로 packet을 처리한다. routing table에서 10.0.1.0/24 dev br1 route를 찾고 TTL을 1 줄인다. 그리고 br1의 MAC address를 src MAC에, C의 MAC address를 dst MAC에 적은 새 frame을 br1로 내보낸다.

A → H (br0 쪽)H → C (br1 쪽)
src MAC…:0a (A)…:b1 (br1)
dst MAC…:b0 (br0)…:0c (C)
src IP10.0.0.210.0.0.2
dst IP10.0.1.210.0.1.2
TTL6463

L2 Switching and L3 Routing 글의 router R은 subnet마다 interface를 하나씩 가졌다. H에서는 br0와 br1이 R의 eth0, eth1 자리에 있고, R이 하던 routing은 H의 kernel이 ip_forward 설정에 따라 수행한다.

NAT

A와 B가 H 바깥과 통신하려면 NAT 설정이 하나 더 필요하다. 지금부터 A와 B의 default route는 br0(10.0.0.1)를 가리키고, H의 ip_forward는 켜져 있다고 하자.

Case 1: A reaches the outside without NAT

A가 바깥의 server 203.0.113.10의 443 port로 요청을 보낸다. H는 이 packet을 forwarding해서 default route를 따라 eth0로 내보내는데, packet의 src IP는 여전히 10.0.0.2다.

문제는 응답이 돌아올 때 생긴다. server가 보낸 응답의 dst IP는 10.0.0.2인데, 10.0.0.0/24가 H 뒤에 있다는 것은 H만 안다. 사무실 gateway를 비롯한 바깥의 router들은 “10.0.0.2? 그런 주소로 가는 길은 모르는데?” 하고 응답을 H에게 보내지 않는다.

Case 2: MASQUERADE rewrites the source

H에 NAT 규칙을 하나 넣는다.

# iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE

이 규칙은 “src가 10.0.0.0/24이고 eth0로 나가는 packet은, 나가기 직전에 src IP를 eth0의 address로 바꿔라”라는 뜻이다. 이렇게 src IP를 나가는 interface의 address로 바꾸는 NAT를 MASQUERADE라고 한다.

sequenceDiagram
  accTitle: MASQUERADE와 conntrack이 주소를 바꾸고 되돌리는 순서
  accDescr: netns A(10.0.0.2)가 server 203.0.113.10의 443 port로 packet을 보낸다. host H는 eth0로 내보내기 직전에 src IP를 자기 address 192.168.1.10으로 바꾸고, conntrack에 이 connection을 기록한다. server는 192.168.1.10으로 응답하고, H는 conntrack 기록을 보고 dst IP를 10.0.0.2로 되돌려 A에게 넘긴다.
  participant A as netns A
  participant H as host H
  participant S as server
  A->>H: src 10.0.0.2<br/>dst 203.0.113.10
  Note over H: MASQUERADE<br/>src → 192.168.1.10<br/>conntrack: record
  H->>S: src 192.168.1.10<br/>dst 203.0.113.10
  S->>H: src 203.0.113.10<br/>dst 192.168.1.10
  Note over H: conntrack: match<br/>dst → 10.0.0.2
  H->>A: src 203.0.113.10<br/>dst 10.0.0.2

A가 보낸 packet(10.0.0.2:40000 → 203.0.113.10:443)이 eth0로 나가기 직전에, H는 src IP를 192.168.1.10으로 바꾼다. 이때 kernel의 conntrack(connection tracking)은 이 connection을 기록해 둔다. 원래 방향은 10.0.0.2:40000 → 203.0.113.10:443이고, 응답은 203.0.113.10:443 → 192.168.1.10:40000으로 돌아올 것이라는 기록이다.

server는 192.168.1.10으로 응답하므로, 이번에는 응답이 H의 eth0로 돌아온다. H의 conntrack은 “이건 아까 10.0.0.2:40000이 연 connection의 응답이네” 하고 dst IP를 10.0.0.2로 되돌린다. 주소를 되돌리는 일은 routing보다 먼저 일어나므로, H는 되돌린 dst IP로 route를 찾아 packet을 br0로 내보내고 A가 응답을 받는다.

B도 같은 server에 같은 src port 40000으로 connection을 열면, src IP를 바꾼 뒤의 두 connection이 둘 다 192.168.1.10:40000에서 출발하게 된다. 이런 경우 MASQUERADE는 src port까지 바꿔서 두 connection을 구분한다.

Case 3: An outside client connects to A

이번에는 방향이 반대다. 사무실의 다른 machine이 A의 web server에 먼저 연결하려고 한다. 바깥에서는 10.0.0.2로 갈 수 없으니 H의 address로 보내야 하는데, H로 온 새 connection은 conntrack에 기록된 connection이 아니다. 그래서 H에게는 이 packet을 A에게 넘길 근거가 없고, H는 이 packet을 자기에게 온 packet으로 처리한다.

이 방향을 열려면 DNAT 규칙이 필요하다. 예를 들어 “eth0로 들어온 packet 중 dst port가 8080인 것은 dst를 10.0.0.2:80으로 바꿔라” 같은 규칙이다. 이렇게 바꾼 connection도 conntrack이 기록하므로, A의 응답은 나갈 때 src가 다시 192.168.1.10:8080으로 바뀐다. Docker에서 docker run -p 8080:80을 실행하면 이런 DNAT 규칙이 들어간다.

docker0 and virbr0

지금까지 만든 구성은 bridge 하나, gateway 역할을 하는 bridge address, ip_forward, MASQUERADE 규칙으로 이루어진다. Docker를 설치하면 생기는 docker0와 libvirt를 설치하면 생기는 virbr0가 모두 이 구성이다.

docker0virbr0
만드는 주체Docker daemonlibvirt의 default network
bridge address172.17.0.1/16192.168.122.1/24
bridge portcontainer마다 veth의 root netns 쪽 끝VM마다 tap (vnet0, vnet1, …)
guest address 배정Docker가 container를 만들 때 직접 넣는다dnsmasq가 DHCP로 나눠 준다
바깥으로 나가는 packetMASQUERADEMASQUERADE
바깥에서 들어오는 connection-p로 publish한 port만 DNAT로 넘긴다기본 설정에서는 넘기지 않는다

Docker는 시작할 때 ip_forward가 꺼져 있으면 이를 켜고, 이때 iptables FORWARD chain의 기본 policy를 DROP으로 바꾼다. 그래서 Docker가 이렇게 시작한 H에서는, Bridge as Gateway의 Case 3처럼 직접 만든 br0와 br1 사이의 forwarding은 ACCEPT 규칙을 따로 넣기 전까지 막힌다. 이런 계층별 filtering은 Packet Filtering Layers에서 다뤘다.

tap

이번에는 H 위에 VM V를 띄우고, V도 br0에 붙여 A, B와 같은 10.0.0.0/24 network에 두려고 한다. 그런데 V는 container와 다르다. container A의 프로세스는 H의 kernel 위에서 돌고 netns만 다르다. 반면 V 안에서는 guest kernel이 따로 돌고, V의 eth0와 routing table, ARP cache도 모두 guest kernel 안에 있다. H의 kernel 입장에서 guest kernel은 QEMU 프로세스의 memory에 들어 있는 data다. netns는 H의 kernel 자원을 나누는 단위이므로, veth의 한쪽 끝을 guest kernel 안으로 옮길 방법이 없다.

tap은 이 경우에 쓰는 interface다. tap interface는 H의 kernel에서 다른 interface와 똑같이 보여서 bridge port로 붙일 수 있다. 하지만 반대쪽 끝은 interface가 아니라 user space 프로세스가 가진 file descriptor(fd)다. QEMU는 /dev/net/tun을 열고 TUNSETIFF ioctl로 tap interface tap0를 만들거나 기존 tap에 연결해서 fd를 하나 얻는다. 이후 kernel이 tap0로 내보낸 frame은 QEMU가 이 fd에서 read()로 꺼내고, QEMU가 fd에 write()한 frame은 kernel 입장에서 tap0로 수신된 frame이 된다.

flowchart TB
  accTitle: user space의 QEMU와 kernel의 tap0, br0, veth
  accDescr: VM V는 user space의 QEMU 프로세스 안에서 돈다. guest kernel의 eth0(virtio-net)가 내보낸 frame을 QEMU가 /dev/net/tun fd에 write하면, host kernel의 tap0에서 수신된다. tap0와 veth-a는 둘 다 br0의 port다. veth-a의 짝은 netns A의 eth0이며, veth pair의 두 끝은 모두 host kernel 안에 있다.
  subgraph US["user space"]
    subgraph Q["QEMU process (VM V)"]
      G["guest kernel<br/>eth0 (virtio-net)"]
      FD["fd: /dev/net/tun"]
    end
  end
  subgraph KS["host kernel"]
    TAP["tap0"]
    VA["veth-a<br/>peer: eth0 in netns A"]
    BR["br0"]
  end
  G --- FD
  FD ---|"read(), write()"| TAP
  TAP --- BR
  VA --- BR

Case 1: V sends a frame to A

V의 guest kernel이 A(10.0.0.2)에게 보낼 ARP request를 만들어 가상 NIC(virtio-net)로 내보낸다. QEMU는 이 frame을 받아 “guest가 frame을 하나 내보냈네. tap fd에 써 주자” 하고 fd에 write()한다. kernel은 이 frame을 tap0로 수신된 frame으로 처리하고, tap0는 br0의 port이므로 br0가 frame을 받는다. 여기서부터는 Linux Bridge의 Case 1과 같다. br0는 V의 MAC address를 tap0 쪽으로 기록하고 frame을 veth-a, veth-b로 flooding한다. A가 보낸 ARP reply는 tap0로만 나간다.

br0가 tap0로 내보낸 frame은 QEMU가 fd에서 read()로 꺼낸다. QEMU는 “fd에서 frame이 나왔네. guest의 virtio-net 수신 queue에 넣어 주자” 하고 frame을 guest에게 넘긴다. br0 입장에서는 tap0와 veth-a가 다르지 않다. 둘 다 frame이 들어오고 나가는 port이고, 차이는 port의 반대쪽에서 frame을 누가 받느냐에 있다.

QEMU가 frame마다 read()와 write()를 호출하면, frame 하나를 옮길 때마다 user space와 kernel 사이를 오가야 한다. 그래서 실제 환경에서는 대부분 vhost-net을 쓴다. vhost-net을 쓰면 virtio-net queue와 tap 사이의 frame 전달을 kernel thread가 처리하고, QEMU는 처음 설정만 맡는다. 이때도 bridge port는 여전히 tap0다.

Case 2: QEMU exits

V를 끄면 QEMU 프로세스가 종료되고 fd도 닫힌다. kernel은 fd가 닫힐 때 그 fd로 만든 tap interface와 관련 route를 지우므로, tap0는 br0의 port 목록에서 사라진다. ip tuntap add처럼 persistent 설정으로 미리 만든 tap은 fd가 닫혀도 interface가 남는다. 하지만 frame을 받아 갈 프로세스가 없으므로 NO-CARRIER 상태가 되고, 이 tap으로 내보낸 frame은 버려진다.

veth와 tap을 나란히 놓으면 다음과 같다.

vethtap
구성interface 두 개가 한 쌍interface 하나와 fd 하나
반대쪽 끝다른 netns에 있는 interface/dev/net/tun을 연 user space 프로세스
반대쪽까지 frame을 옮기는 주체kernel그 프로세스 (QEMU), 또는 vhost-net
주로 연결하는 대상containerVM
반대쪽이 사라지면한쪽이 down이면 양쪽 모두 link가 끊긴다fd가 닫히면 interface가 사라지거나 NO-CARRIER가 된다

tun은 tap과 같은 driver(/dev/net/tun)로 만드는 interface다. tap fd로는 Ethernet frame이 오가지만, tun fd로는 Ethernet header가 없는 IP packet이 오간다. 그래서 VM처럼 L2 frame이 필요한 경우에는 tap을 쓰고, OpenVPN 같은 VPN 프로그램처럼 IP packet만 다루면 되는 경우에는 tun을 쓴다.

Bridged Mode

지금까지 본 bridge의 port 중에는 물리 NIC의 interface가 없었다. 그래서 H 바깥에서 A나 V에 닿으려면 H의 address와 DNAT 규칙을 거쳐야 했다. 그런데 V를 사무실 LAN의 다른 machine과 똑같이, 자기 address를 가진 machine으로 쓰고 싶을 때가 있다.

이때는 H의 eth0를 br0의 port로 붙인다. 그러면 br0는 eth0를 통해 사무실 switch와 같은 L2 network에 연결된다.

V는 사무실 DHCP server에게서 192.168.1.20 같은 address를 직접 받는다. 사무실의 다른 machine이 보기에 V는 switch에 연결된 다른 machine과 구별되지 않는다. libvirt는 이 구성을 bridged networking이라고 부른다.

flowchart LR
  accTitle: eth0를 br0의 port로 붙이기 전과 후
  accDescr: 왼쪽 NAT mode에서는 eth0가 192.168.1.10을 가지고 사무실 switch에 연결되어 있고, br0(10.0.0.1)와 VM V의 tap0(10.0.0.5)는 routing과 NAT를 거쳐 eth0로 나간다. 오른쪽 bridged mode에서는 eth0가 address 없이 br0의 port가 되고, 192.168.1.10은 br0로 옮겨 간다. V는 같은 LAN에서 192.168.1.20을 받는다.
  subgraph NATM["NAT mode"]
    direction TB
    L1["office switch"]
    E1["eth0<br/>192.168.1.10"]
    B1["br0<br/>10.0.0.1"]
    T1["tap0<br/>V: 10.0.0.5"]
    L1 --- E1
    E1 -.-|"routing, NAT"| B1
    B1 --- T1
  end
  subgraph BRM["bridged mode"]
    direction TB
    L2["office switch"]
    E2["eth0<br/>port, no IP"]
    B2["br0<br/>192.168.1.10"]
    T2["tap0<br/>V: 192.168.1.20"]
    L2 --- E2
    E2 --- B2
    B2 --- T2
  end
  NATM ~~~ BRM

eth0 joins br0 with its address

H의 eth0를 br0의 port로 붙이는 순간, H 자신의 연결이 끊긴다. eth0에는 여전히 192.168.1.10이 붙어 있는데 왜 그럴까?

interface가 bridge port가 되면, 그 interface로 들어온 frame은 H의 IP stack으로 바로 가지 않고 bridge가 먼저 받는다. br0는 dst MAC을 보고 다른 port로 보낼 frame이면 그 port로 내보내고, H에게 온 frame이면 eth0가 아니라 br0를 통해 H의 IP stack으로 올려 보낸다. 그래서 H가 사무실 gateway의 MAC address를 묻는 ARP request를 eth0로 보내도, 돌아온 ARP reply는 br0로 들어온 것으로 처리된다. H의 kernel은 “eth0 쪽에서는 아직 답이 없네” 하고 기다리고, eth0를 가리키는 route로 보내는 packet은 나가지 못한다.

그래서 bridged mode에서는 address와 default route를 eth0에서 br0로 옮긴다. 그러면 H는 br0로 packet을 보내고 br0로 받으며, br0는 사무실로 나가는 frame을 port인 eth0로 내보낸다. eth0는 address 없이 frame이 드나드는 port 역할만 한다.

KubeVirt Binding

KubeVirt는 VM을 virt-launcher pod 안의 QEMU 프로세스로 실행한다. container runtime이 pod의 netns를 만들면 CNI가 그 안에 interface 하나와 IP address 하나를 넣는데, 이 address를 실제로 써야 하는 guest는 QEMU 안에 있다. KubeVirt는 pod의 interface와 VM의 가상 NIC를 잇는 방식을 binding이라고 부르고, pod network에서 주로 쓰는 binding 두 가지는 이 글에서 본 두 구성을 pod netns 안에 만든 것이다.

bindingpod netns 안에 만드는 구성guest가 쓰는 address
masqueradeNAT section의 구성: bridge, tap, guest용 내부 subnet, NAT 규칙KubeVirt가 정한 내부 address. pod IP는 pod interface에 남는다
bridgebridged mode의 구성: pod interface와 tap을 bridge port로 붙인다pod의 IP address와 MAC address를 DHCP로 넘겨받는다

masquerade binding에서 guest가 보낸 packet은 src가 pod IP로 바뀌어 나간다. 들어오는 packet은 interface의 ports에 선언한 port만 guest로 넘어가고, ports를 비워 두면 모든 port가 넘어간다. bridge binding에서는 guest가 NAT 없이 pod IP를 직접 쓰는 대신, pod interface에는 address가 남지 않는다. 그래서 pod IP가 pod에 붙어 있다고 가정하는 Istio 같은 도구가 동작하지 않을 수 있고, pod network에서 bridge binding을 쓰는 VM은 live migration을 할 수 없다. pod network에는 masquerade binding을, secondary network에는 bridge binding을 붙인 VM 구성은 Multus CNI에서 다뤘다.

Summary

  • netns는 interface, routing table, ARP cache, iptables 규칙, port 번호를 따로 가진다. 다른 netns의 interface는 보이지 않으므로, 아무것도 연결하지 않은 netns끼리는 통신할 수 없다.
  • veth pair는 두 netns에 하나씩 놓인 interface 사이로 frame을 넘긴다. container 안의 eth0는 대개 veth의 한쪽 끝이다.
  • Linux bridge는 switch와 같은 MAC learning, flooding, aging을 kernel 안에서 수행한다. bridge port는 veth의 root netns 쪽 끝이나 tap이다.
  • bridge에 address를 주면 host가 그 L2 network의 gateway가 되고, ip_forward를 켜면 host가 bridge 사이에서 router 역할을 한다. 바깥으로 나갈 때는 MASQUERADE가 src IP를 host의 address로 바꾸고, conntrack이 응답의 dst IP를 되돌린다.
  • VM의 network stack은 guest kernel 안에 있으므로, VM은 veth 대신 반대쪽 끝이 QEMU의 fd인 tap으로 bridge에 붙는다.
  • 물리 NIC의 interface를 bridge port로 붙이면 guest가 LAN에 직접 연결되고(bridged mode), host의 address는 그 interface에서 bridge로 옮겨야 한다.

References