Linux и ОС
Что происходит с Go-бинарём после go build, когда он работает на живом сервере.
Как горутины ложатся на потоки ОС и что делает ядро при системном вызове. Куда уходят
дескрипторы, память и место на диске, какой путь проходит пакет до сокета и почему
соединения копятся в CLOSE_WAIT. Как найти, на что уходит время, почему ядро
убило процесс и как запустить сервис под systemd.
Умеешь ли ты читать процесс изнутри, а не по логам снаружи. Кандидат, который говорит «сервис упал», и кандидат, который говорит «горутина заблокировалась на системном вызове, поток ушёл в D-state, дескрипторы кончились, ядро прислало SIGTERM» — это разные уровни. Перезапустят процесс, скорее всего, оба. Разберутся, почему он упал, — только второй.
1.1Процессы и ядро
Go прячет от тебя ядро ровно до первого инцидента. Потом внезапно оказывается, что горутина живёт внутри потока, поток внутри процесса, у процесса кончились дескрипторы, а load average равен 40. Эта глава про уровень, который лежит под рантаймом.
Три уровня: процесс, поток, горутина
Процесс даёт изоляцию: собственное виртуальное адресное пространство, своя таблица файловых дескрипторов, свой текущий каталог, свои uid/gid, свои обработчики сигналов. Два процесса по умолчанию не видят память друг друга совсем, для обмена нужен явный механизм (pipe, сокет, разделяемая память, файл).
Поток (thread) живёт внутри процесса, и планирует потоки ядро. В Linux и процесс, и поток
сводятся к одной структуре ядра task_struct, созданной системным вызовом
clone() с разным набором флагов: если передать CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD,
получится поток (общая память, общие дескрипторы, тот же PID); без них выйдет процесс. Отсюда деталь
для собеса: Linux не различает процессы и потоки на уровне планировщика, он планирует
задачи; различие только в том, сколько ресурсов эти задачи делят. Потоки одного процесса делят
всё, кроме регистров, стека, errno, TLS, маски сигналов, приоритета и привязки к ядрам.
Горутина вообще не сущность ядра. Ядро о ней не знает. Это структура
runtime.g внутри процесса Go, которую планирует сам рантайм по модели
G–M–P: горутина, поток ОС (machine) и логический процессор (контекст
планирования, их GOMAXPROCS штук). Переключение горутин не выходит в ядро вообще:
рантайм сохраняет несколько регистров и прыгает на другой стек, поэтому оно в разы дешевле
переключения потоков.
| Ресурс | Между процессами | Между потоками одного процесса | Между горутинами |
|---|---|---|---|
| Адресное пространство (код, куча) | раздельное | общее | общее |
| Стек | свой | свой (8 МиБ виртуально) | свой (от 2 КиБ, растёт) |
| Таблица файловых дескрипторов | своя | общая | общая |
| Обработчики сигналов | свои | общие (маска — своя) | обрабатывает рантайм |
| Кто планирует | ядро | ядро (CFS/EEVDF) | рантайм Go, в user space |
| Стоимость создания | от десятков мкс | единицы мкс | сотни нс |
«Процесс — единица изоляции ресурсов, поток — единица планирования, горутина — единица конкурентности в user space. Переключение процессов дорого не столько из-за сохранения регистров, сколько из-за смены таблицы страниц: TLB и кэши остывают (без PCID/ASID TLB сбрасывается целиком). Потоки одного процесса делят таблицу страниц, поэтому их переключение дешевле. Горутины вообще не трогают ядро.»
Память процесса: сегменты, страницы, page fault
Каждый процесс видит собственное виртуальное адресное пространство. На x86-64 адрес занимает 48 бит, то есть 128 ТиБ на пользовательскую часть; верхняя половина зарезервирована под ядро и из user space недоступна. Виртуальные адреса в физические переводит аппаратный блок MMU по четырёхуровневой таблице страниц, а недавние переводы оседают в TLB. Ядро работает страницами по 4 КиБ (есть huge pages по 2 МиБ).
Классическая раскладка сегментов, снизу вверх:
.textхранит машинный код, отображён read-execute, разделяется между процессами того же бинаря;.rodataдержит константы и строковые литералы, read-only (поэтомуs[0] = 'x'для строки в Go запрещено ещё и физически);- в
.dataлежат инициализированные глобальные переменные, прямо из файла; .bssотдан глобальным нулям: в файле места не занимают, ядро выдаёт обнулённые страницы по требованию;- куча растёт вверх через
brk/sbrk, а крупные куски идут черезmmap; - mmap-область собирает разделяемые библиотеки, файлы, анонимные отображения; рантайм Go держит кучу только в таких анонимных аренах и через brk её не растит;
- стек растёт вниз от верха пользовательской части, ограничен
ulimit -s(обычно 8 МиБ).
fork не копирует память.# карта памяти живого процесса (Go 1.27, linux/arm64; на amd64 код начинается с 00400000)
$ cat /proc/$(pgrep myapp)/maps
00010000-00277000 r-xp 00000000 00:a9 80250 /usr/local/bin/myapp # .text
00280000-0055f000 r--p 00270000 00:a9 80250 /usr/local/bin/myapp # .rodata
...
43fd09c00000-43fd0a400000 rw-p 00000000 00:00 0 # арена кучи Go, адрес случайный с Go 1.26
...
ffffda429000-ffffda44a000 rw-p 00000000 00:00 0 [stack]
$ ps -o pid,vsz,rss,comm -p $(pgrep myapp)
PID VSZ RSS COMMAND
1312 1265524 14396 myapp # VSZ 1.2 ГиБ, RSS 14 МиБ, для Go это норма
$ cat /proc/$(pgrep myapp)/status | grep -E 'VmRSS|Threads'
VmRSS: 14396 kB
Threads: 14 # столько потоков ОС держит рантайм
Linux по умолчанию переобещает память (vm.overcommit_memory=0): mmap
почти всегда успешен, физическая страница выдаётся только при первом обращении. Поэтому «выделить
память» и «получить память» перестают совпадать. Когда физической памяти реально не хватает,
срабатывает OOM killer: он выбирает жертву по oom_score (грубо говоря, кто больше
съел, тот и виноват) и посылает ей SIGKILL. Ни перехватить, ни красиво завершиться
нельзя, и в логе останется только строка в dmesg. Это ровно тот же механизм, что
даёт OOMKilled в Kubernetes, только там лимит задан на cgroup, а не на всю
машину. Так называется группа процессов, которой ядро отдельно считает и ограничивает ресурсы;
разбираем её ниже, в разделе про контейнеры.
Стек и рекурсия: ОС против Go
Поток ОС получает стек фиксированным непрерывным куском виртуальной памяти, обычно 8 МиБ.
В конце стека стоит guard page, страница без прав доступа. Когда рекурсия
доходит до неё, процессор генерирует исключение, ядро превращает его в SIGSEGV,
процесс умирает с «segmentation fault». Никакого «StackOverflowError» в C нет, есть просто
падение. В один кадр рекурсии влезает адрес возврата, сохранённые регистры и локальные переменные,
то есть десятки байт; 8 МиБ хватает примерно на сотни тысяч простых кадров.
В Go всё иначе, и это любимый добивающий вопрос. Стек горутины начинается с 2 КиБ
(с Go 1.19 рантайм может сразу дать больше, если по прошлой сборке мусора стеки в среднем крупнее) и
растёт: в прологе почти каждой функции компилятор вставляет проверку «хватает ли места
до границы стека»; если нет, вызывается morestack, рантайм выделяет вдвое больший
сегмент, копирует туда старый стек и правит указатели на него (стек перемещаемый, поэтому
указатель на локальную переменную в Go всегда корректен: переменная либо убежит в кучу,
либо переедет вместе со стеком). На 64-битных платформах дальше 1 ГБ стек не растёт
(debug.SetMaxStack). На этой границе рантайм не паникует, а падает фатально.
func rec(n int) int { return rec(n+1) } // бесконечная рекурсия
func main() {
defer func() {
// не сработает: stack overflow даёт fatal error рантайма, а не панику
if r := recover(); r != nil { fmt.Println("caught", r) }
}()
rec(0)
}
// runtime: goroutine stack exceeds 1000000000-byte limit
// runtime: sp=0x218af32e0390 stack=[0x218af32e0000, 0x218b132e0000]
// fatal error: stack overflow
«Можно ли поймать переполнение стека через recover?» — Нет.
recover ловит только panic. Переполнение стека, конкурентная запись
в мапу (concurrent map writes), дедлок всех горутин и OOM рантайма дают
fatal error: процесс умирает мгновенно, defer не выполняются.
Второй вопрос-добивка: «почему в Go можно вернуть указатель на локальную переменную?»
Виноват escape-анализ: компилятор увидит, что адрес уходит наружу, и разместит значение в куче.
Системные вызовы
Системный вызов (syscall) открывает единственную легальную дверь из пользовательского кода в ядро.
На x86-64 программа кладёт номер вызова и аргументы в регистры и выполняет инструкцию syscall;
процессор переключается в нулевое кольцо, ядро выполняет работу и возвращает управление.
Дорого здесь не само переключение режима (это десятки наносекунд), а всё вокруг: проверка
аргументов, возможная блокировка, а с митигацией Meltdown (KPTI)
ещё и смена таблицы страниц на каждом входе и выходе, без PCID со сбросом TLB. Простой вызов вроде
getppid() стоит порядка сотни наносекунд (замер на arm64 без KPTI, ядро 7.0: 130 нс из C,
180 нс из Go). Отсюда общее правило: батчить
(writev вместо десяти write, буферизованный bufio.Writer
вместо посимвольной записи).
Часть «вызовов» ядро вообще отдаёт без входа в себя, через vDSO, страницу кода ядра,
отображённую в каждый процесс: так работают clock_gettime и gettimeofday,
поэтому time.Now() в Go стоит наносекунды, а не микросекунды.
ulimit -n.# чем занят процесс: смотрим по системным вызовам
$ strace -p 1312 -f # -f идёт за потоками, для Go обязательно
$ strace -c -f -p 1312 # сводка: сколько вызовов и сколько времени в каждом
$ strace -f -e trace=network ./myapp # только сетевые
$ strace -f -e trace=openat,close ./myapp # ловим утечку дескрипторов
# альтернативы, когда strace слишком дорог (на частых вызовах тормозит процесс в десятки и сотни раз)
$ perf trace -p 1312
$ bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'
Файловые дескрипторы и «too many open files»
Под дескриптором прячется маленькое целое число, индекс в таблице открытых файлов процесса. За ним
стоит структура ядра, и она занимает память ядра. Лимит на количество задаётся ulimit -n
(мягкий и жёсткий). У systemd-юнита по умолчанию мягкий так и остаётся 1024 при жёстком 524288, а для сетевого
сервиса 1024 смешно мало. Программа на Go с версии 1.19 при старте сама поднимает мягкий лимит до жёсткого,
так что упирается она в жёсткий. Docker раньше раздавал контейнерам по 1048576, а с Docker Engine 29
(containerd 2) на хостах с systemd контейнеры получают те же 1024 и 524288. Каждое TCP-соединение, каждый открытый файл, каждый epoll
съедает дескриптор, а каждый os.Pipe сразу два.
$ ulimit -n # мягкий лимит текущей сессии
1024
$ ulimit -Hn # жёсткий: выше него без root не поднять
524288
$ cat /proc/1312/limits | grep 'open files' # Go поднял мягкий (с 1.24 до жёсткого минус один)
Max open files 524287 524288 files
$ ls /proc/1312/fd | wc -l # сколько занято прямо сейчас
3008 # и растёт с каждым запросом
$ lsof -p 1312 | awk '{print $5}' | sort | uniq -c | sort -rn
3000 IPv4 # 3000 сокетов, почти наверняка утекают соединения
3 REG
3 CHR
2 a_inode
2 DIR
1 TYPE
1 IPv6
# поднять постоянно
# systemd unit: [Service] LimitNOFILE=65535
# docker: docker run --ulimit nofile=65535:65535
# k8s: поля в Pod API нет, лимит наследуется от рантайма на ноде (containerd)
// Плохо: типовые причины утечки fd в Go
resp, err := http.Get(url)
if err != nil { return err }
// ранний return: тело не прочитано и не закрыто,
// сокет висит в CLOSE_WAIT, в пул не вернётся
if resp.StatusCode != http.StatusOK { return errStatus }
rows, _ := db.Query("SELECT ...")
for rows.Next() { ... }
// rows.Close() не вызван при раннем return:
// соединение не отдано в пул
for _, name := range files {
f, _ := os.Open(name)
process(f) // defer внутри цикла тоже
} // не спасёт: закроется в конце функции
// Хорошо
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()
// дочитать для keep-alive: сам Close с Go 1.27 дочитывает не больше 256 КиБ
defer io.Copy(io.Discard, resp.Body)
rows, err := db.Query("SELECT ...")
if err != nil { return err }
defer rows.Close() // идемпотентен, вызывать всегда
for _, name := range files {
func() { // своя область видимости
f, err := os.Open(name)
if err != nil { return }
defer f.Close()
process(f)
}()
}
Создать &http.Client{} со своим http.Transport внутри функции,
которую зовут на каждый запрос, значит гарантированно потечь (клиент без своего транспорта берёт общий,
по умолчанию, и не течёт): у каждого транспорта свой пул
соединений, старые сокеты не переиспользуются и висят, пока не отвалятся по таймауту.
http.Client потокобезопасен, поэтому делай его один на процесс, настраивай
MaxIdleConnsPerHost (дефолт 2, для одного бэкенда мало) и всегда ставь
Timeout. Симптом в проде: растёт число открытых сокетов (у незакрытых тел ответов они
висят в CLOSE_WAIT), потом «too many open files», потом сервис перестаёт принимать входящие, ведь
accept() тоже нужен дескриптор.
Сигналы
Сигнал приходит процессу асинхронно, от ядра или от другого процесса. У каждого есть номер, действие по умолчанию и признак «перехватываемый».
| Сигнал | № | Кто и когда шлёт | По умолчанию | Перехватить? |
|---|---|---|---|---|
SIGTERM | 15 | kill, docker stop, k8s при удалении пода | завершение | да — это «просьба закрыться» |
SIGINT | 2 | Ctrl+C в терминале | завершение | да |
SIGQUIT | 3 | Ctrl+\ | завершение + core dump | да, но в Go по умолчанию печатает стеки всех горутин |
SIGKILL | 9 | kill -9, OOM killer, k8s после grace period | мгновенная смерть | нет, никогда |
SIGSTOP | 19 | заморозка процесса | остановка | нет |
SIGHUP | 1 | закрытие терминала; по традиции — «перечитай конфиг» | завершение | да |
SIGUSR1/2 | 10/12 | только то, что ты сам придумал | завершение | да — удобно для «сбрось профиль» |
SIGPIPE | 13 | запись в закрытый сокет/pipe | завершение | да; Go игнорирует его для всего, кроме stdout/stderr |
SIGCHLD | 17 | ребёнок завершился | игнорируется | да — сюда вешают wait() против зомби |
// Типовой graceful shutdown на Go 1.16+
func main() {
// NotifyContext отменяет ctx при первом же SIGINT/SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: router}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // приехал SIGTERM
stop() // вернуть дефолтный обработчик: второй Ctrl+C убьёт сразу
log.Println("shutting down")
// даём доработать текущим запросам, но не бесконечно
shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil { // перестаёт принимать новые, ждёт активные
log.Println("forced shutdown:", err)
}
// и только теперь закрываем пул БД, consumer'ы Kafka, флашим трейсы
pool.Close()
tracerProvider.Shutdown(context.Background())
}
Сначала перестать принимать новую работу (HTTP-сервер, consumer брокера), потом дождаться
активной, потом закрыть исходящие ресурсы (пул БД, Redis, продюсеры), и в самом конце
флашнуть телеметрию. Если закрыть пул БД первым, все доигрывающие запросы упадут с ошибкой, и ты
сам создашь всплеск 5xx на каждом деплое. И держи общий бюджет меньше, чем
terminationGracePeriodSeconds в k8s (по умолчанию 30 с), иначе тебя дорежут
SIGKILL.
Убить процесс, jobs, fg, bg
# найти
$ pgrep -a myapp # PID + командная строка
$ ps aux | grep '[m]yapp' # скобки, чтобы grep не нашёл сам себя
$ pidof myapp
# убить, от вежливого к грубому
$ kill 1312 # SIGTERM по умолчанию, вежливо
$ kill -TERM 1312 # то же явно
$ kill -HUP 1312 # «перечитай конфиг»
$ kill -QUIT 1312 # в Go: дамп стеков всех горутин в stderr, спасает при зависании
$ kill -9 1312 # SIGKILL, последнее средство: без flush, без cleanup
# по имени
$ pkill myapp # по имени процесса
$ pkill -f 'myapp --config=prod' # -f матчит всю командную строку (регулярка, осторожнее)
$ pkill -u deploy -f worker # только процессы пользователя deploy
$ killall myapp # точное совпадение имени; на Solaris killall убивает вообще всё
$ systemctl stop myapp # правильный способ, если это systemd-юнит
$ kill -0 1312 # ничего не шлёт, только проверяет: жив ли и есть ли права
# управление задачами в текущем шелле
$ ./myapp & # запустить в фоне
$ jobs -l # список задач шелла с PID
$ fg %1 # вернуть первую задачу на передний план
$ bg %1 # продолжить её в фоне (после Ctrl+Z)
# Ctrl+Z шлёт SIGTSTP (пауза), Ctrl+C SIGINT, Ctrl+\ SIGQUIT
$ nohup ./myapp & # переживёт закрытие терминала (игнорирует SIGHUP)
$ disown -h %1 # то же для уже запущенной задачи
kill -9 плохой рефлекс
SIGKILL обрабатывает ядро, процесс о нём даже не узнаёт: не выполнятся
defer, не закроются транзакции, не сбросятся буферы, не снимутся файлы-локи,
сообщение из брокера останется вычитанным, но неподтверждённым. Если процесс не умирает от
SIGTERM, это баг, который надо чинить, а не обходить девяткой. И главный
случай, когда даже -9 не работает: процесс в состоянии D
(uninterruptible sleep). Он застрял в системном вызове к сломанному диску,
и ждать придётся ядро.
Куда смотреть: логи, порты, нагрузка
# Логи
$ journalctl -u myapp -f # хвост живого юнита
$ journalctl -u myapp --since '10 min ago' -p err # только ошибки за 10 минут
$ journalctl -u myapp -b -1 # логи предыдущей загрузки
$ journalctl --disk-usage && journalctl --vacuum-time=7d
$ tail -f /var/log/syslog # Ubuntu и Debian до 12, на RHEL /var/log/messages
$ dmesg -T | grep -i -E 'oom|killed' # сюда пишет OOM killer, первое место при 137
$ docker logs -f --tail 100 --since 10m app # логи контейнера (это stdout/stderr PID 1)
$ kubectl logs -f deploy/app -c app --tail=100
$ kubectl logs pod/app-xyz --previous # логи упавшего контейнера, главный трюк при CrashLoop
# Кто слушает порт
$ ss -ltnp # listening, tcp, numeric, process, основной инструмент
$ ss -ltnp 'sport = :8080'
$ ss -s # сводка по сокетам: сколько в TIME-WAIT и т.д.
$ ss -tanp state established | wc -l
$ lsof -i :8080 # то же, но lsof есть не везде и он медленнее
$ fuser -k 8080/tcp # убить того, кто занял порт
$ netstat -tulpn # legacy, во многих образах уже нет
# Нагрузка
$ uptime # load average 1/5/15
$ top -H -p 1312 # -H: показать потоки процесса
$ htop # то же, но по-человечески
$ vmstat 1 5 # r (runnable), b (blocked), si/so (swap), wa
$ iostat -x 1 # %util, r_await/w_await: узкое место по диску
$ pidstat -d 1 # ввод-вывод по процессам
$ free -h # смотреть надо на available, а не на free
Load average в Linux не означает «загрузку CPU в процентах». Это экспоненциально сглаженное
за 1/5/15 минут число задач, которые либо выполняются, либо готовы выполняться, либо находятся
в непрерываемом ожидании (state D). Последнее придумали именно в Linux: ожидание диска
тоже поднимает LA. Читается так: делим на число ядер. При nproc = 8 LA 8.0 значит,
что машина занята на всю. LA 24 даёт очередь втрое длиннее числа ядер. А LA 40 при
2 % CPU почти наверняка означает, что сдох диск или сетевое хранилище вроде NFS и все висят в D. Три числа
дают направление: 1.2 8.4 15.9 читается как «пик был и рассасывается»,
15.9 8.4 1.2 как «растёт прямо сейчас».
Свободная память. Классическая паника новичка: free -h показывает 200 МиБ
free при 32 ГиБ ОЗУ. Это нормально: Linux использует всю незанятую память под page cache
(кэш файлов) и буферы, потому что за простаивающую память заплачено впустую. Кэш ядро
отдаёт мгновенно, как только он кому-то понадобится. Смотреть надо на колонку
available: она и показывает, сколько реально можно занять, не уходя в swap. Тревожно,
когда available близок к нулю, или когда в vmstat ненулевые si/so
(идёт свопинг).
iowait (%wa в top) считает долю времени, когда CPU простаивал и при этом
ждал ввода-вывода хотя бы один процесс. Высокий iowait означает «процессор свободен,
но работа упирается в диск». Ожидание ответа по сети (медленная удалённая БД) iowait не даёт: такие процессы спят в состоянии S. Метрика лукавая: если параллельно есть счётный процесс,
занимающий CPU, iowait упадёт до нуля, хотя диск так же тормозит. Поэтому подтверждать надо
через iostat -x (r_await/w_await, %util) и число процессов в
состоянии D.
Зомби (Z в ps, «defunct») остаётся от процесса, который уже завершился,
а родитель не вызвал wait()/waitpid() и не забрал код возврата. Ядро
держит запись в таблице процессов ради этого кода: памяти зомби не занимает, но занимает PID.
Убить зомби нельзя, он уже мёртв: kill -9 на него не действует. Помогает либо
починить родителя, либо убить его (тогда зомби усыновляет init/PID 1
и немедленно «пожинает»). В Go зомби появляются, если ты запускаешь подпроцессы через
exec.Command и не вызываешь cmd.Wait(). Отдельный контейнерный случай:
твоё приложение стало PID 1 и не жнёт сирот, так что зомби копятся, пока не кончатся PID;
лечится флагом docker run --init (tini) или процессом-надзирателем.
cgroups и namespaces — на чём стоят контейнеры
Что физически представляет собой контейнер? Ответ короткий и для многих неожиданный: обычный процесс Linux. Никакой отдельной сущности «контейнер» в ядре нет; есть два независимых механизма, которыми процессу подрезают обзор и аппетит. Путают их на собесе постоянно, поэтому сначала простыми словами.
Namespace (пространство имён) показывает процессу урезанную версию реальности. У ядра есть глобальные списки: все процессы, все сетевые интерфейсы, все точки монтирования. Namespace создаёт для процесса отдельный такой список, и процесс видит только его. Бытовая аналогия: комната с односторонним стеклом, где ты видишь только то, что внутри, и искренне считаешь, что это весь мир. Аналогия ломается в одном месте: стены тут не защитные. Ядро у всех общее, и дыра в нём видна из любой комнаты.
Cgroup (control group, «контрольная группа»), наоборот, занимается счётчиками. Процессы объединяются в группу, и ядро для всей группы считает потраченный CPU, занятую память, число процессов и не даёт выйти за потолок. Аналогия: счётчик и автомат в электрощитке. Ничего не прячет, просто считает и вырубает при превышении.
Оба механизма ортогональны: можно взять только namespaces (изолировать, но не ограничивать) или только cgroups (ограничить, но всё показать). Контейнер получается, когда взяли оба сразу.
- Namespaces отвечают на вопрос «что процесс видит», то есть изолируют пространства имён:
pid(своя нумерация процессов, твоё приложение — PID 1),net(свои интерфейсы, свои порты, свои iptables),mnt(своё дерево монтирования),uts(свой hostname),ipc,user(маппинг uid — root внутри не root снаружи),cgroup,time. - Cgroups отвечают на вопрос «сколько процессу можно» и ведут учёт
ресурсов:
cpu.max(квота за период),memory.max(жёсткий потолок, за ним OOM внутри группы),memory.high(мягкий: за ним ядро начинает искусственно притормаживать процесс, это и называется троттлинг, от англ. throttle, «душить»; процесс не падает, его просто перестают пускать на процессор),pids.max,io.max.
Контейнер = обычный процесс Linux + набор namespaces + cgroup + корневая ФС из образа
(pivot_root поверх OverlayFS) + урезанные capabilities + seccomp-профиль. Никакой
«виртуализации» тут нет: ядро одно, общее с хостом.
systemd vs docker. systemd тоже управляет cgroups: каждый юнит живёт в своей группе,
через [Service] задаются MemoryMax=, CPUQuota=,
LimitNOFILE=, Restart=on-failure, а логи со stdout уходят в journald.
Разница в том, что systemd управляет процессами на этой машине с её файловой системой,
а docker добавляет к тому же ядру упакованную неизменяемую ФС (образ) и сетевую изоляцию.
Для одиночного сервиса на VM systemd-юнит подходит не хуже, а возни с ним обычно меньше.
# посмотреть лимиты cgroup v2 изнутри контейнера
$ cat /sys/fs/cgroup/cpu.max
200000 100000 # 200 мс CPU на каждые 100 мс = 2 ядра
$ cat /sys/fs/cgroup/memory.max
536870912 # 512 МиБ, к этому и привязывают GOMEMLIMIT
$ cat /sys/fs/cgroup/memory.current
412876800
# какие namespaces у процесса
$ ls -l /proc/1312/ns
lrwxrwxrwx 1 root root 0 Sep 16 21:27 net -> 'net:[4026534208]'
lrwxrwxrwx 1 root root 0 Sep 16 21:27 pid -> 'pid:[4026534206]'
# зайти в сетевой namespace контейнера с хоста
$ nsenter -t $(docker inspect -f '{{.State.Pid}}' app) -n ss -ltnp
nproc внутри контейнера врёт
Namespaces не изолируют /proc/cpuinfo и sysconf(_SC_NPROCESSORS_ONLN):
процесс внутри контейнера видит все ядра хоста, даже если его cgroup даёт ему полядра.
Именно отсюда растёт вся боль с GOMAXPROCS в Kubernetes: до Go 1.25 рантайм ставил
GOMAXPROCS = runtime.NumCPU(), то есть 64 на 64-ядерной ноде, при лимите в 1 ядро,
и сервис получал жестокий CFS-троттлинг. Разбираем это в статье «Docker», глава 2.1.
SSH по ключу на свежий сервер
# 1. Локально: сгенерировать пару (ed25519 короче и быстрее RSA, дефолт с OpenSSH 9.5)
$ ssh-keygen -t ed25519 -C "german@laptop" -f ~/.ssh/id_ed25519
# приватный ключ с парольной фразой; публичный в ~/.ssh/id_ed25519.pub
# 2. Положить публичный ключ на сервер
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@1.2.3.4
# руками почти то же самое (ssh-copy-id ещё ставит umask 077 и не добавляет ключ повторно):
$ cat ~/.ssh/id_ed25519.pub | ssh deploy@1.2.3.4 \
'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'
# 3. Права: sshd молча откажет, если каталог или файл доступны на запись другим
$ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
$ chown -R deploy:deploy ~/.ssh
# 4. Проверить, не закрывая текущую сессию
$ ssh -i ~/.ssh/id_ed25519 deploy@1.2.3.4 'echo ok'
$ ssh -vvv deploy@1.2.3.4 # если не пускает, здесь видно, какой ключ был предложен
# 5. Закрутить сервер: /etc/ssh/sshd_config.d/00-hardening.conf
# (sshd берёт первое значение, а sshd_config в Debian, Ubuntu и RHEL первым делом
# подключает sshd_config.d/*.conf; если там лежит 50-cloud-init.conf с
# PasswordAuthentication yes, строка в самом sshd_config ему проиграет)
# PasswordAuthentication no
# PermitRootLogin no
# PubkeyAuthentication yes
$ sudo sshd -t && sudo systemctl reload ssh # -t: проверить конфиг до перезапуска; в RHEL юнит sshd
$ sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication) ' # что sshd применил на деле
# 6. Удобство на клиенте: ~/.ssh/config
# Host prod
# HostName 1.2.3.4
# User deploy
# IdentityFile ~/.ssh/id_ed25519
# ProxyJump bastion # через бастион
$ ssh prod
Три вещи, которые отличают ответ мидла от джуна: (1) не закрывай текущую сессию, пока не
проверил вход новой, иначе останешься за дверью; (2) sshd -t перед
reload; (3) права на ~/.ssh, из-за которых чаще всего и выходит «ключ
не работает, а всё вроде правильно»: sshd отказывает молча, и видно это только на сервере, в
journalctl -u ssh. Плюс: ключ для CI заводи отдельный, без парольной фразы,
с ограничениями в authorized_keys (command=, no-pty).
Вопросы
12Память
У процесса своё виртуальное адресное пространство, своя таблица файловых дескрипторов,
свой cwd, свои uid/gid и свои обработчики сигналов. Потоки одного процесса делят абсолютно
всё это: код, кучу, глобальные переменные, дескрипторы. Потоку принадлежат только стек
(обычно 8 МиБ виртуально, по ulimit -s), регистры, TLS, маска сигналов,
приоритет и привязка к ядрам.
Отсюда и разница в модели программирования: два процесса нужно связывать через IPC,
а два потока уже видят одну память, и потому им нужны мьютексы.
В Linux и то и другое живёт как одна структура task_struct, созданная
clone() с разными флагами. С CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD
получается поток (тот же PID), без них процесс. Планировщик их не различает.
Переключение контекста
Прямая стоимость переключения сводится к сохранению и восстановлению регистров и работе
планировщика, это десятые доли микросекунды. Но главная цена косвенная. При переключении
между процессами меняется таблица страниц (CR3), TLB и кэши L1/L2 остывают
(без PCID/ASID TLB сбрасывается целиком), и следующие сотни обращений к памяти будут медленными.
Между потоками одного процесса таблица страниц та же, поэтому TLB выживает и переключение
дешевле. Плюс само создание: fork() стоит от десятков микросекунд до миллисекунд
(растёт с памятью процесса), clone() потока единицы микросекунд.
Где здесь горутина
Горутина живёт в куче процесса Go как структура runtime.g. Планировщик Go работает
по схеме G–M–P: G это горутина, M поток ОС, P логический процессор
(их GOMAXPROCS, по умолчанию по числу доступных ядер). Рантайм мультиплексирует
тысячи G на десяток M. Переключить горутину значит сменить несколько регистров и перейти
на другой стек: без входа в ядро, 50–200 нс. Стек начинается с 2 КиБ (с Go 1.19 рантайм
может сразу дать больше, по среднему размеру стеков на прошлой сборке мусора) и растёт
копированием, а не резервируется на 8 МиБ. Поэтому миллион горутин живёт спокойно,
а миллион потоков нет: только под стеки ушло бы 8 ТиБ виртуальной памяти, и планировщик
ядра захлебнулся бы.
«Горутина не бесплатна: это от 2 КиБ стека плюс структура g, и обходится она
не дешевле потока, если внутри неё сидит блокирующий системный вызов: тогда
рантайм отдаёт P другому потоку, а этот M висит в ядре. Число потоков процесса Go
видно в /proc/PID/status в поле Threads, и если оно растёт
до сотен, значит, где-то много блокирующих syscall или cgo-вызовов.
Жёсткий предел стоит на 10 000 потоков (runtime/debug.SetMaxThreads).»
Сегменты
Снизу вверх: .text (код, r-x), .rodata (константы, r--),
.data (инициализированные глобалы), .bss (глобальные нули,
в файле места не занимают), куча (растёт вверх), область mmap
(библиотеки, файлы, крупные аллокации, арены Go), стек (растёт вниз от верхней границы
пользовательской части). Живьём это видно через cat /proc/PID/maps.
Виртуальная память
Адрес, которым оперирует программа, — виртуальный. Перевод в физический делает MMU по
четырёхуровневой таблице страниц; результаты кэшируются в TLB. Единица — страница 4 КиБ.
Это даёт три вещи: изоляцию (чужие страницы просто не отображены), возможность дать процессу
больше памяти, чем есть физически, и разделение общих страниц (один .text
libc на все процессы).
Как ОС управляет
- Ленивое выделение.
mmapтолько резервирует диапазон. Физическую страницу выдают при первом обращении, и это minor page fault (микросекунда). - Major page fault ловим, когда нужной страницы в RAM нет: читаем с диска или из swap: десятки микросекунд на SSD, миллисекунды на HDD.
- Copy-on-write. При
fork()копируются не данные, а таблицы страниц; все страницы становятся read-only, и физическое копирование происходит постранично при первой записи. - Page cache. Вся свободная память уходит под кэш файлов и отдаётся мгновенно, когда нужна.
- Overcommit и OOM killer. Ядро обещает больше, чем имеет; когда память кончилась
реально, выбирается жертва по
oom_scoreи получаетSIGKILL.
Потому что смотрят на VSZ. VSZ показывает, сколько виртуального пространства
зарезервировано, а рантайм Go заранее резервирует адреса под кучу и свои служебные
структуры, и это ничего не стоит.
Реальное потребление видно в RSS (VmRSS в /proc/PID/status),
а в контейнере правильнее container_memory_working_set_bytes: именно по нему
kubelet решает, кого вытеснить с ноды (сам OOMKill по лимиту делает ядро). И RSS у Go не всегда падает
сразу после GC: память возвращается ядру через MADV_FREE/MADV_DONTNEED
с задержкой, и на графике это выглядит как «утечка», которой нет.
SIGSEGV; у горутины стек начинается от 2 КиБ и растёт копированием
до 1 ГБ, а на пределе рантайм падает с fatal error: stack overflow,
которую нельзя поймать через recover.Как это устроено в ОС
Каждый вызов функции кладёт на стек кадр: адрес возврата, сохранённые регистры, локальные
переменные и аргументы. Стек растёт вниз, к меньшим адресам. Снизу стоит guard page —
страница без прав доступа. Когда рекурсия дотягивается до неё, MMU генерирует исключение,
ядро превращает его в SIGSEGV, и процесс умирает с «segmentation fault».
Размер стека потока фиксируется при создании (ulimit -s, обычно 8 МиБ) и
не меняется, потому что стек должен быть непрерывным куском адресов, а расти ему некуда:
соседние адреса уже могут быть заняты.
Как это в Go
Стек горутины «сегментированный» только исторически; с Go 1.3 он перемещаемый.
Компилятор вставляет в пролог почти каждой функции проверку границы стека; при нехватке
вызывается morestack: рантайм выделяет вдвое больший кусок, копирует старый
стек и корректирует все указатели внутрь него. Возвращать указатель на локальную
переменную в Go безопасно по другой причине: escape-анализ это видит и размещает её в куче.
А указатели на стек внутри самой горутины при копировании переедут вместе со стеком.
func rec(n int) int { return rec(n + 1) }
func main() {
defer func() { recover() }() // не поможет
rec(0)
}
// runtime: goroutine stack exceeds 1000000000-byte limit
// fatal error: stack overflow
Практические следствия
- Глубокая рекурсия в Go «стоит» кучу мелких
morestackи копирований — обход глубокого дерева лучше переписать на явный стек-слайс. - Крупные локальные массивы (
var buf [64 << 10]byte) тоже вызывают рост стека и его копирование (больше 128 КиБ компилятор с Go 1.24 сам уносит в кучу); лучшеmakeв кучу илиsync.Pool. - Предел меняется через
debug.SetMaxStack, но если ты об этом задумался, скорее всего, у тебя бесконечная рекурсия.
«Поймается ли recover-ом переполнение стека?» — нет. Не ловятся recover-ом:
stack overflow, concurrent map writes, all goroutines are
asleep - deadlock!, out of memory рантайма и любой fatal error.
Это не паника, а фатальная ошибка: defer-ы не выполняются, процесс умирает
сразу.
strace -f, для сводки — strace -c.Механика
На x86-64 программа кладёт номер вызова и аргументы в регистры и выполняет syscall.
Процессор переключается из третьего кольца в нулевое, ядро проверяет аргументы, делает
работу (возможно, блокируется), возвращает результат в регистре. Стоимость «пустого»
вызова вроде getpid() порядка сотни наносекунд (замер на arm64 без KPTI,
ядро 7.0: 120–180 нс); с митигацией Meltdown дороже, KPTI меняет таблицу страниц на каждом
входе в ядро и выходе, а без PCID ещё и сбрасывает TLB. Плюс после возврата холодные кэши.
Часть вызовов ядро отдаёт бесплатно через vDSO — страницу ядра, отображённую в
каждый процесс: clock_gettime, gettimeofday. Поэтому
time.Now() в Go стоит десятки наносекунд, а не микросекунду.
Как Go с этим живёт
Перед блокирующим вызовом рантайм делает entersyscall; если вызов затянулся
(от 20 мкс, когда P ждёт работа, иначе до 10 мс), фоновый поток sysmon отбирает
у застрявшего M его P и отдаёт другому потоку — планировщик не останавливается. Для сети всё ещё лучше: сокеты
в Go всегда неблокирующие, ожиданием заведует netpoller поверх epoll,
так что десять тысяч соединений обслуживаются несколькими потоками.
Инструменты
$ strace -f -p 1312 # -f обязателен: Go многопоточный
$ strace -c -f -p 1312 # сводка: топ вызовов по времени и количеству
$ strace -f -e trace=openat,close ./app # ловим утечку дескрипторов
$ perf trace -p 1312 # дешевле strace
$ bpftrace -e 'tracepoint:syscalls:sys_enter_write { @[comm]=count(); }'
strace работает через ptrace и останавливает процесс на каждом
вызове; если вызовы частые, программа замедляется в десятки и сотни раз. На проде это уже инцидент сам по себе; там правильнее
perf/eBPF. Раз syscall дорог, его батчат.
bufio.Writer вместо голого Write, writev вместо
серии записей, чтение большими блоками. Классический профиль «90 % времени в
write» почти всегда означает, что кто-то пишет в лог по одной строке
без буфера.
ulimit -n (часто 1024 по умолчанию, но Go с 1.19 сам поднимает мягкий лимит до
жёсткого). В Go чаще всего это незакрытый
resp.Body, незакрытые rows или новый http.Transport
на каждый запрос.Что это
Дескриптор это маленькое целое, индекс в таблице процесса. За ним в ядре стоит
struct file со смещением, флагами и счётчиком ссылок, а за ней уже inode,
сокет, pipe, eventfd или epoll. 0/1/2 заняты под stdin/stdout/stderr. В Unix «всё есть файл»,
поэтому и TCP-соединение это fd, и epoll-инстанс тоже fd.
Диагностика
$ ulimit -n; ulimit -Hn # мягкий и жёсткий лимиты
$ cat /proc/1312/limits | grep 'open files'
$ ls /proc/1312/fd | wc -l # сколько занято сейчас
$ lsof -p 1312 | awk '{print $5}' | sort | uniq -c | sort -rn
$ ss -tanp | grep CLOSE-WAIT | wc -l # верный признак незакрытых тел ответов
Типовые причины в Go
- Не закрыт
resp.Body. Даже если тело не нужно, закрывать обязательно, и лучше дочитать (io.Copy(io.Discard, resp.Body)), иначе соединение не вернётся в keep-alive-пул (с Go 1.27 закрытие само дочитывает до 256 КиБ). - Не закрыт
rowsизdatabase/sqlпри раннемreturn. Соединение не возвращается в пул, пул пустеет, приложение висит. - Новый
http.Transport(илиhttp.Clientсо своим транспортом) на каждый запрос. У каждого транспорта свой пул, старые сокеты копятся; клиент без своего транспорта берёт общий, по умолчанию, и не течёт. defer f.Close()внутри цикла — закроется только в конце функции.- Отсутствие таймаутов: подвисшие соединения держат fd, пока их не прибьёт TCP.
- Реально высокая нагрузка: 5000 одновременных соединений при жёстком лимите 4096 — лимит и правда надо поднять (мягкий Go поднимает до жёсткого сам).
Как поднять лимит
# systemd unit
[Service]
LimitNOFILE=65535
# docker
docker run --ulimit nofile=65535:65535 app
# /etc/security/limits.conf (для интерактивных сессий)
deploy soft nofile 65535
deploy hard nofile 65535
«Сначала я смотрю, утечка это или честная нагрузка: ls /proc/PID/fd | wc -l
в динамике и разбивка по типам через lsof. Если растёт монотонно и не падает,
это утечка: ищу незакрытые тела и rows. Много CLOSE_WAIT
значит, что не закрываем мы. А на честной нагрузке поднимаю
LimitNOFILE и заодно проверяю MaxIdleConnsPerHost.
Поднять лимит первым делом, не разобравшись, значит отложить падение на пару часов.»
SIGTERM — вежливая просьба закрыться, её нужно
перехватывать и делать graceful shutdown; SIGKILL и SIGSTOP
перехватить нельзя в принципе — их обрабатывает ядро.Кто что значит
SIGTERM(15) значит «заверши работу». Шлютkill,docker stop, kubelet при удалении пода, systemd приsystemctl stop. Перехватывается, и именно на нём строится graceful shutdown.SIGINT(2), он же Ctrl+C. Семантически то же самое; в Go обрабатывают вместе с SIGTERM.SIGKILL(9) убивает мгновенно, обработчик поставить нельзя. Шлютkill -9, OOM killer и kubelet после истечения grace period.SIGQUIT(3) приходит по Ctrl+\. В Go по умолчанию печатает стеки всех горутин и завершает процесс с кодом 2: бесплатный способ понять, где всё зависло (GOTRACEBACK=allдля этого не нужен).SIGHUP(1) исторически означал «терминал закрыт», а по традиции демонов «перечитай конфиг». Стандарта нет, это соглашение.SIGSTOP/SIGCONTзамораживают и размораживают;SIGSTOPтоже неперехватываемый.SIGPIPE(13) прилетает при записи в закрытый сокет. Go его игнорирует для всех дескрипторов, кроме stdout/stderr, и превращает в ошибкуEPIPE.
Как правильно реагировать
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
stop() // второй сигнал теперь убьёт немедленно, так и надо
shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
_ = srv.Shutdown(shCtx) // перестать принимать новые, доиграть текущие
consumer.Close() // потом входящие из брокера
pool.Close() // и только потом исходящие ресурсы
Порядок важен: сначала закрываем входящие источники работы, потом ждём активную работу,
потом закрываем исходящие ресурсы, последними флашим трейсы и метрики. Общий бюджет
должен быть меньше, чем terminationGracePeriodSeconds (в k8s по умолчанию 30 с),
иначе получишь SIGKILL посреди работы.
Процесс с PID 1 не имеет обработчиков сигналов по умолчанию: ядро специально не
применяет к нему default action для перехватываемых сигналов. Если приложение не поставило
свой обработчик SIGTERM, оно его просто проигнорирует, docker stop подождёт
10 секунд и пришлёт SIGKILL. Go-бинаря это не касается: обработчики ставит сам рантайм, и даже
без перехвата в коде процесс сразу выходит (с Go 1.27 с кодом 143, раньше с кодом 2), только
без graceful shutdown. Второй вариант той же беды: ENTRYPOINT в shell-форме
превращается в /bin/sh -c "app", и PID 1 становится sh, который
сигнал никому не передаёт (так в Debian и Ubuntu, где sh — это dash; busybox sh в Alpine
единственную команду запускает через exec, и PID 1 достаётся приложению). Лечится exec-формой ENTRYPOINT ["/app"] или
--init/tini.
Ровно два: SIGKILL (9) и SIGSTOP (19). Их нельзя ни поймать,
ни заблокировать, ни проигнорировать. Всё остальное — можно.
pkill/killall по имени,
kill PID после pgrep, а для сервиса —
systemctl stop. По умолчанию все шлют SIGTERM, и это правильно.$ pgrep -a myapp # найти: PID + командная строка
$ kill $(pgrep myapp) # классика
$ pkill myapp # по имени процесса, но по подстроке: заденет и myapp-worker
$ pkill -f 'myapp --config=prod' # -f: матчить всю командную строку
$ pkill -u deploy -f worker # сузить по пользователю
$ killall myapp # точное имя; на Solaris killall убивает вообще всё
$ systemctl stop myapp # правильный способ для systemd-юнита
$ kill -QUIT $(pgrep myapp) # Go: дамп стеков всех горутин перед смертью
$ kill -9 $(pgrep myapp) # последнее средство
Чем отличаются
killработает по PID и ничего не знает про имена.pkillматчит имя регуляркой;-fрасширяет матч на всю командную строку. Опасен:pkill -f goубьёт больше, чем ты хотел, поэтому всегда сначалаpgrep -afс тем же паттерном.killallтребует точного совпадения имени процесса, без регулярок.systemctl stopубивает не процесс, а всю cgroup юнита — вместе с детьми, что важно, если приложение форкает воркеров.
jobs / fg / bg
Это управление задачами внутри одного шелла. ./app & запускает в фоне,
jobs -l показывает список с номерами и PID, fg %1 возвращает задачу
на передний план, bg %1 продолжает её в фоне. Ctrl+Z шлёт
SIGTSTP (пауза), Ctrl+C — SIGINT, Ctrl+\ — SIGQUIT.
Но фоновая задача остаётся ребёнком шелла, и при закрытии терминала получит
SIGHUP. Чтобы выжила, есть nohup ./app &, disown -h %1
или setsid; а по-хорошему долгоживущее отправляют в systemd-юнит или в
tmux/screen.
journalctl -u, классика —
/var/log/* и dmesg, контейнеры — docker logs /
kubectl logs (это просто stdout/stderr PID 1). Порт — ss -ltnp.Логи
$ journalctl -u myapp -f # хвост
$ journalctl -u myapp --since '10 min ago' -p err # только ошибки (голый stderr журнал пишет как info)
$ journalctl -u myapp -b -1 # прошлая загрузка
$ journalctl -k | grep -i oom # сообщения ядра
$ dmesg -T | grep -i -E 'oom|killed process' # кто был убит OOM killer
$ tail -f /var/log/syslog # Ubuntu и Debian до 12; RHEL: /var/log/messages
$ docker logs -f --tail 100 --since 10m app
$ kubectl logs -f deploy/app --tail=100
$ kubectl logs pod/app-xyz --previous # логи предыдущего, упавшего контейнера
Про контейнеры: у контейнера нет «файла лога». Приложение обязано
писать в stdout/stderr, а runtime сам складывает это в файл на ноде (у Docker по умолчанию
json-file, у kubelet свой текстовый формат CRI) и отдаёт по
docker logs/kubectl logs. Писать логи в файл внутри контейнера —
антипаттерн: их никто не соберёт, а диск ноды они забьют.
Кто слушает порт
$ ss -ltnp # l listening, t tcp, n numeric, p process
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 4096 *:8080 *:* users:(("myapp",pid=1312,fd=4))
$ ss -ltnp 'sport = :8080'
$ ss -Htanp state established | wc -l # сколько активных соединений (-H: без заголовка)
$ ss -s # сводка, в т.ч. сколько в TIME-WAIT
$ lsof -i :8080 # альтернатива; медленнее, есть не везде
$ fuser -k 8080/tcp # убить того, кто держит порт (шлёт SIGKILL, не SIGTERM)
$ netstat -tulpn # legacy, в slim-образах отсутствует
ss с хоста не увидит сокеты внутри контейнера: у него свой сетевой namespace.
Зайти внутрь помогает nsenter -t $(docker inspect -f '{{.State.Pid}}' app) -n ss -ltnp.
А в distroless-образе ss просто нет, там спасают
kubectl debug с ephemeral-контейнером.
available.Что читать в top
- строка
%Cpu(s):us(user),sy(kernel),wa(iowait),st(steal — время отбирает гипервизор, верный признак «шумного соседа» на облачной VM); RESэто резидентная память (реальная),VIRTвиртуальная (у Go всегда огромна, это норма),SHRразделяемая;- состояние
S:Rвыполняется,Sспит,Dждёт ввода-вывода непрерываемо (диск!),Zзомби; top -H -p PIDпокажет потоки: видно, сколько их наплодил рантайм Go.
Load average
Три числа — экспоненциально сглаженные средние за 1, 5 и 15 минут. В Linux (в отличие от
классического Unix) в счёт идут и runnable-задачи, и задачи в состоянии
D. Поэтому LA растёт и от нехватки CPU, и от тормозящего диска или NFS.
- Нормировать на ядра:
nproc = 8, LA 8.0 означает загрузку ровно на 100 %. - LA 24 при 8 ядрах даёт очередь втрое длиннее, чем можно обслужить, и задержки растут.
- LA 40 при
%usоколо нуля и высоком%waупирается в диск или NFS, а не в CPU: все висят вD. - Динамика:
1.2 8.4 15.9значит, пик прошёл;15.9 8.4 1.2— растёт прямо сейчас.
Память и кэши
$ free -h
total used free shared buff/cache available
Mem: 31Gi 9.1Gi 412Mi 120Mi 21Gi 21Gi
# ^^^^^ пугает ^^^^ вот что важно
Незанятая память — потраченные впустую деньги, поэтому Linux заполняет её page cache
(кэшем прочитанных файлов). Кэш отдаётся мгновенно по первому требованию, и колонка
free почти всегда маленькая, это нормально. Реальный показатель тут
available: сколько можно занять, не уходя в swap. Тревожно, когда
available близок к нулю или когда в vmstat 1 ненулевые
si/so: начался свопинг, и задержки вырастут на порядки.
Внутри контейнера top и free показывают хост, а не cgroup:
эти данные берутся из /proc/meminfo, который namespace не изолирует. Реальные
цифры лежат в /sys/fs/cgroup/memory.current и memory.max.
Отсюда же известный парадокс: free в поде говорит «свободно 20 ГиБ»,
а контейнер уходит в OOMKilled на 512 МиБ.
wait(); убить его нельзя, лечится через родителя.iowait
Формально %wa — idle-время процессора, в течение которого была хотя бы одна
задача, заблокированная на вводе-выводе. Высокий iowait означает: «CPU свободен, упираемся
в диск или сетевое хранилище вроде NFS». Метрика лукавая по двум причинам. Во-первых, если параллельно крутится
счётная задача, iowait упадёт до нуля, хотя диск тормозит так же. Во-вторых, она усреднена
по ядрам. Поэтому подтверждать надо через iostat -x 1
(r_await/w_await показывают задержку запроса, %util занятость устройства) и через
число процессов в состоянии D в ps aux. В Go-сервисе высокий
iowait обычно означает не «мой код медленный», а «диск под нами тормозит»; медленная
удалённая БД iowait не даёт, горутины ждут её в netpoller.
Зомби
Когда процесс завершается, ядро освобождает его память и дескрипторы, но оставляет запись
в таблице процессов с кодом возврата, вдруг родителю интересно. Родитель обязан вызвать
wait()/waitpid() и «пожать» ребёнка. Пока он этого не сделал,
ребёнок висит в состоянии Z (defunct). Зомби не занимает ни памяти, ни CPU,
зато занимает PID, а PID конечны (/proc/sys/kernel/pid_max).
kill -9на зомби бесполезен: он уже мёртв.- Чинить надо родителя: заставить его сделать
wait()(например, приславSIGCHLD) или убить, тогда зомби усыновит PID 1 и немедленно пожнёт. - В Go: если ты запустил
exec.Commandи не вызвалcmd.Wait()(или использовалStartбезWait), получишь зомби.cmd.Run()делаетStart+Waitсам. - В контейнере твоё приложение работает как PID 1 и обязано жать осиротевшие процессы.
Обычные приложения этого не умеют, поэтому есть
docker run --init(tini) иshareProcessNamespaceв k8s.
$ ps aux | awk '$8 ~ /^Z/ {print}' # найти зомби
$ ps -o ppid= -p 4711 # кто родитель, его и чинить
$ ps -eo state | grep -c D # сколько застряло в непрерываемом ожидании
Сирота (orphan) — живой процесс, чей родитель умер; его усыновляет PID 1, и он спокойно работает дальше. Зомби, наоборот, мёртвый процесс, чей живой родитель не забрал код возврата. Путая их, ты сразу показываешь, что тему знаешь по верхам.
Namespaces — изоляция видимости
pidдаёт свою нумерацию: приложение становится PID 1 и не видит чужих процессов;- у
netсвои интерфейсы, свои порты, свои правила iptables (поэтому два контейнера могут оба слушать 8080); mntдержит своё дерево монтирования (сюда подставляется ФС образа);utsотвечает за hostname,ipcза разделяемую память;userмаппит uid: root внутри может быть непривилегированным снаружи;cgroupиtimeзадают вид на иерархию cgroups и на часы.
Cgroups (v2) — ограничение ресурсов
cpu.maxзадаёт квоту и период, например200000 100000= 2 ядра; превышение даёт троттлинг (задачу останавливают до конца периода);memory.maxставит жёсткий потолок, за которым OOM внутри группы;memory.highмягкий, включает агрессивный reclaim;pids.max,io.max,io.weight.
Контейнер собирается из этого плюс pivot_root на OverlayFS, урезанные
capabilities (нет CAP_SYS_ADMIN и т. д.) и seccomp-профиль,
запрещающий опасные системные вызовы. Именно поэтому контейнер стартует за доли секунды:
это просто clone() с флагами, а не загрузка ОС.
systemd vs docker
systemd тоже управляет cgroups: каждый юнит живёт в своей группе, и через
[Service] задаются MemoryMax=, CPUQuota=,
LimitNOFILE=, политика перезапуска Restart=on-failure, а stdout
уходит в journald. Он умеет и часть namespaces (PrivateTmp=,
ProtectSystem=). Разница в другом: systemd обычно запускает бинарь в файловой
системе этой машины, и зависимости ставишь на неё сам (запускать сервис из образа
он тоже умеет, но так делают редко), а docker приносит с
собой неизменяемый образ со всей ФС и полную сетевую изоляцию. Для одиночного Go-сервиса
на выделенной VM systemd-юнит — вполне взрослый и более простой выбор; контейнеры выигрывают,
когда нужны одинаковые артефакты между окружениями и оркестрация.
«Namespaces не изолируют /proc/cpuinfo и /proc/meminfo. Процесс
внутри контейнера видит все ядра и всю память хоста, хотя его cgroup даёт ему полядра
и 512 МиБ. Отсюда исторические проблемы с GOMAXPROCS (лечится
automaxprocs, а с Go 1.25 рантайм читает лимит cgroup сам) и с JVM,
где эту же проблему закрыли флагом UseContainerSupport.»
~/.ssh/authorized_keys нужного пользователя, закрыть их правами 700 и 600,
проверить вход второй сессией и только потом выключить парольную аутентификацию.$ ssh-keygen -t ed25519 -C 'german@laptop' # приватный с парольной фразой
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@1.2.3.4
$ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys # на сервере
$ ssh -i ~/.ssh/id_ed25519 deploy@1.2.3.4 'echo ok'
# /etc/ssh/sshd_config.d/00-hardening.conf: PasswordAuthentication no, PermitRootLogin no
$ sudo sshd -t && sudo systemctl reload ssh # в RHEL юнит sshd
Что здесь важно проговорить
- ed25519, а не RSA: короче, быстрее, современная кривая. RSA берут, только если сервер древний, и тогда минимум 4096 бит.
- Права. При
StrictModes yessshd молча откажет, если домашний каталог,~/.sshилиauthorized_keysдоступны на запись группе или всем (Debian и Ubuntu прощают запись группе, если в ней нет никого, кроме владельца). Поэтому и ставят 700 и 600 — с запасом. Это самая частая причина «ключ не работает». Смотриssh -vvvна клиенте иjournalctl -u sshна сервере. - Не закрывай текущую сессию, пока новым терминалом не проверил вход по ключу.
sshd -tперед reload. Синтаксическая ошибка в конфиге приrestartоставит тебя снаружи.- Отдельный пользователь под деплой, root-логин выключен, sudo выдан по правилам.
- Ключ для CI держи отдельно, без парольной фразы, с ограничениями прямо в
authorized_keys:command="/usr/local/bin/deploy",no-pty,no-port-forwarding. ~/.ssh/configс алиасами иProxyJump bastionпригождается каждый день;ssh-agentизбавляет от постоянного ввода фразы.
Раздавать ключи руками не масштабируется. Дальше authorized_keys
раскладывает Ansible или конфиг-менеджмент, потом идут SSH-сертификаты
(короткоживущие, подписанные CA, отзывать ключи при увольнении не надо), потом
bastion с записью сессий. На собесе достаточно упомянуть, что ты знаешь про этот путь.
1.2Файлы и диски
Для ядра файл начинается с inode, записи с правами, размером и адресами блоков, а имя лежит отдельно,
в каталоге. Отсюда и из page cache растут сюрпризы на сервере: rm не возвращает место, диск «полон» при
свободных гигабайтах, rename подменяет файл за один шаг, а write отвечает
успехом раньше, чем байты попали на диск.
- Чем hard link отличается от symlink и почему
dfиduрасходятся. - Как вернуть место от удалённого лога, не перезапуская сервис.
- Что даёт бит
xна каталоге, зачем/tmpsticky bit и как пустить Go-бинарь на 80-й порт без root. - Где лежат байты между
writeи диском и как записать файл из Go, чтобы после падения не остался обрубок. - Почему запись файлов в контейнере расходует его лимит памяти и что показывает
/proc/<pid>.
Примеры сняты в контейнерах debian:13 под Docker Desktop 29.8 (ядро 7.0.12 в его
виртуальной машине), Go 1.27.1. Номера inode, размеры и часть sysctl у тебя будут свои.
Имя лежит в каталоге, файл лежит в inode
Каталог устроен как таблица из двух колонок: имя и номер inode. Inode (index node, индексный
узел) хранит всё остальное: тип, права, владельца, размер, времена, счётчик ссылок и адреса блоков.
Имени в inode нет, а номер уникален только внутри одной файловой системы. После open
процесс работает с inode через дескриптор (глава 1.1), и судьба имени его больше не касается.
$ echo 'port: 8080' > app.yaml
$ ls -li app.yaml
55190 -rw-r--r-- 1 root root 11 Sep 16 21:24 app.yaml
$ stat app.yaml
File: app.yaml
Size: 11 Blocks: 8 IO Block: 4096 regular file
Device: 0,60 Inode: 55190 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2026-09-16 21:24:44.720859007 +0000
Modify: 2026-09-16 21:24:44.721930090 +0000
Change: 2026-09-16 21:24:44.721930090 +0000
Birth: 2026-09-16 21:24:44.720859007 +0000
Links: 1 означает, что на inode смотрит одно имя. Blocks считается по 512 байт, и
восемь единиц составляют один блок на 4 КиБ. Change часто принимают за время создания, а
он сдвигается при любой правке inode: смене прав, новой ссылке, записи в файл. Создание записано в
Birth.
Hard link и symlink
ln добавляет в каталог ещё одну строку с тем же номером inode. Это жёсткая ссылка
(hard link), и «оригинала» у inode нет: все имена равноправны. ln -s заводит новый inode
типа symlink, внутри которого лежит строка с путём.
$ ln app.yaml app-hard.yaml
$ ln -s app.yaml app-sym.yaml
$ stat -c '%n inode=%i links=%h size=%s %F' app.yaml app-hard.yaml app-sym.yaml
app.yaml inode=55190 links=2 size=11 regular file
app-hard.yaml inode=55190 links=2 size=11 regular file
app-sym.yaml inode=55191 links=1 size=8 symbolic link
$ rm app.yaml
$ cat app-hard.yaml
port: 8080
$ cat app-sym.yaml
cat: app-sym.yaml: No such file or directory
$ echo 'port: 9090' > app.yaml
$ ls -li app.yaml
55192 -rw-r--r-- 1 root root 11 Sep 16 21:24 app.yaml
$ cat app-sym.yaml
port: 9090
$ ln app-hard.yaml /data/app-hard.yaml
ln: failed to create hard link '/data/app-hard.yaml' => 'app-hard.yaml': Invalid cross-device link
Размер symlink 8 байт равен длине строки app.yaml. rm вызывает
unlink: убирает строку из каталога и уменьшает счётчик, а inode живёт, пока на него смотрит
хоть одно имя. Symlink помнил имя, а не номер: без имени повис, а с новым app.yaml повёл к
другому inode, 55192. Путь в symlink разбирается при каждом обращении, относительный считается от
каталога ссылки. Жёсткую ссылку нельзя сделать на каталог (. и .. ядро заводит
само) и в другую файловую систему, где номер inode ничего не значит: /data здесь отдельный
tmpfs.
Удалённый файл, который занимает место
Кроме имён ядро учитывает, кто держит inode открытым, и отдаёт блоки, только когда не осталось ни того,
ни другого. Типичный случай: сервис открыл лог при старте, файл удалили, а место не вернулось.
Программа на Go applog открывает лог с O_APPEND, пишет 200 МиБ и раз в секунду
добавляет строку. /data здесь tmpfs, чтобы df показывал только наш файл; на
корне контейнера (overlay поверх ext4) результат тот же.
$ mkdir -p /data/log; applog /data/log/app.log 200 &
[1] 13
pid 13 wrote 200 MiB to /data/log/app.log
$ rm /data/log/app.log
$ df -h /data; du -sh /data
Filesystem Size Used Avail Use% Mounted on
tmpfs 512M 201M 312M 40% /data
0 /data
$ lsof -nP +L1
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
applog 13 root 4w REG 0,118 209715252 0 3 /data/log/app.log (deleted)
$ ls -l /proc/$(pgrep applog)/fd | grep deleted
l-wx------ 1 root root 64 Sep 16 22:07 4 -> /data/log/app.log (deleted)
du обходит каталоги и складывает размеры найденных файлов, удалённого среди них нет.
df спрашивает у файловой системы число занятых блоков (вызов statfs), а блоки
всё ещё принадлежат inode. lsof +L1 отбирает открытые файлы, у которых меньше одной
ссылки. lsof есть не в каждом образе, а /proc/<pid>/fd есть всегда, и к
пути удалённого файла ядро дописывает (deleted). Перезапуск сервиса вернёт место, но
файл можно обрезать и сейчас: открытие /proc/13/fd/4 на запись открывает сам inode.
$ : > /proc/$(pgrep applog)/fd/4
$ df -h /data
Filesystem Size Used Avail Use% Mounted on
tmpfs 512M 0 512M 0% /data
$ cat /proc/$(pgrep applog)/fd/4
2026-09-16T22:07:26Z tick
2026-09-16T22:07:27Z tick
2026-09-16T22:07:28Z tick
Процесс при этом пишет дальше в удалённый файл, так что обрезка снимает только симптом. Причину убирают иначе: сервис переоткрывает лог после ротации или пишет в stdout, как принято в контейнерах.
Место есть, а inode кончились
Бывает и наоборот: запись падает с «No space left on device», а df -h показывает свободное
место. На ext4 число inode задаётся при создании файловой системы, и на множестве мелких файлов inode
кончаются раньше блоков. На tmpfs лимит задаёт опция nr_inodes, у /small здесь 1000:
$ cd /small
$ for i in $(seq 1 1000); do echo x > f$i || break; done
bash: f1000: No space left on device
$ df -h /small; df -i /small
Filesystem Size Used Avail Use% Mounted on
tmpfs 64M 4.0M 61M 7% /small
Filesystem Inodes IUsed IFree IUse% Mounted on
tmpfs 1000 1000 0 100% /small
Один inode занят корнем, и тысячный файл не поместился. ENOSPC одна на оба случая (в Go
errors.Is(err, syscall.ENOSPC)), поэтому смотрят и df -h, и df -i.
mkfs.ext4 по умолчанию заводит inode на каждые 16 КиБ: на 10 ГиБ это 655 360 inode, с
-i 4096 уже 2 621 440. XFS выделяет inode по мере надобности, в пределах доли места
maxpct.
Права: rwx, umask и особые биты
В -rw-r--r-- первый символ задаёт тип, дальше идут три тройки rwx: владелец,
группа, остальные. Восьмерично тройка становится цифрой (r = 4, w = 2, x = 1), поэтому
0644 и есть rw-r--r--. Ядро берёт одну тройку: владельцу тройку владельца,
члену группы файла тройку группы, остальным последнюю. Тройки не складываются, и владелец может
получить отказ там, где остальным можно. Root проверку обходит благодаря capability
CAP_DAC_OVERRIDE и читает даже файл с правами 000.
$ as_nobody() { setpriv --reuid=65534 --regid=65534 --clear-groups "$@"; }
$ echo hello > own.txt; chown nobody own.txt; chmod 074 own.txt; ls -l own.txt
----rwxr-- 1 nobody root 6 Sep 16 21:25 own.txt
$ as_nobody cat own.txt
cat: own.txt: Permission denied
Права нового файла просит программа, а umask процесса вычитает биты: touch просит
0666, mkdir просит 0777, и при umask 027 выходит
0640 и 0750. Так же обрезается 0o644 в os.WriteFile.
На каталоге буквы значат другое. r разрешает прочитать список имён, x даёт
пройти через каталог к inode, а w даёт создавать, удалять и переименовывать записи, причём
только вместе с x.
$ mkdir box; echo secret > box/a.txt; chmod 744 box
$ as_nobody ls -l box
ls: cannot access 'box/a.txt': Permission denied
total 0
-????????? ? ? ? ? ? a.txt
$ chmod 711 box
$ as_nobody ls box
ls: cannot open directory 'box': Permission denied
$ as_nobody cat box/a.txt
secret
$ mkdir shared; chmod 777 shared; echo data > shared/root.txt
$ as_nobody sh -c 'echo x >> shared/root.txt'
sh: 1: cannot create shared/root.txt: Permission denied
$ as_nobody rm -f shared/root.txt; ls -l shared
total 0
С одним r ls получил имена, но ни одного inode, с одним x файл с
известным именем открывается. Удаление меняет каталог, а не файл, и nobody удалил файл, в
который не мог писать. В /tmp от этого защищает sticky bit (t,
1000): удалять и переименовывать записи там могут только владелец файла, владелец каталога
и root. setgid на общем каталоге (s у группы, 2000) даёт новым файлам
его группу. С setuid на исполняемом файле (s у владельца, 4000)
программа работает с uid владельца файла, так passwd пишет в /etc/shadow. На
скрипты Linux этот бит не применяет, а опция nosuid и флаг no_new_privs его
отключают.
$ echo data > /tmp/root.txt; as_nobody rm -f /tmp/root.txt
rm: cannot remove '/tmp/root.txt': Operation not permitted
Capabilities вместо root
Проверку «uid 0 может всё» ядро разрезало на capabilities, отдельные привилегии:
CAP_NET_BIND_SERVICE разрешает слушать порты ниже 1024, CAP_SYS_ADMIN
монтировать и многое другое. На ядре 7.0 их 41 (cap_last_cap = 40). Capability можно выдать процессу, а можно записать
в расширенный атрибут исполняемого файла security.capability, и процесс получит её при
exec.
Проверим на Go-программе, которая слушает :80 и печатает CapEff из
/proc/self/status. Docker опускает в контейнерах порог привилегированных портов до нуля
(об этом в вопросах), так что контейнер запущен с
--sysctl net.ipv4.ip_unprivileged_port_start=1024, как на обычном сервере.
$ as_nobody /usr/local/bin/listen80
listen tcp :80: bind: permission denied
$ setcap cap_net_bind_service=+ep /usr/local/bin/listen80
$ getcap /usr/local/bin/listen80
/usr/local/bin/listen80 cap_net_bind_service=ep
$ as_nobody /usr/local/bin/listen80
listening on [::]:80 as uid 65534
CapEff: 0000000000000400
$ cp /usr/local/bin/listen80 /tmp/listen80; getcap /tmp/listen80
$ cat /opt/bin/listen80 > /usr/local/bin/listen80; getcap /usr/local/bin/listen80
$ as_nobody /usr/local/bin/listen80
listen tcp :80: bind: permission denied
p (permitted) кладёт capability в разрешённый набор, e (effective) сразу её
включает. CapEff записан битовой маской, и бит 10 (0x400) означает
cap_net_bind_service. Атрибут принадлежит файлу: cp без
--preserve=xattr его теряет, перезапись содержимого его стирает, и после выкладки бинаря
setcap повторяют. В образ атрибут ставят в стадии сборки, COPY --from в BuildKit
его не теряет (проверено на Docker 29.8).
Флаг no_new_privs (--security-opt no-new-privileges в Docker,
allowPrivilegeEscalation: false в Kubernetes) запрещает exec давать процессу
привилегии сверх прежних: setuid-бит перестаёт действовать, а capability из файла достаётся, только
если процесс и до вызова держал её в разрешённом наборе (permitted).
Page cache: где байты между write и диском
write в обычный файл диска не ждёт. Ядро копирует байты в page cache (страницы
оперативной памяти с содержимым файлов), помечает страницы грязными (dirty) и возвращает управление, а
на диск их позже отправляют фоновые потоки ядра. Почему из-за кэша мало свободной памяти, разобрано в
главе 1.1; здесь важнее, сколько живут грязные страницы.
$ dd if=/dev/urandom of=blob bs=1M count=300 status=none
$ grep -E '^(Dirty|Writeback):' /proc/meminfo
Dirty: 307468 kB
Writeback: 0 kB
$ grep -E '^(file|file_dirty|file_writeback) ' /sys/fs/cgroup/memory.stat
file 314580992
file_dirty 314572800
file_writeback 0
$ sync blob
$ grep -E '^(file|file_dirty|file_writeback) ' /sys/fs/cgroup/memory.stat
file 314580992
file_dirty 0
file_writeback 0
dd завершился, а 300 МиБ числятся в Dirty: пропади сейчас питание, данных не
будет, хотя каждый write вернул успех (Writeback считает страницы, которые
уже уходят на устройство). sync blob вызывает fsync и ждёт. В
memory.stat контейнера грязных страниц не осталось, а file не уменьшился: кэш
стал чистым и по-прежнему входит в память контейнера, это аукнется в последнем вопросе. Без
sync всё решают sysctl:
| sysctl | В ядре | Что задаёт |
|---|---|---|
vm.dirty_expire_centisecs | 3000 | страница, грязная дольше 30 с, уходит на диск при ближайшей фоновой записи |
vm.dirty_writeback_centisecs | 500 | период фоновой записи; в ВМ Docker Desktop 1500 |
vm.dirty_background_ratio | 10 | доля доступной памяти, после которой запись идёт в фоне |
vm.dirty_ratio | 20 | доля, на подходе к которой пишущий процесс засыпает в write, пока фоновые потоки пишут на диск |
В этой ВМ 100 МиБ без sync пролежали грязными не меньше 10 секунд, а на сервере с
десятками гигабайт памяти 10 % означают гигабайты «записанных» данных. Гарантию даёт
fsync(fd) (в Go f.Sync()): он возвращается, когда устройство сообщило о
записи данных и метаданных файла. close записи на диск не гарантирует.
После ошибки записи на устройство страницы могут оказаться помеченными как чистые, и повторный
fsync вернёт успех, хотя данных нет. PostgreSQL наткнулся на это в 2018 году и с тех пор на
ошибке fsync аварийно останавливается и восстанавливается из журнала. После ошибки
Sync запись считают несостоявшейся.
Атомарная запись файла из Go
$ strace -f -qq -e trace=openat,write,fchmod,fsync,renameat,close -e signal=none atomicw plain config.json v2 2>&1 | grep '^\[pid'
[pid 21] openat(AT_FDCWD, "config.json", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0644) = 4
[pid 21] write(4, "{\"version\": \"v2\", \"workers\": 8}\n", 32) = 32
[pid 21] close(4) = 0
$ strace -f -o /dev/null -e inject=write:signal=SIGKILL:when=1 atomicw plain config.json v4
Killed
$ ls -l config.json
-rw-r--r-- 1 root root 0 Sep 16 21:25 config.json
os.WriteFile открывает файл с O_TRUNC, пишет и закрывает (grep
убрал строки старта рантайма). До конца write на диске пустой или недописанный файл:
strace убил процесс на первом write, и от конфига осталось 0 байт. После close
данные ещё в page cache, и сбой питания может оставить файл пустым или недописанным. Файловые системы ext4
(эвристика auto_da_alloc), XFS и btrfs после такой обрезки начинают запись уже при
закрытии, но это лишь сужает окно.
Надёжный способ опирается на rename: по man 2 rename существующее имя
заменяется атомарно, и момента, когда его нет, не бывает.
func writeFileAtomic(path string, data []byte, perm os.FileMode) (err error) {
dir := filepath.Dir(path)
// Same directory, so rename stays within one file system.
f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
defer func() {
if err != nil {
f.Close()
os.Remove(f.Name())
}
}()
if _, err = f.Write(data); err != nil {
return err
}
if err = f.Chmod(perm); err != nil { // CreateTemp makes 0600
return err
}
if err = f.Sync(); err != nil { // data and inode on disk before the name
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(f.Name(), path); err != nil {
return err
}
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync() // persist the directory entry changed by rename
}
$ strace -f -qq -e trace=openat,write,fchmod,fsync,renameat,close -e signal=none atomicw atomic config.json v3 2>&1 | grep '^\[pid'
[pid 30] openat(AT_FDCWD, "./.config.json.tmp-2279600906", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0600) = 4
[pid 34] write(4, "{\"version\": \"v3\", \"workers\": 8}\n", 32) = 32
[pid 34] fchmod(4, 0644) = 0
[pid 34] fsync(4) = 0
[pid 34] close(4) = 0
[pid 34] renameat(AT_FDCWD, "./.config.json.tmp-2279600906", AT_FDCWD, "config.json") = 0
[pid 34] openat(AT_FDCWD, ".", O_RDONLY|O_CLOEXEC) = 4
[pid 34] fsync(4) = 0
[pid 34] close(4) = 0
$ atomicw atomic config.json v5
$ strace -f -o /dev/null -e inject=write:signal=SIGKILL:when=1 atomicw atomic config.json v6
Killed
$ strace -f -o /dev/null -e inject=renameat:signal=SIGKILL:when=1 atomicw atomic config.json v7
Killed
$ ls -la; cat config.json
total 16
drwxr-xr-x 2 root root 4096 Sep 16 21:25 .
drwxr-xr-x 1 root root 4096 Sep 16 21:25 ..
-rw-r--r-- 1 root root 32 Sep 16 21:25 .config.json.tmp-1711084816
-rw------- 1 root root 0 Sep 16 21:25 .config.json.tmp-3280935206
-rw-r--r-- 1 root root 32 Sep 16 21:25 config.json
{"version": "v5", "workers": 8}
Процесс убит на записи и на rename, а в config.json по-прежнему v5.
Такой мусор сервису стоит подчищать при старте. Смена номера в [pid] значит, что горутина
переехала на другой поток ОС. Зачем каждый вызов, разобрано во втором вопросе.
os.WriteFile файл пуст или неполон от первого
вызова до последнего. При записи через временный файл имя до rename ведёт к старому файлу
целиком, после него к новому.Монтирование, tmpfs и overlay
Дерево от / собрано из точек монтирования: к каталогу подключается корень другой файловой
системы, и прежнее содержимое каталога пропадает из виду. Вот дерево свежего контейнера с
--tmpfs /data:size=64m:
$ findmnt -o TARGET,FSTYPE
TARGET FSTYPE
/ overlay
|-/proc proc
| |-/proc/bus proc
| |-/proc/fs proc
| |-/proc/irq proc
| |-/proc/sys proc
| |-/proc/sysrq-trigger proc
| |-/proc/interrupts tmpfs
| |-/proc/kcore tmpfs
| |-/proc/keys tmpfs
| |-/proc/scsi tmpfs
| `-/proc/timer_list tmpfs
|-/dev tmpfs
| |-/dev/pts devpts
| |-/dev/mqueue mqueue
| `-/dev/shm tmpfs
|-/sys sysfs
| |-/sys/firmware tmpfs
| `-/sys/fs/cgroup cgroup2
|-/data tmpfs
|-/etc/hostname ext4
|-/etc/hosts ext4
`-/etc/resolv.conf ext4
$ findmnt -no OPTIONS / | tr , '\n' | cut -d= -f1
rw
relatime
lowerdir
upperdir
workdir
nouserxattr
Корень смонтирован как overlay, который накладывает слои образа из lowerdir (только
чтение) и слой записи контейнера из upperdir; подробно в статье «Docker», глава 2.1.
/etc/hosts и соседей Docker монтирует отдельными файлами с диска ВМ. Опции у точек свои:
ro запрещает запись, nosuid выключает setuid и capabilities файлов,
nodev не даёт открывать файлы устройств, noexec запускать программы.
Монтировать изнутри обычного контейнера нельзя: mount требует CAP_SYS_ADMIN.
$ findmnt -o TARGET,FSTYPE,OPTIONS /data
TARGET FSTYPE OPTIONS
/data tmpfs rw,nosuid,nodev,noexec,relatime,size=65536k
$ cp /opt/bin/listen80 /data/ && ls -l /data/listen80
-rwxr-xr-x 1 root root 3223186 Sep 16 21:25 /data/listen80
$ /data/listen80
bash: /data/listen80: Permission denied
tmpfs хранит файлы в page cache без диска под ним: size задаёт потолок, а не резерв,
но записанное выгрузить некуда, кроме swap, и в контейнере оно идёт в лимит памяти. А точка
монтирования прячет файлы, лежавшие в каталоге до неё: кэш записан в /srv/cache до
подключения тома, и df считает место, которого не находит du (опыт с
--cap-add SYS_ADMIN):
$ mkdir -p /srv/cache; dd if=/dev/zero of=/srv/cache/old.bin bs=1M count=300 status=none
$ mount -t tmpfs -o size=64m tmpfs /srv/cache
$ du -sh /srv/cache
0 /srv/cache
$ mkdir -p /mnt/root && mount --bind / /mnt/root
$ ls -l /mnt/root/srv/cache
total 307200
-rw-r--r-- 1 root root 314572800 Sep 16 21:25 old.bin
mount --bind подключает файловую систему ещё раз, без вложенных точек монтирования, и через
/mnt/root видно, что лежит под tmpfs.
/proc и /sys: ядро в виде файлов
В /proc и /sys нет ни байта на диске: содержимое ядро собирает в момент
чтения, поэтому ls показывает размер 0, а wc насчитывает килобайт. Вот каталог
живого Go-процесса, HTTP-сервера с логом в /tmp/srv.log:
$ ls -l /proc/9/status; wc -c /proc/9/status
-r--r--r-- 1 root root 0 Sep 16 21:25 /proc/9/status
1089 /proc/9/status
$ grep -E '^(State|Threads|VmRSS|VmHWM|SigCgt|CapEff|NoNewPrivs|Seccomp):' /proc/9/status
State: S (sleeping)
VmHWM: 9384 kB
VmRSS: 9384 kB
Threads: 5
SigCgt: fffffffd7fc1fefd
CapEff: 00000000a80425fb
NoNewPrivs: 0
Seccomp: 2
$ ls -l /proc/9/fd
total 0
lr-x------ 1 root root 64 Sep 16 21:25 0 -> /dev/null
lrwx------ 1 root root 64 Sep 16 21:25 1 -> /dev/pts/0
lrwx------ 1 root root 64 Sep 16 21:25 2 -> /dev/pts/0
lr-x------ 1 root root 64 Sep 16 21:25 3 -> /sys/fs/cgroup/cpu.max
l-wx------ 1 root root 64 Sep 16 21:25 4 -> /tmp/srv.log
lrwx------ 1 root root 64 Sep 16 21:25 5 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Sep 16 21:25 6 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Sep 16 21:25 7 -> 'socket:[1896907]'
$ cat /proc/9/fdinfo/4
pos: 39
flags: 02402001
mnt_id: 375
ino: 55252
VmRSS, Threads, limits и maps разобраны в главе 1.1.
VmHWM хранит пик резидентной памяти, SigCgt маску сигналов с обработчиками.
В CapEff a80425fb собраны 14 capabilities, которые Docker оставляет root, а
Seccomp: 2 означает seccomp-фильтр системных вызовов. В fd ядро подставляет
описание объекта: сокет с номером inode, eventpoll netpoller рантайма и eventfd,
которым рантайм его будит. Дескриптор 3 тоже открыл рантайм: с Go 1.25 он подстраивает
GOMAXPROCS под cpu.max и перечитывает файл не чаще раза в секунду.
fdinfo хранит смещение pos, флаги open восьмерично
(O_WRONLY = 1, O_APPEND = 02000, O_CLOEXEC = 02000000) и номер
inode; по pos видно, как далеко процесс прочитал большой файл.
$ tr '\0' '\n' < /proc/9/environ | head -1
DB_PASSWORD=s3cr3t
$ as_nobody cat /proc/9/environ
cat: /proc/9/environ: Permission denied
$ cat /sys/fs/cgroup/memory.max /sys/block/vda/queue/scheduler
268435456
none [mq-deadline] kyber
$ echo max > /sys/fs/cgroup/memory.max
bash: /sys/fs/cgroup/memory.max: Read-only file system
В environ окружение запуска лежит открытым текстом. Прочитает его тот же uid, в том числе
через kubectl exec, который входит под тем же пользователем, а чужой, даже root без
CAP_SYS_PTRACE, получит отказ. /sys
(sysfs) раскладывает объекты ядра по каталогам, по значению в файле. В контейнер он смонтирован только
на чтение: лимит своей cgroup видно, но не поменять, а в scheduler скобки отмечают
выбранный планировщик ввода-вывода диска.
Вопросы
4du его не видит, а блоки заняты, пока процесс не закроет дескриптор. Находят через
lsof +L1 или (deleted) в /proc/*/fd, возвращают обрезкой через
/proc/<pid>/fd/N. Реже виноваты файлы под точкой монтирования и резерв ext4.Удалённый, но открытый файл
du обходит каталоги, df спрашивает у файловой системы занятые блоки, и
разницу дают блоки, до которых не дойти по именам. Первым подозревают удалённый лог, который
сервис держит открытым: find /proc/[0-9]*/fd -lname '*(deleted)' найдёт его без
lsof, а : > /proc/<pid>/fd/N обрежет. Причину чинят отдельно:
переоткрытием лога после ротации или записью в stdout.
Если файл открыт без O_APPEND
С O_APPEND запись идёт в текущий конец, и после обрезки лог растёт с нуля. Без него
процесс пишет со своего смещения, и в начале файла остаётся дыра:
$ NOAPPEND=1 applog /data/log/app.log 100 &
[1] 38
pid 38 wrote 100 MiB to /data/log/app.log
$ rm /data/log/app.log
$ : > /proc/$(pgrep applog)/fd/4
$ ls -lsL /proc/$(pgrep applog)/fd/4
4 -rw-r--r-- 0 root root 104857704 Sep 16 22:07 /proc/38/fd/4
$ df -h /data
Filesystem Size Used Avail Use% Mounted on
tmpfs 512M 4.0K 512M 1% /data
Размер снова 100 МиБ, а занято 4 КиБ (первая колонка ls -s): файл стал разреженным.
То же бывает с copytruncate в logrotate, и по документации между копированием и
обрезкой ещё и теряется часть записей.
Что ещё не видит du
- Файлы под точкой монтирования. Записанное до монтирования тома не видно, но занимает
место; смотрят через
mount --bind / /mnt/root, где вложенных монтирований нет. - Резерв ext4. 5 % блоков отданы root (
Reserved block countвdumpe2fs -h, меняетсяtune2fs -m).Use%вdfсчитается как used / (used + avail) с округлением вверх (так ведёт себяdf9.7, хотя руководство coreutils пишет «used / size»), и 100 % значит, что места нет обычным пользователям, а root ещё пишет.
rm на логе живого процесса места не вернёт: исчезнет только имя. Очищают такой лог
обрезкой по имени, : > app.log, и место возвращается сразу.
f.Sync(), закрыть, сделать os.Rename поверх старого имени и Sync
каталога. rename заменяет запись в каталоге за один шаг, и читатель или перезапущенный
сервис видит либо старый файл целиком, либо новый.Почему не os.WriteFile
Он открывает файл с O_TRUNC, и процесс, убитый на первом write, оставил 0
байт. После close данные ещё в page cache, и сбой питания может обнулить файл.
Зачем каждый шаг
| Шаг | Что будет без него |
|---|---|
os.CreateTemp в каталоге цели | rename в другую файловую систему или даже в другую точку монтирования вернёт EXDEV |
f.Chmod(perm) | останутся права 0600, которые ставит CreateTemp |
f.Sync() до rename | после сбоя питания имя может вести к inode без данных |
Sync каталога | после сбоя питания может вернуться старая запись: fsync файла не гарантирует записи в каталоге |
Где рецепт перестаёт работать
$ atomicw rename /srv/app/config.json /data/config.json
rename /srv/app/config.json /data/config.json: invalid cross-device link
- Разные точки монтирования. Временный файл в
os.TempDir()и цель на томе контейнера не переименовать, аmvв таком случае молча копирует и удаляет, без атомарности. - Несколько писателей. От гонки
renameне спасает, из двух замен останется последняя.
Читатель, открывший файл до замены, дочитает старую версию: его дескриптор держит старый inode. Тем
же приёмом kubelet обновляет тома ConfigMap и Secret, только через rename переключается
symlink ..data на каталог с новой версией.
«Пишу во временный файл рядом, делаю fsync, rename поверх старого и fsync каталога. rename в пределах одной файловой системы атомарен, поэтому по имени всегда лежит целый файл, старый или новый».
-p 80:8080 в Docker. Если слушать нужно именно 80-й, хватает
CAP_NET_BIND_SERVICE на файле, если контейнер её не отбирает. В контейнерах Docker с 20.10
и containerd с 2.0 порог привилегированных портов и вовсе опущен до нуля.Порт снаружи
Процесс слушает 8080, а 80-й принимает Service с port: 80 и
targetPort: 8080, публикация порта в Docker или балансировщик. От ядра и политик
безопасности это не зависит.
Порог непривилегированных портов
Какие порты требуют capability, решает sysctl net.ipv4.ip_unprivileged_port_start (по
умолчанию 1024). Он принадлежит сетевому namespace, и Docker с 20.10 ставит контейнерам 0, а CRI в
containerd с 2.0 делает то же (enable_unprivileged_ports) для всех контейнеров, кроме
работающих в сети хоста.
$ docker run --rm --cap-drop ALL --entrypoint sh listen80:cap -c "cp /usr/local/bin/listen80 /tmp/l && /tmp/l"
listening on [::]:80 as uid 65534
CapEff: 0000000000000000
Копия бинаря без атрибута, uid 65534, ни одной capability, а порт 80 открыт. Это ловушка: «работает
без root» в контейнере ничего не говорит о голом сервере и о поде с hostNetwork: true,
где тот же бинарь упадёт на bind: permission denied.
Capability и где она ломается
Образ listen80:cap собран так: setcap в стадии сборки,
COPY --from в debian:13, USER 65534. Порог для опыта возвращён к
1024:
$ docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 --cap-drop ALL listen80:cap; echo "exit=$?"
exec /usr/local/bin/listen80: operation not permitted
exit=255
$ docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 --security-opt no-new-privileges --entrypoint sh listen80:cap -c /usr/local/bin/listen80
listen tcp :80: bind: permission denied
drop: [ALL]убирает capability из ограничивающего набора, и ядро отказывает в самомexec: бинарь с флагомe, не получивший всех своих capabilities, не запускается (man 7 capabilities). С--cap-add NET_BIND_SERVICEтот же образ стартует; профиль restricted в Pod Security Standards разрешает добавить только её.no_new_privsне мешает, когда бинарь сам служит entrypoint: runc (Docker 29.8) запускает его уже от uid 65534, но с capabilities контейнера в permitted, и capability из файла остаётся. Аshтеряет permitted при собственном запуске, иexecс этим флагом её уже не добавит. В профиле restricted флаг обязателен, в поде всё так же.
Когда кэш не успевает освободиться
На быстром диске ВМ это лотерея: dd на 1 ГиБ в контейнере с лимитом 256 МиБ получал
OOMKilled от силы в половине запусков из восьми, а в иных сериях ни разу. Если урезать
запись до 20 МБ/с (/dev/vda здесь диск ВМ), процесс убивают почти в каждом запуске;
bigwrite пишет 400 МиБ по 1 МиБ, последний аргумент задаёт Sync через каждые
N МиБ:
$ docker run -d --name dv-writer --memory 256m --memory-swap 256m --device-write-bps /dev/vda:20mb -v "$PWD/bin:/opt/bin:ro" debian:13 sleep infinity
a3959095027d91d46afd35a0483c4ebb1145faac89ac8241ac2589852127544c
$ docker exec dv-writer /opt/bin/bigwrite /srv/out.bin 400 0; echo "exit=$?"
exit=137
$ docker inspect -f '{{.State.Status}} OOMKilled={{.State.OOMKilled}}' dv-writer
running OOMKilled=true
В dmesg видно, чем была занята память cgroup:
[26570.304057] anon 2433024
[26570.304058] file 258211840
[26570.304061] file_dirty 0
[26570.304061] file_writeback 258211840
[26570.304108] Memory cgroup out of memory: Killed process 74323 (bigwrite) total-vm:1226976kB, anon-rss:2288kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:88kB oom_score_adj:0
Анонимной памяти 2,4 МБ, а весь лимит заняли страницы в writeback, и выбросить их нельзя, пока диск их не примет. Ждать ядро не станет: в cgroup v2 оно рассчитывает, что писателя притормозят лимиты грязных страниц, такие страницы пропускает и после нескольких пустых попыток зовёт OOM killer, хотя диску оставалось секунд двенадцать. Как он выбирает жертву, разобрано в главе 1.4.
Что делать
Сбрасывать данные по ходу записи, чтобы грязных страниц не набиралось больше, чем принимает диск:
с f.Sync() через каждые 32 МиБ тот же контейнер три раза из трёх записал 400 МиБ за 19
секунд, а без Sync в том же прогоне был убит пять раз из пяти. Помогает и запас
лимита, если сервис регулярно пишет большие файлы.
tmpfs и emptyDir в памяти
$ docker rm -f dv-writer
dv-writer
$ docker run -d --name dv-writer --memory 256m --memory-swap 256m --tmpfs /scratch:size=1g debian:13 sleep infinity
b7bce221912909a32407868e2f874d59d227b8a8b0af8c0de3ab6bb768cd5a29
$ docker exec dv-writer dd if=/dev/zero of=/scratch/tmp.bin bs=1M count=400 status=none; echo "exit=$?"
exit=137
$ docker exec dv-writer rm /scratch/tmp.bin; echo "exit=$?"
OCI runtime exec failed: exec failed: unable to start container process: error executing setns process: signal: killed (possibly OOM-killed)
exit=128
Страницы tmpfs вытеснять некуда: dd убит, записанная часть держит почти весь лимит
(docker stats показал 254.7MiB из 256MiB), и в контейнер не зайти даже удалить файл. В
Kubernetes файлы в emptyDir с medium: Memory так же считаются в лимит
памяти записавшего их контейнера.
pprof показывает память Go-процесса, а лимит cgroup считает ещё page cache и tmpfs. При
OOMKilled у пишущего сервиса смотрят file, file_writeback и
shmem в memory.stat.
1.3Сеть на хосте
Сетевую беду Go-сервиса часто видно с хоста раньше, чем в логах: сокеты копятся в странных
состояниях, очередь приёма упирается в предел, кончаются порты, пакет идёт не тем путём. Увидеть это
помогают ip, ss, tcpdump, curl и счётчики netfilter
и conntrack, а объясняют увиденное настройки ядра, от которых зависят показания.
- Почему сервис на
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.
1.4Диагностика и systemd
Сервис тормозит, а в top свободные ядра и спокойный load average. Тогда спрашивают ядро:
чего ждут задачи и кто их убил. Вторая половина главы про Go-сервис под systemd: старт, перезапуск, остановка
и изоляция.
- Почему load average не замечает троттлинг контейнера и что значат
someиfullв PSI. - Что читать в
vmstat,iostat -x,pidstatи почему диск бывает свободен, пока процессы ждут записи. - Как OOM killer выбирает жертву, чем OOM в cgroup отличается от глобального и где искать след.
- Во сколько раз
straceтормозит живой Go-сервис и что видятperfи eBPF. - Как написать unit-файл Go-сервиса:
Type=notify, перезапуск, лимиты, остановка, изоляция, timer вместо cron.
От симптома к подсистеме
Жалоба приходит на языке сервиса: вырос p99, таймауты, перезапуск с кодом 137. Первый вопрос к хосту: процесс работает медленно или стоит, и чего он ждёт. По каждому ресурсу смотрят занятость, очередь и ошибки (методика USE, глава 5.1). Задержку делает очередь, а привычные цифры её прячут: процент CPU усредняет, load average смешивает ожидание процессора и диска.
| Подозрение | С чего начать | Чем уточнить |
|---|---|---|
| Процесс убили | journalctl -k, memory.events | код 137, OOMKilled, Result= юнита |
| Ждут процессор | cpu.pressure, cpu.stat cgroup | pidstat -u, perf top |
| Не хватает памяти | memory.pressure, memory.events | pidstat -r, vmstat |
| Упёрлись в диск | io.pressure, vmstat | pidstat -d, iostat -x |
| Висит в ядре | strace -f -p на секунды | eBPF, стеки горутин по SIGQUIT |
Сеть хоста разобрана в главе 1.3. Прогоны сняты в Docker Desktop 29.8 на общей ВМ (ядро 7.0.12-linuxkit,
4 CPU, 8 ГБ), утилиты собраны в образ diag на Debian 13: strace 6.13, sysstat 12.7.5, stress-ng
0.19.02, perf 6.12, bpftrace 0.23.2, libbpf-tools, systemd 257.
PSI: сколько времени задачи простояли
PSI (pressure stall information) есть в ядре с 4.20. По каждому ресурсу ядро считает долю времени, когда
процессор, освобождение памяти или ввод-вывод ждала хотя бы одна задача (some) и когда ждали все,
кому было что делать (full): avg10, avg60, avg300 в
процентах, total в микросекундах. /proc/pressure/* описывают всю машину, даже в
контейнере, а cpu.pressure, memory.pressure и io.pressure есть в каждой
cgroup v2.
Четыре процесса жгут CPU в контейнере с --cpus=1: просят 400 мс процессора на каждые 100 мс, а получают 100.
$ docker run --rm diag cat /proc/loadavg; docker run -d --name cpu-hog --cpus=1 --memory=128m diag stress-ng --cpu 4 --timeout 65s >/dev/null
1.35 2.05 2.97 4/622 6
$ sleep 60; docker exec cpu-hog sh -c 'cat /proc/loadavg /proc/pressure/cpu /sys/fs/cgroup/cpu.pressure; grep throttled /sys/fs/cgroup/cpu.stat'
1.21 1.84 2.83 7/646 15
some avg10=75.59 avg60=49.23 avg300=25.97 total=1112746765
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
some avg10=73.92 avg60=46.78 avg300=13.78 total=45094293
full avg10=73.60 avg60=46.67 avg300=13.76 total=44996229
nr_throttled 603
throttled_usec 178866621
По PSI cgroup задачи стоят три четверти времени, а load average за минуту не вырос (1.35, потом 1.21): задачи
cgroup, выбравшей квоту, до следующего периода сняты с очереди выполнения. В среднем такая группа добавляет к
нему около одного ядра, сколько даёт квота, и вклад этот тонет в шуме соседей: при повторе опыта load average
вырос с 0.96 до 2.91 при тех же 74 % PSI. full почти равен some: пока квота
исчерпана, не работает никто из группы. В /proc/pressure/cpu строка full всегда
нулевая: для машины целиком ядро её не определяет.
Ещё два прогона: те же процессы на одном ядре (--cpuset-cpus=0) и четыре писателя с O_DIRECT при --device-write-bps /dev/vda:4mb.
| Нагрузка | load average: до / через 60 с | cgroup: cpu some / full | cgroup: io some / full |
|---|---|---|---|
4 процесса, --cpus=1 | 1.35 / 1.21 | 74 % / 74 % | 0 / 0 |
| 4 процесса на одном ядре | 3.00 / 3.67 | 99 % / 0 | 0 / 0 |
| 4 писателя, диск 4 МБ/с | 0.96 / 3.08 | 0 / 0 | 89 % / 89 % |
На одном ядре задачи ждут 99 % времени при нулевом full: одна из четырёх всегда работает. У
писателей load average вырос на два без всякого процессора, четыре задачи в D. Load average валит
оба случая в одно число на всю машину, а PSI разводит их по ресурсам и cgroup.
В /proc/pressure/memory можно записать порог (some 150000 1000000) и ждать его в
poll(); на PSI построен systemd-oomd. kubelet отдаёт PSI контейнеров в метриках cAdvisor, фича
KubeletPSI стабильна с Kubernetes 1.36.
vmstat, iostat, pidstat на одной нагрузке
Нагрузка на все три одна: cpu-hog с квотой в ядро, io-hog с четырьмя писателями и
общим лимитом записи 4 МБ/с и memhog, Go-программа, которая каждые полсекунды выделяет и трогает
16 МиБ.
# vmstat 1 4
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
r b swpd free buff cache si so bi bo in cs us sy id wa st gu
1 4 91212 1073236 653528 4690464 266 246 608 6425 3704 19 5 2 91 1 0 0
6 4 91212 1049036 653532 4690796 0 0 0 4336 4565 6513 27 2 7 64 0 0
0 4 91212 1035176 653532 4690796 0 0 32768 4096 3909 4642 26 0 2 71 0 0
…
Первая строка усреднена с загрузки, кроме колонок процессов и памяти. В b четыре писателя, ждущие
ввода-вывода, а r прыгает от 0 до 6: снимок попадает то на исчерпанную квоту
cpu-hog, то на начало периода. В wa ушло 64–71 % процессора, bo
держится около 4096 КиБ/с, на лимите записи.
# iostat -dx vda 1 2 | awk '/^Device|^vda/ {print $1, $8, $9, $12, $22, $23}'
Device w/s wkB/s w_await aqu-sz %util
vda 70.87 6425.54 1.18 0.09 0.60
Device w/s wkB/s w_await aqu-sz %util
vda 64.00 4096.00 0.09 0.02 1.40
Первый отчёт тоже с загрузки. Во втором диск отдыхает: запись за 0.09 мс, очередь 0.02, занят 1.4 % времени. А
четыре процесса стоят в D: лимит --device-write-bps держит запросы в блочном слое, а
iostat видит только дошедшее до диска. У SSD и RAID с параллельной обработкой запросов и 100 %
%util не значит «упёрлись» (man iostat).
# pidstat -u -r -d -C "stress-ng|memhog" 1 1 | grep -v Average
…
23:07:58 UID PID %usr %system %guest %wait %CPU CPU Command
23:07:59 0 85108 25.74 0.00 0.00 73.27 25.74 0 stress-ng-cpu
23:07:59 0 85109 24.75 0.00 0.00 74.26 24.75 3 stress-ng-cpu
…
23:07:58 UID PID minflt/s majflt/s VSZ RSS %MEM Command
23:07:59 0 85193 8118.81 0.00 1620768 447252 5.50 memhog
23:07:58 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
23:07:59 0 85157 0.00 950.50 0.00 101 stress-ng-hdd
…
%wait 73–74 % против 25 % работы показывает троттлинг у отдельного процесса.
minflt/s у memhog 8119, по ошибке на каждые 4 КиБ; в других прогонах было около 26,
когда ядро выдавало прозрачные huge pages по 2 МиБ (режим always). iodelay считают в
тиках, по 100 в секунду: около 100 за секунду значит, что процесс только и ждал диск.
Колонку заполняет учёт задержек ядра, а он по умолчанию выключен; включает его
sysctl -w kernel.task_delayacct=1 или параметр загрузки delayacct. Структуру учёта
ядро заводит задаче при fork, и запущенные раньше процессы так и показывают 0: в прогоне
писатели, стартовавшие до sysctl, стояли в D с нулевым iodelay.
OOM killer: кого убивают и где след
Когда страницу выделить не из чего, ядро убивает процесс SIGKILL (про overcommit в главе 1.1).
OOM в cgroup наступает, когда группа упёрлась в memory.max, и кандидаты только её
процессы; глобальный OOM, когда кончилась память машины. Жертву выбирает oom_badness из
mm/oom_kill.c (ядро 7.0): очки = rss + swap + таблицы страниц + oom_score_adj × лимит / 1000, где
лимит это memory.max с разрешённым группе swap или RAM + swap машины. Поправку
oom_score_adj (от −1000, «не убивать», до 1000) ставят --oom-score-adj в Docker,
OOMScoreAdjust= в systemd, kubelet по QoS пода (−997 у Guaranteed, 1000 у BestEffort).
$ docker run --name oom-demo --memory=128m --memory-swap=128m -v $PWD/bin:/opt/bin:ro diag /opt/bin/memhog | tail -2
allocated 96 MiB
allocated 112 MiB
$ docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' oom-demo
137 true
# dmesg | grep -E 'invoked oom-killer|memory: usage|oom-kill:|Killed process' | tail -4
[31018.366033] memhog invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[31018.366075] memory: usage 131072kB, limit 131072kB, failcnt 51
[31018.366115] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=475d496a8cce…,mems_allowed=0,oom_memcg=/docker/475d496a8cce…,task_memcg=/docker/475d496a8cce…,task=memhog,pid=50987,uid=0
[31018.366132] Memory cgroup out of memory: Killed process 50987 (memhog) total-vm:1358848kB, anon-rss:130144kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:336kB oom_score_adj:0
--memory-swap равен --memory, иначе Docker добавит ещё столько же swap.
dmesg снят из привилегированного контейнера (id cgroup сокращён), на сервере есть
journalctl -k. У глобального OOM вместо CONSTRAINT_MEMCG и
memory: usage будут CONSTRAINT_NONE и Out of memory: Killed process; на
общей ВМ его не вызывали, чтобы не убить чужие процессы.
Выбор жертвы: в контейнере на 128 МиБ «кэш» на 32 МиБ с oom_score_adj 800 и растущий «api».
$ docker run --name oom-two --memory=128m --memory-swap=128m -v $PWD/bin:/opt/bin:ro diag sh -c '
MAX_MB=32 sh -c "echo 800 > /proc/self/oom_score_adj; exec /opt/bin/memhog" > /dev/null &
CACHE=$!; sleep 1
STEP_MS=1000 /opt/bin/memhog > /dev/null &
API=$!; sleep 3
for p in $CACHE $API; do echo "pid $p $(grep VmRSS /proc/$p/status) oom_score=$(cat /proc/$p/oom_score)"; done
wait $CACHE; echo "cache exited: $?"
wait $API; echo "api exited: $?"
grep -E "^(max|oom|oom_kill|oom_group_kill) " /sys/fs/cgroup/memory.events
cat /sys/fs/cgroup/memory.oom.group
'
pid 7 VmRSS: 37504 kB oom_score=1202
pid 13 VmRSS: 53220 kB oom_score=670
Killed
cache exited: 137
Killed
api exited: 137
max 112
oom 2
oom_kill 2
oom_group_kill 0
0
$ docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' oom-two
0 true
Первым умер «кэш», хотя он меньше: к 37 МиБ ядро добавило 800 × 128 / 1000 ≈ 102 МиБ, у «api» было около 90.
oom_score из /proc этого не предсказывает: ядро считает его от памяти всей машины,
(1000 + очки × 1000 / (RAM + swap)) × 2 / 3, отсюда 1202 и 670 при шкале 0–2000.
Контейнер вышел с кодом 0, его PID 1 выжил, а OOMKilled всё равно true. В
memory.events max считает упоры в лимит, oom неудачные попытки
освободить память, oom_kill убитых. С memory.oom.group=1 группа гибнет целиком:
Docker оставляет 0, kubelet на cgroup v2 с Kubernetes 1.28 ставит 1 (поштучное убийство возвращает
singleProcessOOMKill, с 1.32).
memory.high процесс тормозят, у memory.max ядро пытается освободить память и, если не
вышло, выбирает жертву среди процессов группы по очкам. Эти шаги оставляют след в
memory.events.
Шаг 2 без убийства проверен в systemd, где порог задаёт MemoryHigh= (в Docker флага нет): с
MemoryHigh=64M и без swap тот же memhog за 20 секунд дошёл до 64 МиБ и застрял,
memory.pressure группы показал 63 % при oom_kill 0. Сервис жив, но почти стоит.
strace на живом процессе и его цена
strace через ptrace останавливает поток на входе в каждый системный вызов и на
выходе, пока не напечатает строку. С -p он цепляется к работающему процессу, и без
-f виден только поток с TID, равным PID, а у Go он почти всё время спит в futex.
$ docker run -d --name web --cpus=2 --memory=256m --cap-add SYS_PTRACE -v $PWD/bin:/opt/bin:ro diag /opt/bin/websvc
$ docker exec -it web bash
# grep Threads /proc/$(pgrep websvc)/status
Threads: 7
# strace -f -tt -e trace=%network,%file -p $(pgrep websvc)
strace: Process 1 attached with 7 threads
[pid 12] 22:40:33.398141 accept4(4, {sa_family=AF_INET6, sin6_port=htons(59702), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_scope_id=0}, [112 => 28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
…
[pid 11] 22:40:33.398708 openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 8
websvc здесь маленький HTTP-сервис, /file читает /etc/hostname.
Соединение принял поток 12, файл открыл поток 11: семь потоков при GOMAXPROCS=2 обычны, и
горутина переезжает между ними (модель G, M и P в главе 1.1). Классы вызовов пишут с процентом, форму без него
man strace 6.13 называет устаревшей.
Цена: восемь клиентов по 10 секунд из соседнего контейнера с одним ядром, у сервиса два. /file
делает около десятка системных вызовов на запрос, /hash?n=20000 считает SHA-256 и в ядро почти
не ходит.
| Как снимали | /file, запросов/с | /file, p99 | /hash, запросов/с | /hash, p99 |
|---|---|---|---|---|
| без strace | 67 037 | 1.49 мс | 1 182 | 23.5 мс |
strace -f -p | 3 537 | 35.2 мс | 676 | 87.2 мс |
strace -f -e trace=%network,%file -p | 2 999 | 40.7 мс | 488 | 88.9 мс |
strace -f --seccomp-bpf -e …, сервис запущен под strace | 24 622 | 2.07 мс | — | — |
Обработчик, который ходит в ядро, потерял 95 % пропускной способности; в четырёх прогонах он замедлялся в
19–37 раз. Фильтр -e не помогает: ядро всё равно останавливает поток на каждом вызове.
--seccomp-bpf фильтрует в ядре и обходится дешевле, но к процессу, подключённому через
-p, не применяется (man strace), и сервис придётся запускать под strace.
perf и eBPF: смотреть, не останавливая
perf берёт выборки: ядро по таймеру или счётчику процессора тысячи раз в секунду прерывает поток и
записывает, где он был, а с -g и стек. Go на amd64 и arm64 сохраняет указатели кадров (frame
pointers), поэтому стеки собираются без отладочной информации. В контейнере хватило --cap-add PERFMON.
# perf top -p $(pgrep websvc) --stdio -d 6 -E 5
…
23.97% websvc [.] crypto/internal/fips140/sha256.blockSHA2.abi0
…
# perf record -g -o perf.data -p $(pgrep websvc) -- sleep 5
…
# perf report -i perf.data --stdio --no-children | grep -v "^#" | grep -v "^$" | head -10
24.51% websvc websvc [.] crypto/internal/fips140/sha256.blockSHA2.abi0
|
---crypto/internal/fips140/sha256.blockSHA2.abi0
…
main.main.func1
net/http.HandlerFunc.ServeHTTP
Аппаратных счётчиков в ВМ нет (perf stat -e cycles: <not supported>), и
perf перешёл на task-clock. Имена функций он берёт из таблицы символов ELF: у бинаря
с -ldflags='-s -w' вместо них адреса вроде 0x00000000001ec364. Горутин
perf не знает, профили Go в главе 3.3.
С eBPF в ядро загружают маленькую программу: она цепляется к точке трассировки или функции ядра и копит
данные прямо там. bcc на Python компилирует её на лету из заголовков ядра, а в ВМ их нет, и
tcpretrans-bpfcc упал. libbpf-tools и bpftrace берут структуры ядра из BTF и заработали; им нужны
привилегии и смонтированная tracefs.
$ docker run -d --name web --health-cmd 'curl -fsS localhost:8080/file' --health-interval 2s -v $PWD/bin:/opt/bin:ro diag /opt/bin/websvc
$ docker run -it --rm --privileged --pid=host --cgroupns=host diag bash
# mount -t tracefs tracefs /sys/kernel/tracing
# execsnoop-libbpf -T -c /sys/fs/cgroup/docker/<id контейнера web>
TIME PCOMM PID PPID RET ARGS
22:43:24 6 74938 74929 0 /proc/self/fd/6 init
22:43:24 sh 74942 74929 0 /bin/sh -c curl -fsS localhost:8080/file
22:43:24 curl 74949 74942 0 /usr/bin/curl -fsS localhost:8080/file
22:43:26 6 74984 74975 0 /proc/self/fd/6 init
…
Проверка здоровья каждые две секунды запускает в контейнере runc init, sh и
curl; живут они миллисекунды, и в top их не увидеть. А то, что ядро делает без
системного вызова, не увидит и strace. Клиенту в соседнем контейнере добавили потерю пакетов
(tc qdisc add dev eth0 root netem loss 30%) и запустили рядом tcpretrans.bt:
# bpftrace /usr/sbin/tcpretrans.bt
…
TIME PID LADDR:LPORT RADDR:RPORT STATE
…
22:44:26 87777 172.17.0.8:43106 172.17.0.3:8080 SYN_SENT
…
Повтор шлёт таймер TCP в ядре, поэтому в колонке PID тот, кто оказался на процессоре (на свободной машине 0),
а strace клиента показал бы разве что долгий connect. Цена на нагрузке
/file на общей ВМ: opensnoop-libbpf -p в шести прогонах отнял 11–25 % пропускной
способности, однострочник bpftrace со счётчиком открытий 4–33 %, а strace -f -p 95–97 %.
Unit-файл для Go-сервиса
Для опытов systemd поднят PID 1 в привилегированном контейнере; в продакшене так не делают, а для песочницы сойдёт:
$ docker run -d --name sd --privileged --cgroupns=private --tmpfs /run --tmpfs /run/lock -v $PWD/bin:/opt/bin:ro diag /sbin/init
$ docker exec sd systemctl is-system-running
running
# /etc/systemd/system/api.service (websvc установлен как /usr/local/bin/api)
[Unit]
Description=Demo Go API
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStart=/usr/local/bin/api
EnvironmentFile=/etc/api/api.env
DynamicUser=yes
Restart=on-failure
RestartSec=2s
LimitNOFILE=65536
TimeoutStopSec=30s
[Install]
WantedBy=multi-user.target
# /etc/api/api.env; START_DELAY изображает прогрев до открытия порта
ADDR=:8080
START_DELAY=3s
SHUTDOWN_TIMEOUT=20s
# systemctl status api --no-pager
● api.service - Demo Go API
…
Active: active (running) since Wed 2026-09-16 22:44:40 UTC; 51ms ago
…
Sep 16 22:44:37 e128f08c3686 systemd[1]: Starting api.service - Demo Go API...
Sep 16 22:44:40 e128f08c3686 api[173]: 2026/09/16 22:44:40 listening on :8080, pid 173
Sep 16 22:44:40 e128f08c3686 systemd[1]: Started api.service - Demo Go API.
Между «Starting» и «Started» три секунды прогрева. Юнит включают
systemctl daemon-reload && systemctl enable --now api; без daemon-reload
после правки файла systemd останется на старой версии и предупредит «changed on disk». Свойства даёт
systemctl show -p MainPID,NRestarts api, хвост журнала
journalctl -u api -f --since "10 min ago".
Type= задаёт момент, когда сервис считается запущенным: тогда вернётся systemctl start
и стартуют юниты с After=api.service. С simple это сразу после fork(), с
exec после успешного execve() (man systemd.service советует его вместо
simple), с notify когда процесс прислал READY=1.
# grep Type= /etc/systemd/system/api.service; time systemctl start api; curl -sS localhost:8080/file
Type=simple
real 0m0.006s
…
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Could not connect to server
# grep Type= /etc/systemd/system/api.service; time systemctl start api; curl -sS localhost:8080/file
Type=notify
real 0m3.012s
…
e128f08c3686
С опечаткой в ExecStart= systemctl start для simple вернул 0, а для exec 1 и «Job for api.service failed». Для
notify systemd кладёт в окружение NOTIFY_SOCKET (в прогоне
/run/systemd/notify), и о готовности сервис сообщает датаграммой READY=1 в этот
unix-сокет; адрес на @ означает абстрактный сокет. Хватит десятка строк, хотя есть и
daemon.SdNotify из github.com/coreos/go-systemd/v22:
func sdNotify(state string) error {
addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" {
return nil // запущен не под systemd
}
conn, err := net.Dial("unixgram", addr) // "@имя" Go сам считает абстрактным сокетом
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Write([]byte(state))
return err
}
// в main: net.Listen, потом sdNotify("READY=1"); по SIGTERM sdNotify("STOPPING=1")
DynamicUser=yes запускает сервис под временным UID из диапазона 61184–65519 (в прогоне
62104) и включает ProtectSystem=strict, ProtectHome=read-only,
PrivateTmp, NoNewPrivileges: писать можно только в выданные каталоги вроде
StateDirectory=. UID потом может достаться другому сервису, так что файлам с постоянным
владельцем нужен User=. EnvironmentFile= перечитывается при каждом старте.
LimitNOFILE=. Мягкий лимит дескрипторов у юнита по умолчанию 1024, но Go-процессу это почти не мешает. Юнит без LimitNOFILE:
# systemctl show -p LimitNOFILESoft,LimitNOFILE api; grep "open files" /proc/$(systemctl show -p MainPID --value api)/limits
LimitNOFILE=1048576
LimitNOFILESoft=1024
Max open files 1048575 1048576 files
# systemd-run --wait -q -P -p DynamicUser=yes sh -c "ulimit -Sn; ulimit -Hn"
1024
1048576
Go-процесс сам поднял мягкий лимит до жёсткого без единицы (глава 1.1), sh остался на 1024, и детям
из os/exec рантайм тоже возвращает исходный лимит. Жёсткий 1048576 унаследован от Docker, на обычной
машине systemd 257 даёт 1024:524288.
Restart=on-failure перезапускает при ненулевом коде, смерти от сигнала (кроме
SIGHUP, SIGINT, SIGTERM, SIGPIPE: для systemd это чистый
выход), таймауте и пропущенном watchdog; systemctl stop перезапуска не вызывает. Сервис падает на
старте, RestartSec=1s:
# journalctl -u api --since 22:45:04 --no-pager
…
Sep 16 22:45:04 e128f08c3686 api[627]: 2026/09/16 22:45:04 config: DB_DSN is empty
Sep 16 22:45:04 e128f08c3686 systemd[1]: api.service: Main process exited, code=exited, status=1/FAILURE
…
Sep 16 22:45:05 e128f08c3686 systemd[1]: api.service: Scheduled restart job, restart counter is at 1.
…
Sep 16 22:45:10 e128f08c3686 systemd[1]: api.service: Start request repeated too quickly.
…
Sep 16 22:45:10 e128f08c3686 systemd[1]: Failed to start api.service - Demo Go API.
После пятого перезапуска за 10 секунд юнит упёрся в StartLimitBurst=5 и
StartLimitIntervalSec=10s (они в секции [Unit]) и остался в failed до
ручного запуска или systemctl reset-failed. Растущую паузу, как у CrashLoopBackOff, дают
RestartSteps= и RestartMaxDelaySec= (systemd 254+).
Остановка: KillSignal и TimeoutStopSec
systemctl stop выполняет ExecStop=, шлёт KillSignal (SIGTERM),
ждёт TimeoutStopSec (по умолчанию 90 секунд) и добивает SIGKILL.
KillMode=control-group шлёт сигналы всем процессам cgroup юнита, mixed отдаёт
SIGTERM только главному. Сервис доигрывает запросы STOP_DELAY секунд (код — в статье «Веб-сервисы» раздела Go, глава 10.2),
в юните TimeoutStopSec=5s: с 1 секундой вышло Result=success, с 30 так:
# grep STOP_DELAY /etc/systemd/system/api.service; time systemctl stop api; journalctl -u api --since 23:09:32 --no-pager; systemctl show -p ActiveState,Result api
Environment=STOP_DELAY=30s
real 0m5.076s
…
Sep 16 23:09:32 3f1360d68971 systemd[1]: Stopping api.service - Demo Go API...
Sep 16 23:09:32 3f1360d68971 api[316]: 2026/09/16 23:09:32 got signal, shutting down
Sep 16 23:09:38 3f1360d68971 systemd[1]: api.service: State 'stop-sigterm' timed out. Killing.
Sep 16 23:09:38 3f1360d68971 systemd[1]: api.service: Killing process 316 (api) with signal SIGKILL.
…
Sep 16 23:09:38 3f1360d68971 systemd[1]: Stopped api.service - Demo Go API.
Result=timeout
ActiveState=failed
SIGTERM у сервиса есть TimeoutStopSec.
Уложился, и юнит остановлен чисто; не уложился, и systemd добивает процессы группы SIGKILL, а юнит
уходит в failed с результатом timeout.
Бюджет остановки в коде (context.WithTimeout вокруг srv.Shutdown) делают меньше
TimeoutStopSec с запасом на закрытие пула БД и сброс телеметрии. Сервис с Type=notify,
которому нужно дольше, продлевает срок сообщением EXTEND_TIMEOUT_USEC=….
Изоляция: что закрыть сервису
Сетевому сервису не нужны запись в /usr, чужие домашние каталоги и модули ядра. systemd закрывает
это опциями [Service] на тех же namespaces, capabilities и seccomp, что и у контейнеров, а
systemd-analyze security по списку опций оценивает, насколько юнит открыт, от 0 до 10.
# systemd-analyze security plain.service --no-pager | tail -1
→ Overall exposure level for plain.service: 9.6 UNSAFE :-{
# systemd-analyze security api.service --no-pager | awk '/^✗/ {print $1, $2, $NF} /Overall/'
…
✗ RestrictAddressFamilies=~AF_UNIX 0.1
✗ RestrictAddressFamilies=~AF_(INET|INET6) 0.3
✗ PrivateNetwork= 0.5
…
✗ IPAddressDeny= 0.2
→ Overall exposure level for api.service: 1.2 OK :-)
В plain.service один ExecStart=, а в api.service к юниту выше добавлены
опции из таблицы ниже и ещё почти два десятка: PrivateDevices, ProtectKernel*,
RestrictAddressFamilies, RestrictNamespaces, MemoryDenyWriteExecute,
SystemCallFilter=@system-service и другие. Сервис с ними работал, а оставшиеся 1.2 набежали в
основном за сеть. Вызов вне SystemCallFilter= по умолчанию убивает процесс сигналом
SIGSYS. Низкая оценка не значит, что уязвимостей нет, только что последствия будут меньше.
| Опция | Что делает | Что видно изнутри в прогоне |
|---|---|---|
NoNewPrivileges=yes | нельзя получить права через setuid и file capabilities | NoNewPrivs: 1 |
ProtectSystem=strict | вся ФС только для чтения, кроме API-ФС и выданных каталогов; full закрывает /usr, /boot, /etc | touch /etc/x: Read-only file system |
ProtectHome=yes | /home, /root, /run/user недоступны; read-only оставляет чтение | ls /home: Permission denied |
PrivateTmp=yes | свои /tmp и /var/tmp, очищаются после остановки | файлов хоста в /tmp нет |
CapabilityBoundingSet= | пустой набор capabilities, даже у root | CapBnd: 0000000000000000 |
Одного DynamicUser для домашних каталогов мало, он включает только ProtectHome=read-only:
в прогоне сервис с ним прочитал чужой /home/alice/.pgpass с правами 644, а с
ProtectHome=yes получил Permission denied на /home.
Timer вместо cron
Периодическая задача под systemd состоит из двух юнитов: .service с Type=oneshot делает
работу, .timer с тем же именем решает, когда её запускать. В Kubernetes эту роль играет CronJob (глава 3.3).
# /etc/systemd/system/report.service
[Service]
Type=oneshot
ExecStart=/bin/echo report generated
DynamicUser=yes
# /etc/systemd/system/report.timer
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.target
# systemctl enable --now report.timer && systemctl list-timers report.timer --no-pager
…
NEXT LEFT LAST PASSED UNIT ACTIVATES
Thu 2026-09-17 03:01:43 UTC 4h 15min - - report.timer report.service
…
# systemctl show -p AccuracyUSec,RandomizedDelayUSec,Persistent report.timer
AccuracyUSec=1min
…
Выражение времени заранее проверяет systemd-analyze calendar "Mon..Fri 09:00", а
list-timers показывает запуск уже со случайной задержкой (03:01:43). AccuracySec по
умолчанию минута: в её пределах systemd сдвигает срабатывания, чтобы реже будить машину.
Persistent=true хранит время запуска в /var/lib/systemd/timers/: из двух таймеров
OnCalendar=minutely, остановленных на 75 секунд, таймер с этой опцией после включения сразу
запустил задачу, а без неё ждал следующей минуты.
Задача на 25 секунд под таймером раз в 10 секунд и задача на 90 секунд в /etc/cron.d раз в минуту:
таймер второй копии не запустил, срабатывания склеились в запуск сразу после окончания (22:46:00, 22:46:25,
22:46:50), а cron начал новую копию в 22:47:01, хотя первая закончилась только в 22:47:31.
| cron | systemd timer | |
|---|---|---|
| Вывод задачи | почта или перенаправление в файл | journald, journalctl -u report |
| Машина была выключена | запуск пропущен | догоняет с Persistent=true |
| Задача не успела к следующему запуску | вторая копия поверх первой | вторая копия не стартует |
| Лимиты и изоляция | нет | всё из [Service] |
Вопросы
4cpu.max, сняты с очереди выполнения, и load average видит в среднем лишь использованную квоту. Ожидание видно в
cpu.pressure, nr_throttled и %wait у pidstat.Почему узел спокоен
Лимит CPU превращается в квоту cpu.max на период в 100 мс, и выбравшие её потоки стоят до
следующего периода при свободных ядрах узла. В прогоне четыре счётных процесса при --cpus=1
оставили load average узла на 1.35 и 1.21, а cpu.pressure контейнера показал 74 %.
Что показать
$ cat /sys/fs/cgroup/cpu.pressure # внутри контейнера: some и full почти равны
$ grep throttled /sys/fs/cgroup/cpu.stat # nr_throttled растёт между замерами
$ pidstat -u -C websvc 1 5 # колонка %wait
В Kubernetes то же дают метрики cAdvisor container_cpu_cfs_throttled_periods_total и
container_pressure_cpu_waiting_seconds_total. Следом проверь GOMAXPROCS (статья
«Docker», глава 2.1): до Go 1.25 он равнялся числу ядер узла, и потоки выбирали квоту в начале периода.
/proc/pressure/cpu и /proc/loadavg в контейнере показывают всю машину: растут
от соседей и молчат, пока контейнер задыхается в своей квоте. Доказывает только файл cgroup.
journalctl -k и счётчик
oom_kill в memory.events. CONSTRAINT_MEMCG означает лимит cgroup,
CONSTRAINT_NONE память всей машины. Жертву выбирают по памяти процесса плюс
oom_score_adj, пересчитанному в долю этого лимита.Где след
$ journalctl -k | grep -E 'invoked oom-killer|oom-kill:|Killed process'
$ cat /sys/fs/cgroup/<группа>/memory.events # oom, oom_kill
$ systemctl show -p Result api # Result=oom-kill
SIGKILL не перехватить, поэтому приложение молчит, а код 137 значит только «убит
SIGKILL»: его же дают kill -9 и добивание после таймаута остановки.
Какой лимит и почему он
Memory cgroup out of memoryиoom_memcg=: лимит группы, на машине память могла быть.Out of memory: Killed process: кончилась память машины, жертвой мог стать сосед.- «
X invoked oom-killer» называет того, чья аллокация не прошла; убит может быть другой. - Очки: rss + swap + таблицы страниц +
oom_score_adj × лимит / 1000; в прогоне процесс на 37 МиБ с поправкой 800 умер раньше соседа на 90 МиБ.
/proc/<pid>/oom_score считается от памяти всей машины, а выбор внутри cgroup идёт от её лимита.
И при memory.oom.group=0 убивают один процесс: контейнер с выжившим PID 1 выходит с кодом 0 и
OOMKilled=true.
ptrace останавливает поток на каждом
системном вызове, фильтр -e при -p этого не отменяет, и сервис с частыми вызовами в прогоне
потерял 95 % пропускной способности. Заменяют его perf, eBPF и pprof.Откуда цена
Ядро дважды на каждый вызов переключается в strace, поэтому цена растёт с частотой вызовов, а
не с работой процессора: /file упал с 67 до 3,5 тыс. запросов в секунду, p99 с 1,5 до 35 мс.
--seccomp-bpf переносит фильтр в ядро, но только для процесса, запущенного под
strace -f.
Если всё же strace
# timeout -s INT 5 strace -f -tt -T -e trace=%network,%file -o /tmp/trace.txt -p $(pgrep websvc)
-f обязателен, -T добавит время внутри вызова. Держи несколько секунд и пиши в
файл: даже эти секунды заметят клиенты, а проверка здоровья может снять реплику, так что её по возможности
выведи из балансировки.
Чем заменить
- Где тратится CPU:
perf top,perf record -g. - Какие файлы открываются и процессы запускаются:
opensnoop,execsnoopиз libbpf-tools. - Повторы TCP и другие события без системного вызова:
tcpretrans.bt, однострочники bpftrace. - Где стоят горутины: pprof goroutine (глава 3.3) или
kill -QUIT, но после дампа процесс завершится.
Type=notify и READY=1 после net.Listen;
обработка SIGTERM с бюджетом меньше TimeoutStopSec; Restart=on-failure с
паузой. Проверять по журналу: нет ли State 'stop-sigterm' timed out.Старт
С Type=simple в прогоне systemctl start вернулся за 6 мс, сразу после
fork(), а порт открылся через 3 секунды, и curl получил отказ. С
Type=notify start ждёт READY=1 в $NOTIFY_SOCKET, и юниты с
After=api.service стартуют после него.
Остановка
[Service]
Type=notify
Environment=SHUTDOWN_TIMEOUT=20s
TimeoutStopSec=30s
Restart=on-failure
RestartSec=2s
На stop и restart приходит SIGTERM, код через
signal.NotifyContext перестаёт принимать соединения и доигрывает текущие (статья «Веб-сервисы», глава 10.2). Бюджет
в коде меньше TimeoutStopSec с запасом: сервис, которому нужно 30 секунд при
TimeoutStopSec=5s, получил SIGKILL через 5 секунд. Пока новый процесс греется,
порт закрыт (если его не держит сокет-юнит systemd), поэтому реплики за балансировщиком перезапускают по
очереди.
Go-процесс без обработчика SIGTERM умирает мгновенно: memhog в прогоне
остановился за 5 мс, а systemd записал «Deactivated successfully» и Result=success, потому
что смерть от SIGTERM для него чистый выход. Запросы при этом оборваны. Смотри в
journalctl -u api -f, прошло ли между «got signal» и «bye» время доигрывания.