voidlearn
 

1.3Сеть на хосте

Проследишь путь пакета на хосте: адрес прослушивания, маршруты, DNS, очереди TCP и правила netfilter. Научишься читать ss и tcpdump и отличать локальную проблему сервиса от проблемы сети или ядра.

Что сможешь объяснить после этой главы
  • Почему сервис на 127.0.0.1 не виден соседям и что слушает Go на 0.0.0.0.
  • Чей баг копит CLOSE_WAIT и почему ss может показать ноль при живой утечке.
  • Что значат Recv-Q и Send-Q у слушающего сокета и как переполненная очередь accept выглядит у клиента.
  • Почему getent hosts и dig отвечают по-разному и какой резолвер берёт Go.
  • Как пакет проходит цепочки netfilter при DNAT, зачем к нему MASQUERADE и чем грозит полная таблица conntrack.

Стенд собран из контейнеров Docker Desktop 29.8 в сети 10.213.3.0/24: client (.2), server (.3), gw (.4, Ubuntu 24.04), остальные на Debian 13, у всех capability NET_ADMIN. Серверы и клиенты написаны на Go 1.27.1, ядро у всех одно: 7.0.12 из ВМ Docker Desktop.

Адреса, маршруты и адрес, на котором слушает сервис

$ ip -br addr
lo               UNKNOWN        127.0.0.1/8 ::1/128
tunl0@NONE       DOWN
…
eth0@if869       UP             10.213.3.2/24
$ ip route get 1.1.1.1
1.1.1.1 via 10.213.3.1 dev eth0 src 10.213.3.2 uid 0
    cache
$ ip route get 10.213.3.2
local 10.213.3.2 dev lo src 10.213.3.2 uid 0
    cache <local>

За eth0@if869 стоит veth: его пара с индексом 869 подключена к мосту Docker в другом namespace. Выключенные туннели вроде tunl0 ядро ВМ заводит в каждом namespace, их можно не замечать. ip route get выбирает маршрут, как ядро при отправке, и показывает шлюз и адрес источника. Пакет на собственный адрес eth0 идёт через lo: первой просматривается таблица local с адресами машины. Теперь сервер на 127.0.0.1:8080 и запрос с соседа:

$ ss -ltnp                                  # на server
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      4096      127.0.0.11:43385      0.0.0.0:*
LISTEN 0      4096       127.0.0.1:8080       0.0.0.0:*    users:(("srv",pid=908,fd=4))
$ curl -sS http://server:8080/              # на client
curl: (7) Failed to connect to server port 8080 after 0 ms: Could not connect to server
# tcpdump -nn -i any port 8080              # на server
22:00:20.922235 eth0  In  IP 10.213.3.2.57988 > 10.213.3.3.8080: Flags [S], seq 1274840934, …
22:00:20.922246 eth0  Out IP 10.213.3.3.8080 > 10.213.3.2.57988: Flags [R.], seq 0, ack 1274840935, win 0, length 0

SYN дошёл, ядро сразу ответило RST: сокета на 10.213.3.3:8080 нет. Сокет на 127.0.0.1 принимает только пакеты с этим адресом назначения, и отказ получит даже запрос с той же машины на адрес её eth0. Слушатель на 127.0.0.11 принадлежит встроенному DNS Docker.

$ ss -ltnp
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      4096      127.0.0.11:43385      0.0.0.0:*
LISTEN 0      4096         0.0.0.0:8091       0.0.0.0:*    users:(("srv",pid=984,fd=4))
LISTEN 0      4096               *:8080             *:*    users:(("srv",pid=961,fd=4))
LISTEN 0      4096               *:8090             *:*    users:(("srv",pid=972,fd=4))
$ curl -sS "http://[::1]:8090/"
hello from [::1]:8090
$ curl -sS "http://[::1]:8091/"
curl: (7) Failed to connect to ::1 port 8091 after 0 ms: Could not connect to server

Здесь серверы слушают через net.Listen("tcp", ":8080"), net.Listen("tcp", "0.0.0.0:8090") и net.Listen("tcp4", "0.0.0.0:8091"). Для неуказанного адреса Go открывает один IPv6-сокет со снятым IPV6_V6ONLY, и он принимает оба семейства: в ss это *:8080, в логе сервера [::]:8090, IPv4-собеседники выглядят как [::ffff:10.213.3.2]. Чтобы слушать только IPv4, документация net.Listen советует сеть "tcp4".

«0.0.0.0» в Go слушает и IPv6

Сокет на C с bind на 0.0.0.0 принимает только IPv4, а Go с тем же адресом примет и IPv6, так что правило в одном iptables, без ip6tables, порт не закроет. Обратная ошибка случается в Kubernetes: kubelet шлёт пробы на IP пода, и сервис на 127.0.0.1 проваливает readiness. Проброс порта в Docker разобран в главе 2.3.

CLOSE_WAIT: кто не закрыл соединение

Как закрывается TCP-соединение, разобрано в главе 9.2 статьи «Сети». CLOSE_WAIT держит сторона, которая получила FIN, но свой сокет не закрыла, и закрыть его может только процесс. Клиент на Go шлёт 50 запросов и не закрывает resp.Body, а сервер рвёт простаивающие соединения через 5 секунд (IdleTimeout):

resp, err := http.Get(*url)
if err != nil {
    log.Fatal(err)
}
log.Printf("status %d", resp.StatusCode) // resp.Body не читаем и не закрываем
$ ss -Htan '( dport = :8080 )' | awk '{print $1}' | sort | uniq -c    # сразу
     50 ESTAB
      1 TIME-WAIT
$ ss -Htan '( dport = :8080 )' | awk '{print $1}' | sort | uniq -c    # через 7 секунд
     50 CLOSE-WAIT
$ ss -tanp state close-wait '( dport = :8080 )' | head -2
Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
1      0         10.213.3.2:33708   10.213.3.3:8080 users:(("leak",pid=1039,fd=6))
$ ss -tano state fin-wait-2 | head -2                                 # на server
Recv-Q Send-Q       Local Address:Port        Peer Address:Port
0      0      [::ffff:10.213.3.3]:8080 [::ffff:10.213.3.2]:33944 timer:(timewait,57sec,0)

Непрочитанное тело не даёт транспорту вернуть соединение в пул, и каждый запрос открыл новое. Пока сервер молчал, утечка выглядела как 50 обычных ESTAB (TIME-WAIT от прежнего curl); без IdleTimeout или балансировщика так бы и осталось. После FIN сервера все 50 ушли в CLOSE-WAIT, а единица в Recv-Q это непрочитанный FIN. У сервера осиротевший FIN-WAIT-2 живёт tcp_fin_timeout, 60 секунд. Через минуту от него ничего не осталось, а ещё через 40 секунд пропали и клиентские сокеты:

$ ss -Htan '( dport = :8080 )' | wc -l
0
$ ls /proc/$(pgrep -x leak)/fd | wc -l
56
$ lsof -nP -p $(pgrep -x leak) | awk 'NR>1{print $5}' | sort | uniq -c
      1 CHR
      2 DIR
      4 REG
      2 a_inode
     50 sock

Go включает TCP keep-alive на своих сокетах сам: 15 секунд у net.Dialer и net.ListenConfig, 30 у http.DefaultTransport. Проба ушла серверу, который соединение уже забыл, получила RST, и ядро убрало сокет из таблиц, которые читает ss. Дескрипторы процесс так и не закрыл: 50 штук sock без адресов в lsof, по 50 горутин net/http.(*persistConn).readLoop и writeLoop в дампе.

TIME_WAIT и FIN_WAIT_2

TIME_WAIT остаётся у того, кто закрыл первым, будь то клиент или сервер:

$ curl -s http://server:8080/ >/dev/null; ss -tano "( dport = :8080 )"          # на client
State     Recv-Q Send-Q Local Address:Port  Peer Address:Port
TIME-WAIT 0      0         10.213.3.2:58830   10.213.3.3:8080 timer:(timewait,59sec,0)
$ curl -s -H "Connection: close" http://server:8080/ >/dev/null                 # на client
$ ss -tano state time-wait "( sport = :8080 )"                                   # на server
Recv-Q Send-Q       Local Address:Port        Peer Address:Port
0      0      [::ffff:10.213.3.3]:8080 [::ffff:10.213.3.2]:58844 timer:(timewait,59sec,0)

В первый раз curl закрыл соединение сам. Во второй попросил Connection: close, сервер Go закрыл сразу после ответа, и TIME-WAIT переехал к нему. Процесса у этих строк нет, запись держит ядро. FIN-WAIT-2 ядро хранит в той же структуре и в ss пишет тот же timer:(timewait,…), но живут они по разным правилам. В контейнере, запущенном с --sysctl net.ipv4.tcp_fin_timeout=10 (изнутри /proc/sys только читается), видны оба:

$ sysctl net.ipv4.tcp_fin_timeout; curl -s http://127.0.0.1:8080/ >/dev/null; sleep 2; ss -tano "( sport = :8080 or dport = :8080 )"
net.ipv4.tcp_fin_timeout = 10
State      Recv-Q Send-Q Local Address:Port  Peer Address:Port
LISTEN     0      4096       127.0.0.1:8080       0.0.0.0:*
CLOSE-WAIT 1      0          127.0.0.1:49454    127.0.0.1:8080  timer:(keepalive,27sec,0)
TIME-WAIT  0      0          127.0.0.1:49468    127.0.0.1:8080  timer:(timewait,57sec,0)
FIN-WAIT-2 0      0          127.0.0.1:8080     127.0.0.1:49454 timer:(timewait,9.017sec,0)

CLOSE-WAIT здесь от утёкшего клиента, запущенного на один запрос. Через 10 секунд FIN-WAIT-2 исчез, а TIME-WAIT досчитывал свои 60.

tcp_fin_timeout не укорачивает TIME_WAIT

Против «слишком многих TIME_WAIT» часто советуют уменьшить tcp_fin_timeout. Но он действует только на осиротевший FIN_WAIT_2, а TIME_WAIT в Linux всегда длится 60 секунд (константа TCP_TIMEWAIT_LEN). От нехватки портов помогают пул соединений и tcp_tw_reuse.

Очередь accept и somaxconn

Рукопожатие проходит в ядре, и готовое соединение ждёт в очереди accept, пока приложение его не заберёт. Длину задаёт listen(fd, backlog), а ядро урезает её до net.core.somaxconn. Go передаёт в listen сам somaxconn (maxListenerBacklog в net/sock_linux.go) и читает его один раз, при первом вызове: поднятое позже значение подхватит только перезапуск. У слушающего сокета ss пишет в Recv-Q длину очереди, в Send-Q предел:

$ sysctl net.core.somaxconn; ss -ltn "( sport = :8081 )"
net.core.somaxconn = 4096
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0      4096               *:8081            *:*

4096 стоит по умолчанию с ядра 5.4, раньше было 128. Возьмём контейнер с --sysctl net.core.somaxconn=8, сервер, который зовёт net.Listen, но не зовёт Accept, и 12 одновременных curl:

$ for i in $(seq 1 12); do curl -s -m 20 -o /dev/null http://backlog:8081/ & done   # на client
$ ss -ltn "( sport = :8081 )"                                                      # на backlog
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 9      8                  *:8081            *:*
$ nstat -az TcpExtListenOverflows TcpExtListenDrops
#kernel
TcpExtListenOverflows           12                 0.0
TcpExtListenDrops               12                 0.0
$ ss -tano "( dport = :8081 )"                                                     # на client
State    Recv-Q Send-Q Local Address:Port  Peer Address:Port
ESTAB    0      0         10.213.3.2:52276   10.213.3.5:8081 timer:(keepalive,56sec,0)
…
SYN-SENT 0      1         10.213.3.2:52368   10.213.3.5:8081 timer:(on,986ms,3)
клиенты 1–9 рукопожатие завершено клиенты 10–12 SYN-SENT, повтор через 1 с ядро: слушающий сокет *:8081 SYN queue полуоткрытые, SYN-RECV tcp_max_syn_backlog accept queue предел 8, внутри 9 min(backlog, somaxconn) полна: новый SYN отброшен ACK отброшен, ListenOverflows +1 $ ss -ltn '( sport = :8081 )' LISTEN 9 8 *:8081 $ nstat -az TcpExtListenOverflows TcpExtListenOverflows 12 Go-процесс ln.Accept() забирает из очереди; не зовёт, и очередь стоит Accept net.Listen передаёт backlog = somaxconn, прочитанный один раз
Две очереди слушающего сокета. Полуоткрытые соединения ждут в SYN queue, готовые в очереди accept. Когда она полна, ядро молча отбрасывает новые SYN, клиент повторяет их по таймеру, а приложение ничего не узнаёт.

В очереди 9 при пределе 8: полной она считается, только когда больше предела (в другом прогоне было 10, проверка идёт без блокировки). Девять клиентов считают соединение установленным и уже отправили запрос. SYN трёх остальных выброшены молча, без RST, и счётчик насчитал 12, по четыре SYN на клиента. С ядра 6.5 первые пять повторов SYN идут раз в секунду (при tcp_syn_linear_timeouts = 4), дальше интервал удваивается; таймер on,986ms,3 значит три повтора. Медленный Accept виден по time_connect:

$ for i in $(seq 1 16); do curl -s -m 30 -o /dev/null -w '%{time_connect} %{time_starttransfer} %{http_code}\n' http://backlog:8081/ & done; wait
0.000556 0.002219 200
…
0.000162 1.794936 200
1.060195 1.992683 200
…
1.060905 3.018637 200

Сервер принимал по соединению раз в 200 мс: десять клиентов подключились сразу, шесть через 1,06 секунды, после повтора SYN. http.Server забирает соединения одной горутиной в цикле, так что очередь встаёт, когда процессу не дают CPU, когда кончились дескрипторы (сервер пишет http: Accept error: …; retrying in … и ждёт до секунды) или перед Accept стоит ограничитель вроде netutil.LimitListener.

Эфемерные порты кончаются у клиента

Исходящему соединению ядро выдаёт порт из net.ipv4.ip_local_port_range, по умолчанию 32768–60999. Порт занят для конкретного адреса и порта назначения, пока не кончится TIME_WAIT. Сузим диапазон до ста портов и будем открывать соединения, которые клиент тут же закрывает сам (net.Dial, затем Close):

$ sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.ipv4.tcp_tw_reuse_delay
net.ipv4.ip_local_port_range = 50000	50099
net.ipv4.tcp_tw_reuse = 2
net.ipv4.tcp_tw_reuse_delay = 1000
$ /opt/dv/dial server:8080
connection 100: dial tcp 10.213.3.3:8080: connect: cannot assign requested address
$ ss -Htan state time-wait | wc -l
99
$ ss -ltnu
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:Port
udp   UNCONN 0      0         127.0.0.11:50001      0.0.0.0:*
tcp   LISTEN 0      4096      127.0.0.11:50049      0.0.0.0:*
$ /opt/dv/dial server:8090 50
50 connections, no errors

Сотое соединение упало мгновенно с EADDRNOTAVAIL. В TIME-WAIT 99 сокетов, а порт 50049 занял слушатель встроенного DNS Docker. К порту 8090 того же сервера соединения открываются по-прежнему: для другой пары порты свободны.

tcp_tw_reuse разрешает исходящему соединению занять порт с TIME_WAIT старше tcp_tw_reuse_delay (параметр есть с ядра 6.14). Значение 2 по умолчанию включает это только для loopback, поэтому одинаковый цикл из 300 запросов с паузой 20 мс ведёт себя по-разному:

$ pace() { s=$(date +%s%N); ok=0; fail=0; for i in $(seq 1 300); do if curl -s -o /dev/null "$1"; then ok=$((ok+1)); else fail=$((fail+1)); [ $fail -eq 1 ] && echo "first fail at $i after $(( ($(date +%s%N)-s)/1000000 )) ms"; fi; sleep 0.02; done; echo ok=$ok fail=$fail elapsed=$(( ($(date +%s%N)-s)/1000000 ))ms; }
$ pace http://10.213.3.3:8080/
first fail at 100 after 2654 ms
ok=99 fail=201 elapsed=7779ms
$ pace http://127.0.0.1:9000/
ok=300 fail=0 elapsed=8089ms

Нагрузочный тест на loopback исчерпания портов почти наверняка не покажет: там порт снова годится через секунду, а не через минуту.

Что делать с исчерпанием портов

Лечит только переиспользование соединений: пул http.Transport с достаточным MaxIdleConnsPerHost, дочитанные тела ответов (глава 9.2 статьи «Сети»). Расширенный ip_local_port_range (свои порты в ip_local_reserved_ports) и tcp_tw_reuse = 1 лишь покупают время. За NAT порты кончаются и на узле трансляции.

Имена: hosts, resolv.conf, nsswitch.conf и резолвер Go

/etc/hosts хранит статическую таблицу имён, /etc/resolv.conf называет DNS-серверы, а строка hosts: в /etc/nsswitch.conf задаёт порядок источников. Но соблюдает этот порядок лишь тот, кто читает файл:

Кто спрашиваетЧто читает
getent hosts, curl, программы на glibcnsswitch.conf, затем источники по порядку: files - hosts, dns - resolv.conf
dig, nslookupnameserver из resolv.conf, nslookup ещё и search
Go, чистый резолверсам читает все три файла, понимает files и dns
Go, cgo-резолверgetaddrinfo из libc, как getent
Alpine, muslhosts, потом DNS; nsswitch.conf не читает

В контейнере пользовательской сети Docker resolv.conf указывает на nameserver 127.0.0.11, в nsswitch стоит hosts: files dns. Добавим в hosts устаревшую запись, как после переезда сервиса:

$ echo '10.213.3.99 server' >> /etc/hosts
$ getent hosts server
10.213.3.99     server
$ dig +short server
10.213.3.3
$ /opt/dv/resolve server
[10.213.3.99] <nil>
$ curl -sS -m 2 http://server:8080/
curl: (28) Connection timed out after 2002 milliseconds

По dig с именем всё в порядке, а curl и Go-программа (resolve печатает net.LookupHost) сначала идут в files и получают адрес, где никто не отвечает. С hosts: dns files и getent, и Go вернули 10.213.3.3. Какой резолвер взял Go и в каком порядке спрашивает, покажет GODEBUG=netdns: 1 печатает выбор, 2 ещё и порядок для имени, а go или cgo заставляют взять конкретный, если он есть в бинаре:

$ GODEBUG=netdns=2 /opt/dv/resolve server               # CGO_ENABLED=0
go package net: confVal.netCgo = false  netGo = false
go package net: using the Go DNS resolver
go package net: hostLookupOrder(server) = files,dns
[10.213.3.99] <nil>
$ GODEBUG=netdns=2 /opt/dv/resolve-cgo server           # CGO_ENABLED=1
go package net: confVal.netCgo = false  netGo = false
go package net: dynamic selection of DNS resolver
go package net: hostLookupOrder(server) = files,dns
[10.213.3.99] <nil>

Бинарь с CGO_ENABLED=0, обычный для scratch и distroless, всегда берёт чистый резолвер. Бинарь с cgo берёт его же, но уходит в getaddrinfo для имени, если в nsswitch есть источник, которого Go не знает, в resolv.conf незнакомая опция или заданы LOCALDOMAIN, RES_OPTIONS, HOSTALIASES. С hosts: files myhostname dns он напечатал hostLookupOrder(client) = cgo для собственного hostname и files,dns для server.

netfilter: цепочки и DNAT

netfilter ставит на пути пакета точки перехвата с цепочками правил. Входящий пакет проходит PREROUTING, потом выбор маршрута: свой адрес ведёт в INPUT, чужой в FORWARD. Пакет локального процесса начинает с OUTPUT, всё уходящее проходит POSTROUTING. DNAT делают до выбора маршрута, SNAT и MASQUERADE в POSTROUTING. На DNAT стоят публикация порта в Docker и Service в Kubernetes (статьи «Docker», глава 2.3, и «Kubernetes»). iptables в обоих образах пишет в nftables:

$ iptables -V                  # ubuntu:24.04; в debian:13 v1.8.11 (nf_tables)
iptables v1.8.10 (nf_tables)
# iptables -t nat -L -n -v
…
Chain DOCKER_OUTPUT (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 DNAT       6    --  *      *       0.0.0.0/0            127.0.0.11           tcp dpt:53 to:127.0.0.11:36879
    7   434 DNAT       17   --  *      *       0.0.0.0/0            127.0.0.11           udp dpt:53 to:127.0.0.11:50673
…

Эти правила Docker кладёт в namespace каждого контейнера пользовательской сети: запрос на 127.0.0.11:53 цепочка OUTPUT отправляет на слушатель встроенного DNS. Соберём на gw DNAT с порта 9090 на server:8080.

# iptables -t nat -A PREROUTING -p tcp --dport 9090 -j DNAT --to-destination 10.213.3.3:8080
$ curl -sS -m 3 http://gw:9090/                                        # на client
curl: (28) Connection timed out after 3002 milliseconds
# tcpdump -nn -i any tcp port 9090 or tcp port 8080                    # на client
22:06:40.331815 eth0  Out IP 10.213.3.2.34694 > 10.213.3.4.9090: Flags [S], seq 1740971054, …
22:06:40.331870 eth0  In  IP 10.213.3.3.8080 > 10.213.3.2.34694: Flags [S.], seq 1608374181, ack 1740971055, …
22:06:40.331874 eth0  Out IP 10.213.3.2.34694 > 10.213.3.3.8080: Flags [R], seq 1740971055, win 0, length 0
# conntrack -L -p tcp --dport 9090                                     # на gw
tcp      6 115 SYN_SENT src=10.213.3.2 dst=10.213.3.4 sport=34694 dport=9090 [UNREPLIED] src=10.213.3.3 dst=10.213.3.2 sport=8080 dport=34694 mark=0 use=1
conntrack v1.4.8 (conntrack-tools): 1 flow entries have been shown.

gw переписал адрес назначения, но источником остался клиент, и сервер ответил ему напрямую. Клиент ждал ответа от 10.213.3.4:9090, получил SYN-ACK от 10.213.3.3:8080 и сбросил его, а запись conntrack на gw осталась [UNREPLIED]. Нужна подмена источника:

# iptables -t nat -A POSTROUTING -p tcp -d 10.213.3.3 --dport 8080 -j MASQUERADE
$ curl -sS -m 3 http://gw:9090/                                        # на client
hello from gw:9090
# tcpdump -nn -i any tcp port 9090 or tcp port 8080                    # на gw
22:06:45.790700 eth0  In  IP 10.213.3.2.34698 > 10.213.3.4.9090: Flags [S], seq 1834687972, …
22:06:45.790720 eth0  Out IP 10.213.3.4.34698 > 10.213.3.3.8080: Flags [S], seq 1834687972, …
22:06:45.790740 eth0  In  IP 10.213.3.3.8080 > 10.213.3.4.34698: Flags [S.], seq 1023813775, ack 1834687973, …
22:06:45.790756 eth0  Out IP 10.213.3.4.9090 > 10.213.3.2.34698: Flags [S.], seq 1023813775, ack 1834687973, …
# conntrack -L -p tcp --dport 9090
tcp      6 118 TIME_WAIT src=10.213.3.2 dst=10.213.3.4 sport=34698 dport=9090 src=10.213.3.3 dst=10.213.3.4 sport=8080 dport=34698 [ASSURED] mark=0 use=1
…
gw, 10.213.3.4 net.ipv4.ip_forward = 1 client 10.213.3.2 curl gw:9090 PREROUTING nat: DNAT dst 10.213.3.3:8080 маршрут чужой: FORWARD свой: INPUT FORWARD filter транзит POSTROUTING nat: MASQUERADE src 10.213.3.4 server 10.213.3.3:8080 видит src 10.213.3.4 таблицу nat проходит только первый пакет соединения INPUT пакет себе процесс сокет на gw OUTPUT nat: DNAT своих conntrack туда 10.213.3.2:34698 → 10.213.3.4:9090 обратно 10.213.3.3:8080 → 10.213.3.4:34698 ответ идёт через gw, по записи conntrack адреса меняются обратно без MASQUERADE server отвечает клиенту напрямую, мимо gw: client ждал ответа от 10.213.3.4 и шлёт RST
DNAT на промежуточном узле. Первый пакет получает новый адрес назначения в PREROUTING, проходит FORWARD и получает новый источник в POSTROUTING. Ответ возвращается через тот же узел, conntrack подставляет исходные адреса. Без подмены источника ответ идёт мимо, и соединение не устанавливается.

tcpdump видит входящий пакет до netfilter, а исходящий после, поэтому на gw каждый пакет виден дважды. В записи conntrack две половины: соединение на входе и ответ, которого ядро ждёт после трансляции.

# iptables -t nat -L PREROUTING -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    2   120 DNAT       6    --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:9090 to:10.213.3.3:8080

Два пакета на два соединения: таблицу nat проходит только первый пакет соединения, остальные транслируются по conntrack. Счётчик правила-пустышки в INPUT остался нулём, в FORWARD насчитал 6 пакетов: после DNAT адрес уже не свой. Запрос с самого gw на 10.213.3.4:9090 получал отказ, пока то же правило не легло в OUTPUT: локальные пакеты PREROUTING не проходят.

Правила есть, а iptables их не показывает

Таблицу, созданную через nft напрямую, iptables -t nat -S не видит: DNAT с порта 9091 на gw работал, а iptables показывал одно правило на 9090. iptables-legacy смотрит в свои таблицы и покажет пустые цепочки. Kube-proxy тоже умеет режим nftables, так что начинай с iptables -V и nft list ruleset.

conntrack и переполнение таблицы

# sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_buckets net.netfilter.nf_conntrack_tcp_timeout_established net.netfilter.nf_conntrack_tcp_timeout_time_wait
net.netfilter.nf_conntrack_count = 4
net.netfilter.nf_conntrack_max = 262144
net.netfilter.nf_conntrack_buckets = 262144
net.netfilter.nf_conntrack_tcp_timeout_established = 432000
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120

Таблица conntrack есть везде, где есть NAT или правила с состоянием, то есть на хосте Docker, в контейнерах пользовательских сетей и на узлах Kubernetes. nf_conntrack_max по умолчанию равен числу корзин, а оно зависит от памяти: при памяти больше 4 ГиБ это 262144. Предел общий на ядро и в контейнере только читается, а счётчик записей у namespace свой. Kube-proxy по умолчанию ставит предел 32768 на ядро процессора, но не меньше 131072, и таймаут установленных соединений 24 часа вместо пяти дней.

Когда записей больше предела, ядро пробует вытеснить из соседних корзин запись без флага [ASSURED]. Если не вышло, первый пакет нового соединения выбрасывается, а в журнал с ограничением частоты уходит nf_conntrack: nf_conntrack: table full, dropping packet (с ядра 6.17 для некорневого namespace ещё in netns с номером). Старые соединения работают, новые ловят таймауты. Смотри dmesg и счётчики drop и early_drop в conntrack -S. В namespace контейнера это воспроизводится: новые соединения уходят в таймаут, имена не резолвятся, старые живут, строка в журнале та же, что в nf_conntrack_core.c ядра 7.0.

tcpdump и curl: провод и фазы запроса

В выводе tcpdump -nn -i any после времени стоят интерфейс и направление, в скобках флаги: S - SYN, S. - SYN-ACK, . - ACK, P. - данные, F. - FIN, R - RST. Поле win после рукопожатия записано без масштаба, его умножают на 2^wscale из SYN. На нагруженном хосте помогают фильтр по флагам и запись в файл для Wireshark:

# tcpdump -nn -i any 'tcp port 8080 and tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0'
# tcpdump -nn -i any -c 10 -w /tmp/c.pcap port 8080

curl -w печатает время фаз, каждое от начала запроса. Клиенту добавлена задержка 25 мс на исходящие пакеты, сервер спит 200 мс:

# tc qdisc add dev eth0 root netem delay 25ms
$ curl -sk -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' 'https://server:8443/?sleep=200ms'
dns=0.000282 tcp=0.025423 tls=0.052726 ttfb=0.281340 total=0.281386
$ curl -s -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' 'http://server:8080/?sleep=200ms'
dns=0.000295 tcp=0.026471 tls=0.000000 ttfb=0.256619 total=0.256740

tcp − dns: TCP-рукопожатие, 25 мс, один RTT. На TLS 1.3 ушло ещё 27 мс (tls − tcp), у HTTP ноль. ttfb − tls: 229 мс, это RTT и 200 мс обработчика, сюда же попадут ожидание пула и медленная база. total − ttfb занимает передача тела. Большое dns указывает на резолвер, tcp около целых секунд на потерянный SYN, большое ttfb − tls на приложение. curl -v покажет ещё адрес, версию TLS и выбор ALPN.

sysctl, которые реально крутят

Умолчания прочитаны в контейнере ВМ Docker Desktop с 8 ГиБ памяти на ядре 7.0.12. «Свой» значит, что у каждого сетевого namespace отдельное значение и его можно задать поду.

ПараметрУмолчаниеЧто задаётВ контейнере
net.core.somaxconn4096предел очереди accept, Go передаёт его в listenсвой
net.ipv4.ip_local_port_range32768 60999эфемерные порты исходящихсвой
net.ipv4.ip_local_reserved_portsпустопорты, не выдаваемые эфемернымисвой
net.ipv4.tcp_tw_reuse2TIME_WAIT для исходящих: 2 - только loopback, 1 - вездесвой
net.ipv4.tcp_fin_timeout60осиротевший FIN_WAIT_2свой
net.ipv4.tcp_keepalive_time, _intvl, _probes7200, 75, 9keep-alive сокетов без своих настроек; у Go своисвой
net.ipv4.tcp_slow_start_after_idle1сброс окна перегрузки после простоя соединениясвой
net.ipv4.tcp_rmem, tcp_wmem4096 131072 33554432; 4096 16384 4194304границы автоподстройки буферов TCPсвой
net.core.rmem_max, wmem_max4194304потолок SO_RCVBUF/SO_SNDBUF, важен UDP; до ядра 6.18 около 200 КиБобщий
net.netfilter.nf_conntrack_max262144размер таблицы conntrackобщий
net.netfilter.nf_conntrack_tcp_timeout_established432000жизнь записи установленного соединениясвой

На хосте их кладут в файл вида /etc/sysctl.d/90-go-net.conf и применяют без перезагрузки:

# sysctl -p /etc/sysctl.d/90-go-net.conf
net.core.somaxconn = 8192
net.ipv4.ip_local_port_range = 10000 60999
net.ipv4.ip_local_reserved_ports = 15000-15010
net.ipv4.tcp_tw_reuse = 1

В контейнере /proc/sys только читается, и параметры задают при запуске: docker run --sysctl или securityContext.sysctls пода. По документации Kubernetes 1.37 ip_local_port_range, tcp_keepalive_*, tcp_fin_timeout и ещё несколько сетевых входят в безопасный набор, а net.core.somaxconn нет: его разрешают на узле флагом kubelet --allowed-unsafe-sysctls. Общие параметры меняют только на узле.

Вопросы

4
Суть: собеседник закрыл соединение, а наш процесс свой сокет нет, так что баг наш. Порт в ss подсказывает, клиентский это код или серверный, дамп горутин показывает место. Keep-alive проба может убрать сокет из ss, но дескриптор останется.

Клиент или сервер

$ ss -Htan state close-wait | awk '{print $4}' | sort | uniq -c
     50 10.213.3.3:8080
$ ss -tanp state close-wait '( sport = :8082 )' | head -2
Recv-Q Send-Q       Local Address:Port        Peer Address:Port Process
0      0      [::ffff:10.213.3.3]:8082 [::ffff:10.213.3.2]:49438 users:(("hang",pid=1107,fd=9))

Сокеты к чужому порту оставил клиентский код: resp.Body не дочитан и не закрыт, обычно на досрочном выходе. На своём порту виноват обработчик, который не возвращается и не смотрит на r.Context(): во втором выводе curl -m 1 ушёл по таймауту, сервер Go прочитал EOF (Recv-Q ноль), но закроет сокет только после возврата из обработчика. Таймера у CLOSE_WAIT нет, sysctl его не укоротит.

Где в коде и чем мерить

В дампе горутин (pprof или kill -QUIT, глава 3.3 статьи «Память, GC») на каждое утёкшее клиентское соединение приходится пара net/http.(*persistConn).readLoop и writeLoop, а на серверное горутина с одним и тем же стеком обработчика. Число CLOSE_WAIT ненадёжно: keep-alive проба Go к забывшему соединение собеседнику получает RST, и сокет пропадает из ss, а дескриптор остаётся (sock … protocol: TCP в lsof). Мерить утечку надо дескрипторами, например process_open_fds из client_golang.

Суть: отказ пришёл пакетом, значит, хост доступен, но порт не слушают или правило его отвергает. Таймаут значит, что ответа нет: пакет выброшен или ответ ушёл мимо. Дальше ss -ltnp, маршрут, tcpdump на обоих концах, счётчики netfilter и conntrack.

Отказ

На закрытый порт ядро отвечает RST, правило REJECT по умолчанию шлёт ICMP port unreachable, и Go в обоих случаях пишет connect: connection refused. Смотри ss -ltnp на сервере: 127.0.0.1:8080 снаружи недоступен, *:8080 и 0.0.0.0:8080 доступны. Из-за этого же сервис валит readiness в Kubernetes: пробы идут на IP пода.

Таймаут

$ ip route get 10.213.3.3                     # интерфейс и шлюз
# tcpdump -nn -i any 'tcp port 8080 and tcp[tcpflags] & (tcp-syn|tcp-rst) != 0'
# iptables -t nat -L -n -v; nft list ruleset  # счётчики правил
# conntrack -L -p tcp --dport 8080            # [UNREPLIED]: ответа не было

tcpdump снимают на обоих концах. SYN не дошёл: маршрут или файрвол по дороге. Дошёл, а SYN-ACK нет: очередь accept или фильтр на сервере. SYN-ACK пришёл, а клиент ответил RST: ответ пришёл не с того адреса, как при DNAT без MASQUERADE. Пустой iptables -L при этом ничего не доказывает: сначала iptables -V и nft list ruleset.

Суть: новые соединения молча теряются в переполненной очереди accept и в полной таблице conntrack на узле с NAT, оба случая дают time_connect около целых секунд. Кончившиеся эфемерные порты у клиента выглядят иначе: мгновенная cannot assign requested address.

Сервер и узел

Recv-Q, упёршийся в Send-Q, в ss -ltn и растущий TcpExtListenOverflows значат, что приложение не успевает звать Accept: троттлинг CPU в cgroup, кончились дескрипторы (http: Accept error: … too many open files), ограничитель перед Accept. Больший somaxconn спасает от всплесков, но Go прочитает новое значение только после перезапуска. На узле с NAT смотри dmesg на table full, dropping packet, drop в conntrack -S и nf_conntrack_count против nf_conntrack_max.

Клиент

ss -Htan state time-wait '( dport = :8080 )' | wc -l покажет, сколько соединений на этот порт держит TIME_WAIT. Лечат пулом соединений, а более широкий ip_local_port_range и tcp_tw_reuse=1 только отодвигают предел.

Расписание повторов SYN поменялось

По старым статьям ищут задержки в 1, 3 и 7 секунд. С ядра 6.5 первые пять повторов SYN идут раз в секунду (tcp_syn_linear_timeouts = 4), и потерянный SYN даёт time_connect около 1, 2, 3, 4 или 5 секунд.

Суть: dig спрашивает DNS-сервер из resolv.conf и больше ничего не читает. getent идёт через glibc по порядку из nsswitch.conf, где /etc/hosts обычно первым. Go сам читает те же файлы или спрашивает glibc, поэтому почти всегда видит то же, что getent.

Откуда расхождение

  • Запись в /etc/hosts: устаревший адрес, hostAliases в поде, --add-host в Docker.
  • Порядок и модули в nsswitch.conf: dns files, myhostname, resolve для systemd-resolved.
  • Домены из search: glibc дописывает их к короткому имени (getent hosts api вернул api.svc.test), а dig api спрашивает api. как есть. В Kubernetes вместе с ndots:5 это разобрано в главе 3.4.

Что увидит Go

Проверяется самим бинарём с GODEBUG=netdns=2: он печатает резолвер и порядок, например hostLookupOrder(server) = files,dns. Бинарь без cgo всегда на чистом резолвере, бинарь с cgo уходит в getaddrinfo при незнакомом модуле nsswitch или опции resolv.conf. Без nsswitch.conf, как в scratch, Go идёт в files, потом в dns.

dig не отладчик резолва приложения

Вывод «dig отвечает правильно, значит, с DNS всё хорошо» неверен, если приложение идёт через hosts или модули nsswitch. Проверяй тем же путём: getent hosts на том же образе или сам бинарь с GODEBUG=netdns=2.

Что стоит запомнить

  • Сервис на 127.0.0.1 доступен только через loopback своего network namespace.
  • CLOSE_WAIT обычно означает, что удалённая сторона закрыла соединение, а локальное приложение ещё не закрыло сокет.
  • ss показывает состояние сокета сейчас, а tcpdump помогает увидеть обмен пакетами.

Частые ошибки

  • Не путай очередь accept слушающего сокета с очередями уже установленных соединений.
  • dig и системный резолвер могут использовать разные источники и правила разрешения имён.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай адрес слушателя

Клиент 10.213.3.2 обращается к серверу 10.213.3.3:8080. По выводу определи причину отказа и минимальное изменение конфигурации.

State  Recv-Q Send-Q Local Address:Port
LISTEN 0      4096  127.0.0.1:8080
$ curl http://server:8080/
curl: (7) Could not connect to server
Ответ и разбор

Процесс слушает только loopback сервера, а запрос приходит на адрес сетевого интерфейса. Настрой сервер слушать на нужном интерфейсе или на всех адресах namespace, обычно 0.0.0.0:8080, и ограничь внешний доступ правилами сети/фаервола. Не исправляй это публикацией сервиса без проверки границы доступа.

Код-ревью

2. Проверь адрес в Go-сервере

Сервис должен принимать запросы от соседних контейнеров, но оставаться доступным только внутри Docker-сети. Найди несоответствие в запуске.

srv := &http.Server{Addr: "127.0.0.1:8080", Handler: mux}
log.Fatal(srv.ListenAndServe())
Ответ и разбор

Адрес ограничивает listener loopback самого контейнера, поэтому соседний контейнер не подключится. Для доступа из Docker-сети слушай на адресе контейнера или на 0.0.0.0:8080, а внешнюю экспозицию контролируй настройками сети и публикации порта. Проверь доступность с соседнего контейнера, а не только через curl внутри сервиса.

Применение

3. Различи CLOSE_WAIT и TIME_WAIT

За час число CLOSE_WAIT у процесса выросло с 4 до 900, а TIME_WAIT колеблется около 40. Какую сторону и какой путь кода проверишь первым?

Ответ и разбор

Сначала проверю локальный процесс: CLOSE_WAIT появляется после FIN от удалённого узла, когда локальная сторона ещё не закрыла сокет. Ищу незакрытый body/connection, ранний возврат по ошибке и пути отмены запроса; сопоставляю сокеты с PID и логами. TIME_WAIT сам по себе ожидаем после закрытия и не указывает на такую утечку.

Применение

4. Проверь переполненную очередь

У слушающего сокета ss -ltn показывает Recv-Q=128, Send-Q=128, а новые клиенты получают задержку перед отказом. Что означает пара чисел и какие два наблюдения соберёшь до изменения sysctl?

Ответ и разбор

Для listener Recv-Q - число соединений, ожидающих accept, а Send-Q - настроенный предел backlog. При равных 128 очередь заполнена. Сниму частоту входящих SYN и скорость accept приложением, проверю CPU/паузы процесса и счётчики переполнения очередей ядра. Затем выясню, почему приложение не успевает принимать соединения; увеличение лимита лишь отодвинет отказ, если потребитель остаётся медленным.