Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение16 мин
Количество просмотров5.3K


Недавно я столкнулся с докером в 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 адресе теряется.


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

Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0+17
Комментарии0

Публикации

Истории

Работа

DevOps инженер
52 вакансии

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн