Изучаем сетевой стек докера в rootless mode

от автора


Недавно я столкнулся с докером в rootless mode и по привычке решил посмотреть на его сетевые интерфейсы на хосте. К своему удивлению я их не увидел, поэтому начал разбираться, как же в нем организовано сетевое взаимодействие. Результатами анализа я и поделюсь в этой статье.

Статья предполагает знание Linux и устройства сетевого стека обычного докера.

Зачем нужна rootless mode

Согласно официальному мануалу — «rootless mode позволяет запускать демон докера и контейнеры из под непривилегированного пользователя для минимизации потенциальных уязвимостей в демоне и среде исполнения контейнеров. Привилегии рута не требуются даже при установке докера»

Установка докера в rootless mode

Ставим rootless докер по инструкции с сайта. Хост система — Ubuntu 20.04:

sudo apt-get install -y dbus-user-session uidmap slirp4netns curl -fsSL https://get.docker.com/rootless | sh export PATH=/home/user/bin:$PATH systemctl --user start docker

Анализ процессов и сетевого окружения хоста

Ищем процессы докера. Основной процесс rootlesskit породил несколько дочерних. ps aux дополнительно покажет, что эти процессы запущены пользователем user:

user@rootless:~$ ps axf | grep docker -A 10 ---    8075 ?        Ssl    0:00  \_ rootlesskit --state-dir=/run/user/1001/dockerd-rootless --net=slirp4netns --mtu=65520 --slirp4netns-sandbox=auto --slirp4netns-seccomp=auto --disable-host-loopback --port-driver=builtin --copy-up=/etc --copy-up=/run --propagation=rslave /home/user/bin/dockerd-rootless.sh    8085 ?        Sl     0:00      \_ /proc/self/exe --state-dir=/run/user/1001/dockerd-rootless --net=slirp4netns --mtu=65520 --slirp4netns-sandbox=auto --slirp4netns-seccomp=auto --disable-host-loopback --port-driver=builtin --copy-up=/etc --copy-up=/run --propagation=rslave /home/user/bin/dockerd-rootless.sh    8105 ?        Sl     0:00      |   \_ dockerd    8120 ?        Ssl    0:00      |       \_ containerd --config /run/user/1001/docker/containerd/containerd.toml    8097 ?        S      0:00      \_ slirp4netns --mtu 65520 -r 3 --disable-host-loopback --enable-sandbox --enable-seccomp 8085 tap0

Проверяем сетевое окружение. Докера не видно (нет бриджей докера), правила iptables отсутствуют:

user@rootless:~$ ip a --- 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000     link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00     inet 127.0.0.1/8 scope host lo        valid_lft forever preferred_lft forever     inet6 ::1/128 scope host         valid_lft forever preferred_lft forever 2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000     link/ether 52:54:00:9a:0c:7e brd ff:ff:ff:ff:ff:ff     inet 192.168.122.49/24 brd 192.168.122.255 scope global dynamic enp1s0        valid_lft 2349sec preferred_lft 2349sec     inet6 fe80::5054:ff:fe9a:c7e/64 scope link         valid_lft forever preferred_lft forever  user@rootless:~$ ip rule --- 0:  from all lookup local 32766:  from all lookup main 32767:  from all lookup default  user@rootless:~$ ip r --- default via 192.168.122.1 dev enp1s0 proto dhcp src 192.168.122.49 metric 100  192.168.122.0/24 dev enp1s0 proto kernel scope link src 192.168.122.49  192.168.122.1 dev enp1s0 proto dhcp scope link src 192.168.122.49 metric 100  user@rootless:~$ sudo iptables-save # Generated by iptables-save v1.8.4 on Sun Mar 10 16:54:51 2024 *nat :PREROUTING ACCEPT [0:0] :INPUT ACCEPT [0:0] :OUTPUT ACCEPT [0:0] :POSTROUTING ACCEPT [0:0] COMMIT # Completed on Sun Mar 10 16:54:51 2024 # Generated by iptables-save v1.8.4 on Sun Mar 10 16:54:51 2024 *filter :INPUT ACCEPT [59111:116799888] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [45917:5706873] COMMIT # Completed on Sun Mar 10 16:54:51 2024

Сетевой неймспейс докера. Доступ наружу

Попробуем найти сетевой неймспейс, который используется докером (далее — docker_net_ns). Видим дочерний процесс докера 8085, запущенный в отдельном сетевом неймспейсе:

user@rootless:~$ sudo lsns -t net ---         NS TYPE NPROCS   PID USER    NETNSID NSFS COMMAND 4026531992 net     125     1 root unassigned      /sbin/init 4026532352 net       3  8085 user unassigned      /proc/self/exe --state-dir=/run/user/1001/dockerd-rootless --net=sli

Заходим в docker_net_ns и смотрим на его содержимое. Присутствуют стандартные для докера бридж docker0 и правила iptables, а также необычный для докера интерфейс tap0. Обычно к такому интерфейсу подключается процесс, который читает из него пришедшие пакеты, как-то их обрабатывает и, например, отправляет дальше в сетевой стек ОС (или наоборот, получает пакеты извне и передает их в tap интерфейс). OpenVPN — хороший пример такого подхода.

Интересно, что маршрут по умолчанию настроен через tap0 интерфейс:

user@rootless:~$ sudo nsenter -t 8085 -n bash # docker_net_ns root@rootless:/home/user# ip a --- 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000     link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00     inet 127.0.0.1/8 scope host lo        valid_lft forever preferred_lft forever     inet6 ::1/128 scope host         valid_lft forever preferred_lft forever 2: tap0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UP group default qlen 1000     link/ether 32:1d:f0:be:4b:1e brd ff:ff:ff:ff:ff:ff     inet 10.0.2.100/24 scope global tap0        valid_lft forever preferred_lft forever     inet6 fe80::301d:f0ff:febe:4b1e/64 scope link         valid_lft forever preferred_lft forever 3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default      link/ether 02:42:2b:35:4b:6f brd ff:ff:ff:ff:ff:ff     inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0        valid_lft forever preferred_lft forever  root@rootless:/home/user# ip r --- default via 10.0.2.2 dev tap0  10.0.2.0/24 dev tap0 proto kernel scope link src 10.0.2.100  172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1   root@rootless:/home/user# iptables-save --- # Generated by iptables-save v1.8.4 on Sun Mar 10 17:13:25 2024 *nat :PREROUTING ACCEPT [0:0] :INPUT ACCEPT [0:0] :OUTPUT ACCEPT [1:40] :POSTROUTING ACCEPT [1:40] :DOCKER - [0:0] -A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE -A DOCKER -i docker0 -j RETURN COMMIT ...

Обратим внимание на процесс 8097 (slirp4netns), который был запущен с указанием tap0 интерфейса в конфигурации slirp4netns --mtu 65520 -r 3 --disable-host-loopback --enable-sandbox --enable-seccomp 8085 tap0. Исходящий трафик из docker_net_ns проходит через tap0 (благодаря маршруту по умолчанию) и, вероятно, обрабатывается процессом slirp4netns, работающим в сетевом неймспейсе хоста.

Проверим это предположение. Вначале посмотрим на используемые процессом slirp4netns дескрипторы. Видим, что процесс подключен к tun/tap устройству через fd (file descriptor) 6:

# хост user@rootless:~$ lsof -p 8097 --- COMMAND    PID USER   FD   TYPE             DEVICE SIZE/OFF  NODE NAME ... slirp4net 8097 user    6u   CHR             10,200     0t98   137 /dev/net/tun 

Теперь запустим ping 8.8.8.8 из docker_net_ns, а в другой консоли подключимся к процессу slirp4netns с помощью strace. Процесс slirp4netns читает из fd 6 (tun/tap устройство), открывает сокет на хосте, через который отправляет icmp пакет, получает ответ, обрабатывает его, записывает в fd 6 и закрывает сокет. HTTP запрос curl example.com --resolve example.com:80:93.184.216.34 из docker_net_ns работает аналогично:

# хост user@rootless:~$ strace -p 8097 -e read,socket,sendto,recvfrom,write,close --- # ping 8.8.8.8 strace: Process 8097 attached read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0T'W@\0@\1\366\336\n\0\2d\10\10"..., 65536) = 98 socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, IPPROTO_ICMP) = 3 sendto(3, "\10\0<\201!:\0An\357\355e\0\0\0\0z\333\4\0\0\0\0\0\20\21\22\23\24\25\26\27"..., 64, 0, {sa_family=AF_INET, sin_port=htons(8150), sin_addr=inet_addr("8.8.8.8")}, 16) = 64 recvfrom(3, "\0\0e!\0\232\0An\357\355e\0\0\0\0z\333\4\0\0\0\0\0\20\21\22\23\24\25\26\27"..., 65500, 0, NULL, NULL) = 64 write(6, "2\35\360\276K\36RU\n\0\2\2\10\0E\0\0T\0@@\0\377\1^\365\10\10\10\10\n\0"..., 98) = 98 close(3)                                = 0     # curl example.com --resolve example.com:80:93.184.216.34 read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0<\361<@\0@\6\7A\n\0\2d]\270"..., 65536) = 74 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3 sendto(3, "", 0, 0, NULL, 0)            = 0 write(6, "2\35\360\276K\36RU\n\0\2\2\10\0E\10\0,\25\202\0\0@\6#\4]\270\330\"\n\0"..., 58) = 58 read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0(\361=@\0@\6\7T\n\0\2d]\270"..., 65536) = 54 read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0s\361>@\0@\6\7\10\n\0\2d]\270"..., 65536) = 129 sendto(3, "GET / HTTP/1.1\r\nHost: example.co"..., 75, 0, NULL, 0) = 75 write(6, "2\35\360\276K\36RU\n\0\2\2\10\0E\10\0(\25\203\0\0@\6#\7]\270\330\"\n\0"..., 54) = 54 recvfrom(3, "HTTP/1.1 200 OK\r\nAccept-Ranges: "..., 163840, 0, NULL, NULL) = 1607 write(6, "2\35\360\276K\36RU\n\0\2\2\10\0E\10\6o\25\204\0\0@\6\34\277]\270\330\"\n\0"..., 1661) = 1661 read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0(\361?@\0@\6\7R\n\0\2d]\270"..., 65536) = 54 read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0(\361@@\0@\6\7Q\n\0\2d]\270"..., 65536) = 54 write(6, "2\35\360\276K\36RU\n\0\2\2\10\0E\10\0(\25\205\0\0@\6#\5]\270\330\"\n\0"..., 54) = 54 recvfrom(3, "", 163840, 0, NULL, NULL)  = 0 write(6, "2\35\360\276K\36RU\n\0\2\2\10\0E\10\0(\25\206\0\0@\6#\4]\270\330\"\n\0"..., 54) = 54 read(6, "RU\n\0\2\0022\35\360\276K\36\10\0E\0\0(\0\0@\0@\6\370\221\n\0\2d]\270"..., 65536) = 54 close(3) 

Теперь ясно, как работает доступ во вне из docker_net_ns, но как трафик попадает внутрь неймспейса? Понятно, что в рамках соединения, открытого процессом slirp4netns, трафик может ходить в обе стороны, однако как попасть в docker_net_ns после закрытия соединения (как мы уже видели сетевой стек хоста никак не связан с сетевым стеком docker_net_ns)?

docker_net_ns. Доступ внутрь

docker_net_ns после создания контейнера

Создадим контейнер nginx с пробросом портов и посмотрим, что изменилось в docker_net_ns:

# хост user@rootless:~$ docker run -d -p 8080:80 --name nginx nginx

Внутри docker_net_ns добавился veth девайс,

# docker_net_ns root@rootless:/home/user# ip a  3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default      link/ether 02:42:2b:35:4b:6f brd ff:ff:ff:ff:ff:ff     inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0        valid_lft forever preferred_lft forever     inet6 fe80::42:2bff:fe35:4b6f/64 scope link         valid_lft forever preferred_lft forever  7: vethf301793@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default      link/ether 1a:27:47:50:5b:e4 brd ff:ff:ff:ff:ff:ff link-netnsid 0     inet6 fe80::1827:47ff:fe50:5be4/64 scope link         valid_lft forever preferred_lft forever

который подключен к бриджу docker0,

# docker_net_ns root@rootless:/home/user# ip -d link show dev vethf301793 7: vethf301793@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 

вторая часть которого вероятно находится в контейнере nginx.

Проверим это. Вначале установим пакеты iproute2, iputils-ping в контейнер:

# хост user@rootless:~$ docker exec -it nginx bash # контейнер nginx root@dbda1c174f6a:/# apt update  root@dbda1c174f6a:/# apt install -y iproute2 iputils-ping

В контейнере eth0@if7 ссылается на 7 интерфейс (veth) в docker_net_ns, а в docker_net_ns vethf301793@if6 ссылается на 6 интерфейс в контейнере, пинг до docker0 из контейнера работает:

# контейнер nginx root@dbda1c174f6a:/# ip a       --- 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000     link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00     inet 127.0.0.1/8 scope host lo        valid_lft forever preferred_lft forever 6: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default      link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0     inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0        valid_lft forever preferred_lft forever  root@dbda1c174f6a:/# ping 172.17.0.1 -c 2 PING 172.17.0.1 (172.17.0.1) 56(84) bytes of data. 64 bytes from 172.17.0.1: icmp_seq=1 ttl=64 time=0.137 ms 64 bytes from 172.17.0.1: icmp_seq=2 ttl=64 time=0.080 ms

tap0 и lo

Посниффим трафик в docker_net_ns. Для этого запустим в нем tcpdump на интерфейсе tap0 и с хоста сделаем обращение к порту 8080, tcpdump ничего не показывает — трафик не идет через tap0:

# хост user@rootless:~$ curl 192.168.122.49:8080 --- <!DOCTYPE html> <html> <head> <title>Welcome to nginx!</title> <style> html { color-scheme: light dark; } body { width: 35em; margin: 0 auto; font-family: Tahoma, Verdana, Arial, sans-serif; } </style> </head>

# docker_net_ns root@rootless:/home/user# tcpdump -i tap0 -n -v tcpdump: listening on tap0, link-type EN10MB (Ethernet), capture size 262144 bytes

Попробуем посниффить интерфейс lo (loopback). Видим, что появляется пакет с loopback интерфейса с адресом назначения 127.0.0.1:8080. Но откуда же он берется, еще и с loopback интерфейса?

# docker_net_ns root@rootless:/home/user# tcpdump -i lo -n -v tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes --- 20:22:53.337190 IP (tos 0x0, ttl 64, id 55902, offset 0, flags [DF], proto TCP (6), length 60)     127.0.0.1.32920 > 127.0.0.1.8080: Flags [S], cksum 0xfe30 (incorrect -> 0xe397), seq 1059949832, win 65495, options [mss 65495,sackOK,TS val 1345564632 ecr 0,nop,wscale 7], length 0 20:22:53.337221 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)     127.0.0.1.8080 > 127.0.0.1.32920: Flags [S.], cksum 0xfe30 (incorrect -> 0x1691), seq 963548038, ack 1059949833, win 65483, options [mss 65495,sackOK,TS val 1345564633 ecr 1345564632,nop,wscale 7], length 0 20:22:53.337236 IP (tos 0x0, ttl 64, id 55903, offset 0, flags [DF], proto TCP (6), length 52)     127.0.0.1.32920 > 127.0.0.1.8080: Flags [.], cksum 0xfe28 (incorrect -> 0x3d4c), ack 1, win 512, options [nop,nop,TS val 1345564633 ecr 1345564633], length 0 20:22:53.339057 IP (tos 0x0, ttl 64, id 55904, offset 0, flags [DF], proto TCP (6), length 135)     127.0.0.1.32920 > 127.0.0.1.8080: Flags [P.], cksum 0xfe7b (incorrect -> 0x092e), seq 1:84, ack 1, win 512, options [nop,nop,TS val 1345564634 ecr 1345564633], length 83: HTTP, length: 83     GET / HTTP/1.1     Host: 192.168.122.49:8080     User-Agent: curl/7.68.0     Accept: */* ...

Процессы rootlesskit и его дочерний процесс в docker_net_ns

Посмотрим, какие порты открыты на хосте. Порт 8080 открыт основным родительским процессом rootlesskit (pid 8075):

# хост user@rootless:~$ ss -tunlp Netid State  Recv-Q  Send-Q           Local Address:Port    Peer Address:Port Process                                  ...                                          tcp   LISTEN 0       4096                   0.0.0.0:8080         0.0.0.0:*     users:(("rootlesskit",pid=8075,fd=14))                                         tcp   LISTEN 0       4096                      [::]:8080            [::]:*     users:(("rootlesskit",pid=8075,fd=17)) 

Также вспомним, что у нас есть процесс 8085, запущенный в docker_net_ns. Вероятно, процесс rootlesskit (запущенный в хостовом сетевом неймспейсе) принимает трафик и передает его дочернему процессу 8085 (например, по unix сокету), а тот, в свою очередь, передает его контейнеру nginx.

С хоста подключимся к обоим процессам с помощью strace и посмотрим, как они отреагируют на curl 192.168.122.49:8080. strace запустим с опцией -ff, чтобы записать трейс всех дочерних процессов, которые создаются при обработке запроса curl:

sudo strace -ff -p 8075 -o log1 sudo strace -ff -p 8085 -o log2

Лог получился длинный, посмотрим самые главные события.

Процесс 8075. Принятие (accept) нового tcp соединения и его настройка (fd 18):

accept4(14, {sa_family=AF_INET, sin_port=htons(45752), sin_addr=inet_addr("192.168.122.49")}, [112->16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 18 epoll_ctl(4, EPOLL_CTL_ADD, 18, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3970957316, u64=9194675590499663876}}) = 0 getsockname(18, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("192.168.122.49")}, [112->16]) = 0 setsockopt(18, SOL_TCP, TCP_NODELAY, [1], 4) = 0 setsockopt(18, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0 setsockopt(18, SOL_TCP, TCP_KEEPINTVL, [15], 4) = 0 setsockopt(18, SOL_TCP, TCP_KEEPIDLE, [15], 4) = 0

Открытие unix сокета по пути /run/user/1001/dockerd-rootless/.bp.sock:

socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 19 connect(19, {sa_family=AF_UNIX, sun_path="/run/user/1001/dockerd-rootless/.bp.sock"}, 43) = 0 getsockname(19, {sa_family=AF_UNIX}, [112->2]) = 0 getpeername(19, {sa_family=AF_UNIX, sun_path="/run/user/1001/dockerd-rootless/.bp.sock"}, [112->43]) = 0

Передача информации через unix сокет:

write(19, ">\0\0\0{\"Type\":\"connect\",\"Proto\":\"t"..., 66) = 66

Если посмотреть на лог процесса 8085 (запущен в docker_net_ns), то видно, что он слушает unix сокет (accept на fd 8) по тому же пути /run/user/1001/dockerd-rootless/.bp.sock и принимает новое соединение с fd 3:

accept4(8, {sa_family=AF_UNIX}, [112->2], SOCK_CLOEXEC|SOCK_NONBLOCK) = 3 getsockname(3, {sa_family=AF_UNIX, sun_path="/run/user/1001/dockerd-rootless/.bp.sock"}, [112->43]) = 0

lsof показывает, что у этого процесса действительно открыт unix сокет с fd 8:

user@rootless:~$ lsof -p 8085 --- COMMAND  PID USER   FD      TYPE             DEVICE SIZE/OFF   NODE NAME ... exe     8085 user    8u     sock                0,9      0t0  74439 protocol: UNIX

Unix сокет можно послушать при помощи sockdump, для этого нужно сделать следующие действия:

# хост user@rootless:~$ sudo apt-get install bpfcc-tools linux-headers-$(uname -r) user@rootless:~$ git clone https://github.com/mechpen/sockdump.git user@rootless:~$ cd sockdump user@rootless:~$ sudo ./sockdump.py --format string /run/user/1001/dockerd-rootless/.bp.sock --- 21:53:25.778 >>> process rootlesskit [8075 -> 8085] path /run/user/1001/dockerd-rootless/.bp.sock len 66(66) >{"Type":"connect","Proto":"tcp4","IP":"127.0.0.1","Port":8080}21:53:25.780 >>> process exe [8085 -> 8075] path /run/user/1001/dockerd-rootless/.bp.sock len 5(5)

Как мы видим, через unix сокет передается только управляющая информация (о том, что необходимо инициировать соединение по адресу 127.0.0.1:8080), а сам http трафик от контейнера nginx отсутствует. Интересно, где он. Продолжаем анализировать лог.

Процесс 8085. Чтение из unix сокета управляющей информации:

read(3, ">\0\0\0", 4)                   = 4 read(3, "{\"Type\":\"connect\",\"Proto\":\"tcp4\""..., 62) = 62

Открытие и конфигурирование tcp соединения до 127.0.0.1:8080, реквизиты которого были переданы по unix сокету:

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 9 connect(9, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress) getsockopt(9, SOL_SOCKET, SO_ERROR, [0], [4]) = 0 getpeername(9, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 0 getsockname(9, {sa_family=AF_INET, sin_port=htons(41328), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 0 setsockopt(9, SOL_TCP, TCP_NODELAY, [1], 4) = 0 setsockopt(9, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0 setsockopt(9, SOL_TCP, TCP_KEEPINTVL, [15], 4) = 0 setsockopt(9, SOL_TCP, TCP_KEEPIDLE, [15], 4) = 0

Дублирование созданного fd tcp сокета (9->10), дублирование fd unix сокета (3->11) и пересылка по нему дубликата tcp сокета (sendmsg, cmsg_data=[10]):

fcntl(9, F_DUPFD_CLOEXEC, 0)            = 10 epoll_ctl(5, EPOLL_CTL_ADD, 10, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3690463235, u64=9186805852923232259}}) = 0 fcntl(10, F_GETFL)                      = 0x802 (flags O_RDWR|O_NONBLOCK) fcntl(10, F_SETFL, O_RDWR)              = 0 fcntl(3, F_DUPFD_CLOEXEC, 0)            = 11 epoll_ctl(5, EPOLL_CTL_ADD, 11, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3674210307, u64=9186805852906979331}}) = 0 fcntl(11, F_GETFL)                      = 0x802 (flags O_RDWR|O_NONBLOCK) fcntl(11, F_SETFL, O_RDWR)              = 0 sendmsg(11, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="dummy", iov_len=5}], msg_iovlen=1, msg_control=[{cmsg_len=20, cmsg_level=SOL_SOCKET, cmsg_type=SCM_RIGHTS, cmsg_data=[10]}], msg_controllen=24, msg_flags=0}, 0) = 5

Процесс 8075. Получение fd, в этом процессе он записывается в fd 20 (cmsg_data=[20]):

getsockopt(19, SOL_SOCKET, SO_TYPE, [1], [4]) = 0 recvmsg(19, {msg_name={sa_family=AF_UNIX, sun_path="/run/user/1001/dockerd-rootless/.bp.sock"}, msg_namelen=112->43, msg_iov=[{iov_base="d", iov_len=1}], msg_iovlen=1, msg_control=[{cmsg_len=20, cmsg_level=SOL_SOCKET, cmsg_type=SCM_RIGHTS, cmsg_data=[20]}], msg_controllen=24, msg_flags=MSG_CMSG_CLOEXEC}, MSG_CMSG_CLOEXEC) = 1

Закрытие fd unix сокета (19), дублирование полученного fd (20->19), его настройка:

close(19)                               = 0 fcntl(20, F_GETFL)                      = 0x2 (flags O_RDWR) fcntl(20, F_DUPFD_CLOEXEC, 0)           = 19 fcntl(19, F_GETFL)                      = 0x2 (flags O_RDWR) fcntl(19, F_SETFL, O_RDWR|O_NONBLOCK)   = 0 getsockopt(19, SOL_SOCKET, SO_TYPE, [1], [4]) = 0 getsockname(19, {sa_family=AF_INET, sin_port=htons(41328), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 0 getpeername(19, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 0 setsockopt(19, SOL_TCP, TCP_NODELAY, [1], 4) = 0 setsockopt(19, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0 setsockopt(19, SOL_TCP, TCP_KEEPINTVL, [15], 4) = 0 setsockopt(19, SOL_TCP, TCP_KEEPIDLE, [15], 4) = 0

И теперь вишенка на торте — копирование информации из одного tcp соединения в другое (8075<->8085) на уровне ядра:

splice(18, NULL, 24, NULL, 1048576, SPLICE_F_NONBLOCK) = 83 splice(23, NULL, 19, NULL, 83, SPLICE_F_NONBLOCK) = 83 splice(19, NULL, 22, NULL, 1048576, SPLICE_F_NONBLOCK) = 853 splice(21, NULL, 18, NULL, 853, SPLICE_F_NONBLOCK) = 853

Устроено это примерно так. Копируем 1048576 байт из fd 18 (tcp сокет процесса 8075, открытый на хосте) в fd 24 (fifo pipe на запись, соединенная с fd 23 — второй конец fifo pipe в этом же процессе на чтение), по факту скопировано 83 байта (GET запрос к nginx). Копируем 83 байта из fd 23 в fd 19 (tcp сокет, первоначально открытый в docker_net_ns и переданный процессу 8075 на хосте). Копируем 853 байта ответа nginx из fd 19 в fd 18 через пайп 21<->22.

Здесь видны дескрипторы fifo pipe и их взаимосвязь друг с другом:

user@rootless:~$ sudo lsof -p 8075 +E ... rootlessk 8075 user   21r     FIFO               0,13      0t0  82693 pipe 8075,rootlessk,22w rootlessk 8075 user   22w     FIFO               0,13      0t0  82693 pipe 8075,rootlessk,21r rootlessk 8075 user   23r     FIFO               0,13      0t0  82694 pipe 8075,rootlessk,24w rootlessk 8075 user   24w     FIFO               0,13      0t0  82694 pipe 8075,rootlessk,23r

Процесс docker-proxy

Если вы были внимательны, то заметили, что процесс 8085 внутри docker_net_ns открывает соединение к 127.0.0.1:8080, но как же пакеты попадают в контейнер nginx? Посмотрим, есть ли процессы внутри docker_net_ns, которые слушают 8080. Отмечаем, что появился новый процесс docker-proxy:

# docker_net_ns root@rootless:/home/user# ss -tunlp Netid  State   Recv-Q   Send-Q     Local Address:Port     Peer Address:Port  Process                                   tcp    LISTEN  0        128            127.0.0.1:8080          0.0.0.0:*      users:(("docker-proxy",pid=8806,fd=5))   tcp    LISTEN  0        128                [::1]:8080             [::]:*      users:(("docker-proxy",pid=8818,fd=5))

Если аналогично подключиться к docker-proxy с помощью strace, то можно посниффить его работу. docker-proxy принимает соединение на 127.0.0.1:8080 (fd 3) и инициирует подключение к контейнеру nginx на 172.17.0.2:80 (fd 9), а потом копирует запрос из fd 3 в fd 9 и ответ из fd 9 в fd 3 на уровне ядра (splice):

accept4(5, {sa_family=AF_INET, sin_port=htons(38880), sin_addr=inet_addr("127.0.0.1")}, [112->16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 3 getsockname(3, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("127.0.0.1")}, [112->16]) = 0 setsockopt(3, SOL_TCP, TCP_NODELAY, [1], 4) = 0 setsockopt(3, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0 setsockopt(3, SOL_TCP, TCP_KEEPINTVL, [15], 4) = 0 setsockopt(3, SOL_TCP, TCP_KEEPIDLE, [15], 4) = 0 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 9 connect(9, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("172.17.0.2")}, 16) = -1 EINPROGRESS (Operation now in progress) getsockopt(9, SOL_SOCKET, SO_ERROR, [0], [4]) = 0 getpeername(9, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("172.17.0.2")}, [112->16]) = 0 getsockname(9, {sa_family=AF_INET, sin_port=htons(37900), sin_addr=inet_addr("172.17.0.1")}, [112->16]) = 0 setsockopt(9, SOL_TCP, TCP_NODELAY, [1], 4) = 0 setsockopt(9, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0 setsockopt(9, SOL_TCP, TCP_KEEPINTVL, [15], 4) = 0 setsockopt(9, SOL_TCP, TCP_KEEPIDLE, [15], 4) = 0 splice(3, NULL, 13, NULL, 1048576, SPLICE_F_NONBLOCK) = 83 splice(12, NULL, 9, NULL, 83, SPLICE_F_NONBLOCK) = 83 splice(9, NULL, 11, NULL, 1048576, SPLICE_F_NONBLOCK) = 853 splice(10, NULL, 3, NULL, 853, SPLICE_F_NONBLOCK) = 853

Отметим, что при такой организации подключения до контейнера nginx теряется информация об ip адресе источника пакетов.

Итого

Таким образом, при обращении с хоста к контейнеру nginx осуществляются следующие действия:

  • процесс rootlesskit (pid 8075) принимает tcp соединение на порту 8080 в сетевом неймспейсе хоста
  • процесс rootlesskit передает управляющую информацию о соединении, которое нужно установить, дочернему процессу /proc/self/exe (pid 8085), запущенному в docker_net_ns. Управляющая информация передается через unix сокет
  • процесс /proc/self/exe устанавливает соединение до 127.0.0.1:8080 (порт открыт новым процессом docker-proxy), а уже docker-proxy подсоединяется к контейнеру nginx
  • процесс /proc/self/exe отправляет дескриптор открытого tcp сокета до 127.0.0.1:8080 процессу rootlesskit
  • процесс rootlesskit копирует данные между своим и переданным ему сетевыми сокетами на уровне ядра

Почему так сложно?

Все дело в том, что докер в rootless mode запускается из под обычного пользователя, а значит у него нет возможности создать пары veth девайсов, которые подключаются к бриджу и заводятся в контейнеры (как в обычном докере) — для этого нужны полномочия рута. veth девайсы докер создает уже внутри своего сетевого неймспейса, подсоединяет их к бриджу и заводит в контейнер.

Краткие выводы по организации сетевого стека

Доступ из сетевого неймспейса докера наружу требует обработки трафика в userspace, что может негативно сказаться на скорости сетевых соединений.

Доступ в сетевой неймспейс докера с хоста более быстрый, т.к. обработка трафика производится на уровне ядра, а в userspace выполняется относительно немного операций. К недостаткам такого доступа можно отнести тот факт, что у приходящего в контейнер пакета ip адрес источника изменен на адрес бриджа, в котором находится контейнер, т.е. информация об исходном ip адресе теряется.

Спасибо за внимание.


ссылка на оригинал статьи https://habr.com/ru/articles/799625/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *