voidlearn
 

1.4Диагностика и systemd

Свяжешь задержку и перезапуск процесса с очередями CPU, памяти и диска, PSI и журналами ядра. Затем разберёшь systemd unit: ограничения, сигналы, перезапуск и корректную остановку сервиса.

Что сможешь объяснить после этой главы
  • Почему 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

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

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

  • PSI измеряет время ожидания ресурса, а не только его занятость.
  • Код выхода 137 сам по себе не доказывает OOM; сопоставь его с состоянием cgroup и журналом ядра.
  • systemd управляет процессом и его ресурсами, а корректный drain должен быть реализован приложением.

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

  • Load average не показывает напрямую троттлинг CPU внутри контейнера.
  • Не добавляй автоматический перезапуск без проверки, не зацикливает ли он постоянную ошибку конфигурации.

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

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

Чтение кода

1. Сопоставь PSI и throttling

Контейнеру выделен один CPU, внутри запущены четыре CPU-bound worker. Прочитай снимок и объясни, почему спокойный load average хоста не опровергает нехватку CPU у контейнера.

/proc/loadavg: 1.21 1.84 2.83
/sys/fs/cgroup/cpu.pressure:
some avg10=73.92 avg60=46.78 full avg10=73.60
cpu.stat: nr_throttled 603
Ответ и разбор

Значение nr_throttled показывает, что cgroup упиралась в квоту CPU; высокий some в её cpu.pressure показывает ожидание CPU внутри группы. /proc/loadavg относится к видимой системе и не является прямой метрикой cgroup throttling. Для вывода о причине задержки сопоставь эти данные с лимитом и CPU-временем сервиса.

Код-ревью

2. Проверь unit перед продом

Сервис принимает HTTP-запросы и должен переживать плановую остановку. Найди два риска в unit-файле.

[Service]
ExecStart=/srv/app/api
Restart=always
KillMode=process
TimeoutStopSec=2
User=root
Ответ и разбор

KillMode=process может оставить дочерние процессы, а двух секунд может не хватить на штатный drain. Запуск от root расширяет последствия компрометации и ошибки. Используй выделенного пользователя с нужными правами, настрой остановку группы процессов и согласуй TimeoutStopSec с таймаутом graceful shutdown приложения. Для Restart выбери политику по типу выхода и проверь, что постоянная ошибка не создаёт бесконечный цикл рестартов.

Применение

3. Разбери код выхода 137

После выкладки процесс завершился с кодом 137. Составь план из трёх проверок, который различит OOM killer, ручной SIGKILL и истечение таймаута остановки.

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

Проверь журнал ядра и сообщения OOM, включая имя cgroup и PID; проверь memory.events и состояние контейнера/юнита (OOMKilled, Result); сопоставь время SIGTERM, TimeoutStopSec и последующего SIGKILL. Код 137 говорит о SIGKILL, но не раскрывает, кто его отправил.

Применение

4. Выбери безопасный первый замер

p99 вырос втрое, CPU в top низкий, а диск в iostat почти не загружен. В vmstat растёт столбец wa. Какие данные снимешь следующими и почему одного процента занятости диска недостаточно?

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

Сниму io.pressure и PSI cgroup, очереди и задержки устройств в iostat -x, процессы с I/O в pidstat -d, а затем стеки/системные вызовы коротким целевым наблюдением. Низкая занятость устройства не исключает задержку очереди или блокировку на конкретном пути. Замер должен помочь локализовать ожидание до включения тяжёлой трассировки.