Тема 01

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 штук). Переключение горутин не выходит в ядро вообще: рантайм сохраняет несколько регистров и прыгает на другой стек, поэтому оно в разы дешевле переключения потоков.

Процесс PID 1234 — своё адресное пространство Поток TID 1234 стек 8 МиБ (ulimit -s) G1 G2 G3 G4 горутины: стек от 2 КиБ, планирует рантайм Go Поток TID 1235 свои регистры и стек G5 G6 свободный слот общее с соседним потоком: куча, код, fd, mmap Ядро видит два task_struct. Про G1..G6 оно не знает ничего. Стоимость, порядок величины fork() процесса 15 мкс – 3 мс clone() потока ~6 мкс go func() — старт горутины 200 – 500 нс переключение процессов ~0.4 мкс + TLB переключение потоков ~0.35 мкс переключение горутин ~100 нс Разница не в тактах, а в том, входим ли мы в ядро и сбрасываем ли кэши. Итог для собеса: ядро планирует потоки, рантайм Go планирует горутины поверх потоков. Поэтому 100 000 горутин — норма, а 100 000 потоков — смерть машины.
Три уровня. Процесс изолирует, поток получает время на процессоре, горутина даёт дешёвую конкурентность. Цифры сняты на arm64 (Linux 7.0 в Docker Desktop, Go 1.27.1, одно ядро; fork — от пустого процесса до 1 ГиБ памяти): на другом железе они сдвинутся, но порядок останется.
РесурсМежду процессамиМежду потоками одного процессаМежду горутинами
Адресное пространство (код, куча)раздельноеобщееобщее
Стексвойсвой (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 МиБ).
Адресное пространство процесса, сверху вниз пространство ядра — недоступно 0xFFFF... Стек (stack) 8 МиБ по ulimit -s 0x7FFF... растёт вниз дыра: не отображено — SIGSEGV mmap .so, арены Go, большие блоки куча растёт вверх Куча (heap) — brk / mmap malloc в C; Go brk не трогает .bss — нули, в файле места нет .data — инициализированные глобалы .rodata — константы, строки (r--) .text — машинный код (r-x) 0x400000 Как это ложится на железо Виртуальные страницы по 4 КиБ V1 V2 V3 MMU+TLB физические фреймы Таблица страниц: 4 уровня, TLB кеширует переводы. Промах TLB — аппаратный обход таблицы, десятки нс. minor fault — без диска: нулевая страница или кэш major fault — чтение с диска/swap, от десятков мкс fork() и copy-on-write Копируются не страницы, а таблицы страниц; все страницы становятся read-only. Первая запись даёт fault, ядро копирует одну страницу 4 КиБ. Но не бесплатно: 1 ГиБ RSS — до ~2.5 мс. VSZ vs RSS — почему Go «ест гигабайт» VSZ — сколько адресного пространства занято. RSS — сколько физ. страниц реально отображено. Go заранее резервирует адреса: VSZ большой, RSS маленький. Смотреть надо RSS.
Память процесса. Слева карта: что где лежит. Справа видно, почему виртуальный размер не равен реальному потреблению и почему 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              # столько потоков ОС держит рантайм
Глубже, чем спросят: overcommit и OOM killer

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 стоит наносекунды, а не микросекунды.

Путь блокирующего вызова из Go горутина f.Read(buf) рантайм Go entersyscall G помечается CPU syscall кольцо 3 в кольцо 0 ядро sys_read VFS, драйвер, ожидание возврат exitsyscall свой P или свободный Цена: 130–180 нс на «пустой» вызов (замер: arm64 без KPTI), с KPTI дороже, плюс промахи кэша и TLB после возврата. vDSO: clock_gettime и gettimeofday исполняются прямо в процессе, без перехода в ядро — отсюда дешёвый time.Now(). sysmon отбирает P у потока, застрявшего в syscall: через 20 мкс, если P ждёт работа, иначе через 10 мс. Сетевой ввод-вывод в Go вообще не блокирует поток: сокеты неблокирующие, ожиданием заведует netpoller поверх epoll. Таблица файловых дескрипторов процесса 0 stdin 1 stdout 2 stderr 3 /var/log/app.log 4 TCP 10.0.0.5:5432 (пул БД) 5 TCP 0.0.0.0:8080 (listener) struct file в ядре смещение, флаги, счётчик ссылок inode, сокет, pipe, eventfd, epoll «всё есть файл»
Syscall и дескрипторы. fd работает просто индексом в таблице процесса; за ним стоит объект ядра. Не закрыл, и индекс занят, пока жив процесс (забытый файл в 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.Client{} со своим http.Transport внутри функции, которую зовут на каждый запрос, значит гарантированно потечь (клиент без своего транспорта берёт общий, по умолчанию, и не течёт): у каждого транспорта свой пул соединений, старые сокеты не переиспользуются и висят, пока не отвалятся по таймауту. http.Client потокобезопасен, поэтому делай его один на процесс, настраивай MaxIdleConnsPerHost (дефолт 2, для одного бэкенда мало) и всегда ставь Timeout. Симптом в проде: растёт число открытых сокетов (у незакрытых тел ответов они висят в CLOSE_WAIT), потом «too many open files», потом сервис перестаёт принимать входящие, ведь accept() тоже нужен дескриптор.

Сигналы

Сигнал приходит процессу асинхронно, от ядра или от другого процесса. У каждого есть номер, действие по умолчанию и признак «перехватываемый».

СигналКто и когда шлётПо умолчаниюПерехватить?
SIGTERM15kill, docker stop, k8s при удалении подазавершениеда — это «просьба закрыться»
SIGINT2Ctrl+C в терминалезавершениеда
SIGQUIT3Ctrl+\завершение + core dumpда, но в Go по умолчанию печатает стеки всех горутин
SIGKILL9kill -9, OOM killer, k8s после grace periodмгновенная смертьнет, никогда
SIGSTOP19заморозка процессаостановканет
SIGHUP1закрытие терминала; по традиции — «перечитай конфиг»завершениеда
SIGUSR1/210/12только то, что ты сам придумалзавершениеда — удобно для «сбрось профиль»
SIGPIPE13запись в закрытый сокет/pipeзавершениеда; Go игнорирует его для всего, кроме stdout/stderr
SIGCHLD17ребёнок завершилсяигнорируетсяда — сюда вешают 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
Суть: процесс — единица изоляции (своё адресное пространство), поток — единица планирования ядром внутри процесса, горутина — единица конкурентности, которую планирует рантайм Go в user space и о которой ядро не знает.

Память

У процесса своё виртуальное адресное пространство, своя таблица файловых дескрипторов, свой 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).»

Суть: процесс видит непрерывное виртуальное адресное пространство, разбитое на сегменты; ядро отображает его на физические фреймы страницами по 4 КиБ, лениво и по требованию — через page fault.

Сегменты

Снизу вверх: .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.
Ловушка: «почему Go-процесс занимает 1.2 ГиБ?»

Потому что смотрят на VSZ. VSZ показывает, сколько виртуального пространства зарезервировано, а рантайм Go заранее резервирует адреса под кучу и свои служебные структуры, и это ничего не стоит. Реальное потребление видно в RSS (VmRSS в /proc/PID/status), а в контейнере правильнее container_memory_working_set_bytes: именно по нему kubelet решает, кого вытеснить с ноды (сам OOMKill по лимиту делает ядро). И RSS у Go не всегда падает сразу после GC: память возвращается ядру через MADV_FREE/MADV_DONTNEED с задержкой, и на графике это выглядит как «утечка», которой нет.

Суть: у потока ОС стек фиксированный (8 МиБ) и упирается в guard page — получаем 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-ы не выполняются, процесс умирает сразу.

Суть: syscall — переход из пользовательского кода в ядро через аппаратную инструкцию; дорог не сам переход, а сопутствующая работа ядра, остывшие кэши и, с митигацией Meltdown (KPTI), смена таблицы страниц на каждом входе и выходе. Смотреть — 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» почти всегда означает, что кто-то пишет в лог по одной строке без буфера.

Суть: fd — индекс в таблице открытых файлов процесса; лимит задаётся 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 посреди работы.

Подвох №1: PID 1 в контейнере

Процесс с 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.

Подвох №2: «а какие сигналы нельзя перехватить?»

Ровно два: 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.

Суть: systemd — 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-контейнером.

Суть: load average — это число задач в очереди на выполнение плюс задачи в непрерываемом ожидании ввода-вывода, сглаженное за 1/5/15 минут; делить надо на число ядер. А «мало free» — норма: Linux отдал память под page cache, смотреть надо 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 МиБ.

Суть: iowait — доля времени, когда CPU простаивал, но кто-то ждал ввода-вывода. Зомби — завершившийся процесс, чей код возврата не забрал родитель через 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 отвечают «что процесс видит», cgroups — «сколько ему можно». Контейнер = обычный процесс + namespaces + cgroup + корневая ФС из образа. Никакой виртуализации, ядро общее с хостом.

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

Суть: сгенерировать ed25519-пару, положить публичный ключ в ~/.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 yes sshd молча откажет, если домашний каталог, ~/.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 на каталоге, зачем /tmp sticky 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.

Каталог /srv/demo app.yaml 55190 app-hard.yaml 55190 app-sym.yaml 55191 inode 55190 обычный файл, 0644, root links = 2, size = 11 открыт процессами: 1 адреса блоков данных блоки на диске port: 8080 inode 55191 symlink, links = 1, size = 8 путь: app.yaml имя ищется заново при каждом открытии процесс fd 4 открытый файл в ядре смещение, флаги Когда место освобождается Блоки свободны, когда links = 0 и файл никто не держит открытым. rm только убирает имя. Пока дескриптор открыт, df считает место занятым.
Имя, inode и данные. Два имени ведут к одному inode, symlink хранит только путь и ищет его при каждом обращении, а открытый дескриптор держит inode независимо от имён.

Удалённый файл, который занимает место

Кроме имён ядро учитывает, кто держит 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_centisecs3000страница, грязная дольше 30 с, уходит на диск при ближайшей фоновой записи
vm.dirty_writeback_centisecs500период фоновой записи; в ВМ Docker Desktop 1500
vm.dirty_background_ratio10доля доступной памяти, после которой запись идёт в фоне
vm.dirty_ratio20доля, на подходе к которой пишущий процесс засыпает в write, пока фоновые потоки пишут на диск

В этой ВМ 100 МиБ без sync пролежали грязными не меньше 10 секунд, а на сервере с десятками гигабайт памяти 10 % означают гигабайты «записанных» данных. Гарантию даёт fsync(fd) (в Go f.Sync()): он возвращается, когда устройство сообщило о записи данных и метаданных файла. close записи на диск не гарантирует.

Почему ошибку fsync нельзя повторить

После ошибки записи на устройство страницы могут оказаться помеченными как чистые, и повторный 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: файл обнуляется первым же вызовом openat(O_TRUNC) write close файл уже пустой, старых данных нет умер процесс: пустой или обрывок данные в page cache, сбой питания: обрывок strace убил процесс на первом write: config.json остался пустым, 0 байт Временный файл и rename: имя меняется одним шагом openat(tmp, O_EXCL) write, fchmod fsync(tmp) rename(tmp, name) fsync(каталог) config.json старый, tmp пустой: останется мусор config.json старый, tmp с данными в page cache config.json старый, tmp целиком на диске все видят новый; сбой питания может вернуть старый новый файл и новая запись каталога на диске
Где рвётся запись файла. У 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 скобки отмечают выбранный планировщик ввода-вывода диска.

Вопросы

4
Суть: чаще всего место держит удалённый, но открытый файл: имени нет, и du его не видит, а блоки заняты, пока процесс не закроет дескриптор. Находят через 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) с округлением вверх (так ведёт себя df 9.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 в пределах одной файловой системы атомарен, поэтому по имени всегда лежит целый файл, старый или новый».

Суть: проще всего слушать высокий порт и отдавать 80-й снаружи: Service в Kubernetes, -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 флаг обязателен, в поде всё так же.
Суть: page cache записанных файлов учитывается в cgroup контейнера. Чистые страницы ядро вытесняет, а грязные и уходящие на устройство держит, пока диск их не примет. Если ими занят весь лимит, ядро диск не дожидается: несколько пустых попыток освободить память, и срабатывает OOM killer cgroup. Файлы в tmpfs без swap не вытесняются вовсе.

Когда кэш не успевает освободиться

На быстром диске ВМ это лотерея: 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 так же считаются в лимит памяти записавшего их контейнера.

Смотреть только на heap

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".

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

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

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

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

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

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

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

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

TIME_WAIT и FIN_WAIT_2

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

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

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

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

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

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

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

Очередь accept и somaxconn

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

netfilter: цепочки и DNAT

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вопросы

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

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

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

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

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

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

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

Отказ

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

Таймаут

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

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

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

Сервер и узел

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

Клиент

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

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

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

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

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

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

Что увидит Go

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

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

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

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 cgrouppidstat -u, perf top
Не хватает памятиmemory.pressure, memory.eventspidstat -r, vmstat
Упёрлись в дискio.pressure, vmstatpidstat -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 / fullcgroup: io some / full
4 процесса, --cpus=11.35 / 1.2174 % / 74 %0 / 0
4 процесса на одном ядре3.00 / 3.6799 % / 00 / 0
4 писателя, диск 4 МБ/с0.96 / 3.080 / 089 % / 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 за секунду значит, что процесс только и ждал диск.

iodelay всегда 0, пока не включён учёт задержек

Колонку заполняет учёт задержек ядра, а он по умолчанию выключен; включает его 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).

Память cgroup memory.max memory.high memory.current memory.events 1. Процесс трогает новую страницу ядро записывает её на счёт cgroup (charge) счётчики не меняются 2. memory.current выше memory.high процесс сам освобождает память и засыпает high +1 memory.pressure растёт 3. Упёрлись в memory.max прямое освобождение: page cache, swap, если разрешён max +1 освободили: назад к шагу 1 4. Освободить нечего: OOM в cgroup кандидаты только из этой cgroup, считаем очки oom +1 жертва по очкам 5. SIGKILL процессу с наибольшими очками код выхода 137, в журнале ядра Killed process oom_kill +1 Memory cgroup out of memory
OOM в cgroup по шагам. Каждая новая страница идёт на счёт группы. За 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
без strace67 0371.49 мс1 18223.5 мс
strace -f -p3 53735.2 мс67687.2 мс
strace -f -e trace=%network,%file -p2 99940.7 мс48888.9 мс
strace -f --seccomp-bpf -e …, сервис запущен под strace24 6222.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
время после stop 0 1 с 5 с systemd SIGTERM ждёт TimeoutStopSec=5s SIGKILL успевает за 1 с Shutdown() выход, Result=success нужно 30 с Shutdown() ещё доигрывает запросы убит, Result=timeout KillMode=control-group: оба сигнала получают все процессы cgroup юнита, а не только главный
Остановка юнита. После 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 capabilitiesNoNewPrivs: 1
ProtectSystem=strictвся ФС только для чтения, кроме API-ФС и выданных каталогов; full закрывает /usr, /boot, /etctouch /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, даже у rootCapBnd: 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.

cronsystemd timer
Вывод задачипочта или перенаправление в файлjournald, journalctl -u report
Машина была выключеназапуск пропущендогоняет с Persistent=true
Задача не успела к следующему запускувторая копия поверх первойвторая копия не стартует
Лимиты и изоляциянетвсё из [Service]

Вопросы

4
Суть: смотреть cgroup контейнера, а не узел. Задачи cgroup, выбравшей квоту cpu.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 в контейнере про весь узел

/proc/pressure/cpu и /proc/loadavg в контейнере показывают всю машину: растут от соседей и молчат, пока контейнер задыхается в своей квоте. Доказывает только файл cgroup.

Суть: след оставляет ядро: строки OOM в 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 МиБ.
oom_score в /proc не предсказывает OOM в cgroup

/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» время доигрывания.