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".
Сокет на 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)
В очереди 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, программы на glibc | nsswitch.conf, затем источники по порядку: files - hosts, dns - resolv.conf |
dig, nslookup | nameserver из resolv.conf, nslookup ещё и search |
| Go, чистый резолвер | сам читает все три файла, понимает files и dns |
| Go, cgo-резолвер | getaddrinfo из libc, как getent |
| Alpine, musl | hosts, потом 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
…
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.somaxconn | 4096 | предел очереди accept, Go передаёт его в listen | свой |
net.ipv4.ip_local_port_range | 32768 60999 | эфемерные порты исходящих | свой |
net.ipv4.ip_local_reserved_ports | пусто | порты, не выдаваемые эфемерными | свой |
net.ipv4.tcp_tw_reuse | 2 | TIME_WAIT для исходящих: 2 - только loopback, 1 - везде | свой |
net.ipv4.tcp_fin_timeout | 60 | осиротевший FIN_WAIT_2 | свой |
net.ipv4.tcp_keepalive_time, _intvl, _probes | 7200, 75, 9 | keep-alive сокетов без своих настроек; у Go свои | свой |
net.ipv4.tcp_slow_start_after_idle | 1 | сброс окна перегрузки после простоя соединения | свой |
net.ipv4.tcp_rmem, tcp_wmem | 4096 131072 33554432; 4096 16384 4194304 | границы автоподстройки буферов TCP | свой |
net.core.rmem_max, wmem_max | 4194304 | потолок SO_RCVBUF/SO_SNDBUF, важен UDP; до ядра 6.18 около 200 КиБ | общий |
net.netfilter.nf_conntrack_max | 262144 | размер таблицы conntrack | общий |
net.netfilter.nf_conntrack_tcp_timeout_established | 432000 | жизнь записи установленного соединения | свой |
На хосте их кладут в файл вида /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. Общие параметры меняют только на узле.
Вопросы
4ss подсказывает, клиентский это код или серверный, дамп горутин показывает место. 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.
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 только отодвигают предел.
По старым статьям ищут задержки в 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/паузы процесса и счётчики переполнения очередей ядра. Затем выясню, почему приложение не успевает принимать соединения; увеличение лимита лишь отодвинет отказ, если потребитель остаётся медленным.