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 cgroup | pidstat -u, perf top |
| Не хватает памяти | memory.pressure, memory.events | pidstat -r, vmstat |
| Упёрлись в диск | io.pressure, vmstat | pidstat -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 / full | cgroup: io some / full |
|---|---|---|---|
4 процесса, --cpus=1 | 1.35 / 1.21 | 74 % / 74 % | 0 / 0 |
| 4 процесса на одном ядре | 3.00 / 3.67 | 99 % / 0 | 0 / 0 |
| 4 писателя, диск 4 МБ/с | 0.96 / 3.08 | 0 / 0 | 89 % / 89 % |
На одном ядре задачи ждут 99 % времени при нулевом full: одна из четырёх всегда работает. У
писателей load average вырос на два без всякого процессора, четыре задачи в D. Load average валит
оба случая в одно число на всю машину, а PSI разводит их по ресурсам и cgroup.
В /proc/pressure/memory можно записать порог (some 150000 1000000) и ждать его в
poll(); на PSI построен systemd-oomd. kubelet отдаёт PSI контейнеров в метриках cAdvisor, фича
KubeletPSI стабильна с Kubernetes 1.36.
vmstat, iostat, pidstat на одной нагрузке
Нагрузка на все три одна: cpu-hog с квотой в ядро, io-hog с четырьмя писателями и
общим лимитом записи 4 МБ/с и memhog, Go-программа, которая каждые полсекунды выделяет и трогает
16 МиБ.
# vmstat 1 4
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
r b swpd free buff cache si so bi bo in cs us sy id wa st gu
1 4 91212 1073236 653528 4690464 266 246 608 6425 3704 19 5 2 91 1 0 0
6 4 91212 1049036 653532 4690796 0 0 0 4336 4565 6513 27 2 7 64 0 0
0 4 91212 1035176 653532 4690796 0 0 32768 4096 3909 4642 26 0 2 71 0 0
…
Первая строка усреднена с загрузки, кроме колонок процессов и памяти. В b четыре писателя, ждущие
ввода-вывода, а r прыгает от 0 до 6: снимок попадает то на исчерпанную квоту
cpu-hog, то на начало периода. В wa ушло 64–71 % процессора, bo
держится около 4096 КиБ/с, на лимите записи.
# iostat -dx vda 1 2 | awk '/^Device|^vda/ {print $1, $8, $9, $12, $22, $23}'
Device w/s wkB/s w_await aqu-sz %util
vda 70.87 6425.54 1.18 0.09 0.60
Device w/s wkB/s w_await aqu-sz %util
vda 64.00 4096.00 0.09 0.02 1.40
Первый отчёт тоже с загрузки. Во втором диск отдыхает: запись за 0.09 мс, очередь 0.02, занят 1.4 % времени. А
четыре процесса стоят в D: лимит --device-write-bps держит запросы в блочном слое, а
iostat видит только дошедшее до диска. У SSD и RAID с параллельной обработкой запросов и 100 %
%util не значит «упёрлись» (man iostat).
# pidstat -u -r -d -C "stress-ng|memhog" 1 1 | grep -v Average
…
23:07:58 UID PID %usr %system %guest %wait %CPU CPU Command
23:07:59 0 85108 25.74 0.00 0.00 73.27 25.74 0 stress-ng-cpu
23:07:59 0 85109 24.75 0.00 0.00 74.26 24.75 3 stress-ng-cpu
…
23:07:58 UID PID minflt/s majflt/s VSZ RSS %MEM Command
23:07:59 0 85193 8118.81 0.00 1620768 447252 5.50 memhog
23:07:58 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
23:07:59 0 85157 0.00 950.50 0.00 101 stress-ng-hdd
…
%wait 73–74 % против 25 % работы показывает троттлинг у отдельного процесса.
minflt/s у memhog 8119, по ошибке на каждые 4 КиБ; в других прогонах было около 26,
когда ядро выдавало прозрачные huge pages по 2 МиБ (режим always). iodelay считают в
тиках, по 100 в секунду: около 100 за секунду значит, что процесс только и ждал диск.
Колонку заполняет учёт задержек ядра, а он по умолчанию выключен; включает его
sysctl -w kernel.task_delayacct=1 или параметр загрузки delayacct. Структуру учёта
ядро заводит задаче при fork, и запущенные раньше процессы так и показывают 0: в прогоне
писатели, стартовавшие до sysctl, стояли в D с нулевым iodelay.
OOM killer: кого убивают и где след
Когда страницу выделить не из чего, ядро убивает процесс SIGKILL (про overcommit в главе 1.1).
OOM в cgroup наступает, когда группа упёрлась в memory.max, и кандидаты только её
процессы; глобальный OOM, когда кончилась память машины. Жертву выбирает oom_badness из
mm/oom_kill.c (ядро 7.0): очки = rss + swap + таблицы страниц + oom_score_adj × лимит / 1000, где
лимит это memory.max с разрешённым группе swap или RAM + swap машины. Поправку
oom_score_adj (от −1000, «не убивать», до 1000) ставят --oom-score-adj в Docker,
OOMScoreAdjust= в systemd, kubelet по QoS пода (−997 у Guaranteed, 1000 у BestEffort).
$ docker run --name oom-demo --memory=128m --memory-swap=128m -v $PWD/bin:/opt/bin:ro diag /opt/bin/memhog | tail -2
allocated 96 MiB
allocated 112 MiB
$ docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' oom-demo
137 true
# dmesg | grep -E 'invoked oom-killer|memory: usage|oom-kill:|Killed process' | tail -4
[31018.366033] memhog invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[31018.366075] memory: usage 131072kB, limit 131072kB, failcnt 51
[31018.366115] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=475d496a8cce…,mems_allowed=0,oom_memcg=/docker/475d496a8cce…,task_memcg=/docker/475d496a8cce…,task=memhog,pid=50987,uid=0
[31018.366132] Memory cgroup out of memory: Killed process 50987 (memhog) total-vm:1358848kB, anon-rss:130144kB, file-rss:4kB, shmem-rss:0kB, UID:0 pgtables:336kB oom_score_adj:0
--memory-swap равен --memory, иначе Docker добавит ещё столько же swap.
dmesg снят из привилегированного контейнера (id cgroup сокращён), на сервере есть
journalctl -k. У глобального OOM вместо CONSTRAINT_MEMCG и
memory: usage будут CONSTRAINT_NONE и Out of memory: Killed process; на
общей ВМ его не вызывали, чтобы не убить чужие процессы.
Выбор жертвы: в контейнере на 128 МиБ «кэш» на 32 МиБ с oom_score_adj 800 и растущий «api».
$ docker run --name oom-two --memory=128m --memory-swap=128m -v $PWD/bin:/opt/bin:ro diag sh -c '
MAX_MB=32 sh -c "echo 800 > /proc/self/oom_score_adj; exec /opt/bin/memhog" > /dev/null &
CACHE=$!; sleep 1
STEP_MS=1000 /opt/bin/memhog > /dev/null &
API=$!; sleep 3
for p in $CACHE $API; do echo "pid $p $(grep VmRSS /proc/$p/status) oom_score=$(cat /proc/$p/oom_score)"; done
wait $CACHE; echo "cache exited: $?"
wait $API; echo "api exited: $?"
grep -E "^(max|oom|oom_kill|oom_group_kill) " /sys/fs/cgroup/memory.events
cat /sys/fs/cgroup/memory.oom.group
'
pid 7 VmRSS: 37504 kB oom_score=1202
pid 13 VmRSS: 53220 kB oom_score=670
Killed
cache exited: 137
Killed
api exited: 137
max 112
oom 2
oom_kill 2
oom_group_kill 0
0
$ docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' oom-two
0 true
Первым умер «кэш», хотя он меньше: к 37 МиБ ядро добавило 800 × 128 / 1000 ≈ 102 МиБ, у «api» было около 90.
oom_score из /proc этого не предсказывает: ядро считает его от памяти всей машины,
(1000 + очки × 1000 / (RAM + swap)) × 2 / 3, отсюда 1202 и 670 при шкале 0–2000.
Контейнер вышел с кодом 0, его PID 1 выжил, а OOMKilled всё равно true. В
memory.events max считает упоры в лимит, oom неудачные попытки
освободить память, oom_kill убитых. С memory.oom.group=1 группа гибнет целиком:
Docker оставляет 0, kubelet на cgroup v2 с Kubernetes 1.28 ставит 1 (поштучное убийство возвращает
singleProcessOOMKill, с 1.32).
memory.high процесс тормозят, у memory.max ядро пытается освободить память и, если не
вышло, выбирает жертву среди процессов группы по очкам. Эти шаги оставляют след в
memory.events.
Шаг 2 без убийства проверен в systemd, где порог задаёт MemoryHigh= (в Docker флага нет): с
MemoryHigh=64M и без swap тот же memhog за 20 секунд дошёл до 64 МиБ и застрял,
memory.pressure группы показал 63 % при oom_kill 0. Сервис жив, но почти стоит.
strace на живом процессе и его цена
strace через ptrace останавливает поток на входе в каждый системный вызов и на
выходе, пока не напечатает строку. С -p он цепляется к работающему процессу, и без
-f виден только поток с TID, равным PID, а у Go он почти всё время спит в futex.
$ docker run -d --name web --cpus=2 --memory=256m --cap-add SYS_PTRACE -v $PWD/bin:/opt/bin:ro diag /opt/bin/websvc
$ docker exec -it web bash
# grep Threads /proc/$(pgrep websvc)/status
Threads: 7
# strace -f -tt -e trace=%network,%file -p $(pgrep websvc)
strace: Process 1 attached with 7 threads
[pid 12] 22:40:33.398141 accept4(4, {sa_family=AF_INET6, sin6_port=htons(59702), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_scope_id=0}, [112 => 28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
…
[pid 11] 22:40:33.398708 openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 8
websvc здесь маленький HTTP-сервис, /file читает /etc/hostname.
Соединение принял поток 12, файл открыл поток 11: семь потоков при GOMAXPROCS=2 обычны, и
горутина переезжает между ними (модель G, M и P в главе 1.1). Классы вызовов пишут с процентом, форму без него
man strace 6.13 называет устаревшей.
Цена: восемь клиентов по 10 секунд из соседнего контейнера с одним ядром, у сервиса два. /file
делает около десятка системных вызовов на запрос, /hash?n=20000 считает SHA-256 и в ядро почти
не ходит.
| Как снимали | /file, запросов/с | /file, p99 | /hash, запросов/с | /hash, p99 |
|---|---|---|---|---|
| без strace | 67 037 | 1.49 мс | 1 182 | 23.5 мс |
strace -f -p | 3 537 | 35.2 мс | 676 | 87.2 мс |
strace -f -e trace=%network,%file -p | 2 999 | 40.7 мс | 488 | 88.9 мс |
strace -f --seccomp-bpf -e …, сервис запущен под strace | 24 622 | 2.07 мс | - | - |
Обработчик, который ходит в ядро, потерял 95 % пропускной способности; в четырёх прогонах он замедлялся в
19–37 раз. Фильтр -e не помогает: ядро всё равно останавливает поток на каждом вызове.
--seccomp-bpf фильтрует в ядре и обходится дешевле, но к процессу, подключённому через
-p, не применяется (man strace), и сервис придётся запускать под strace.
perf и eBPF: смотреть, не останавливая
perf берёт выборки: ядро по таймеру или счётчику процессора тысячи раз в секунду прерывает поток и
записывает, где он был, а с -g и стек. Go на amd64 и arm64 сохраняет указатели кадров, поэтому стеки собираются без отладочной информации. В контейнере хватило --cap-add PERFMON.
# perf top -p $(pgrep websvc) --stdio -d 6 -E 5
…
23.97% websvc [.] crypto/internal/fips140/sha256.blockSHA2.abi0
…
# perf record -g -o perf.data -p $(pgrep websvc) -- sleep 5
…
# perf report -i perf.data --stdio --no-children | grep -v "^#" | grep -v "^$" | head -10
24.51% websvc websvc [.] crypto/internal/fips140/sha256.blockSHA2.abi0
|
---crypto/internal/fips140/sha256.blockSHA2.abi0
…
main.main.func1
net/http.HandlerFunc.ServeHTTP
Аппаратных счётчиков в ВМ нет (perf stat -e cycles: <not supported>), и
perf перешёл на task-clock. Имена функций он берёт из таблицы символов ELF: у бинаря
с -ldflags='-s -w' вместо них адреса вроде 0x00000000001ec364. Горутин
perf не знает, профили Go в главе 3.3.
С eBPF в ядро загружают маленькую программу: она цепляется к точке трассировки или функции ядра и копит
данные прямо там. bcc на Python компилирует её на лету из заголовков ядра, а в ВМ их нет, и
tcpretrans-bpfcc упал. libbpf-tools и bpftrace берут структуры ядра из BTF и заработали; им нужны
привилегии и смонтированная tracefs.
$ docker run -d --name web --health-cmd 'curl -fsS localhost:8080/file' --health-interval 2s -v $PWD/bin:/opt/bin:ro diag /opt/bin/websvc
$ docker run -it --rm --privileged --pid=host --cgroupns=host diag bash
# mount -t tracefs tracefs /sys/kernel/tracing
# execsnoop-libbpf -T -c /sys/fs/cgroup/docker/<id контейнера web>
TIME PCOMM PID PPID RET ARGS
22:43:24 6 74938 74929 0 /proc/self/fd/6 init
22:43:24 sh 74942 74929 0 /bin/sh -c curl -fsS localhost:8080/file
22:43:24 curl 74949 74942 0 /usr/bin/curl -fsS localhost:8080/file
22:43:26 6 74984 74975 0 /proc/self/fd/6 init
…
Проверка здоровья каждые две секунды запускает в контейнере runc init, sh и
curl; живут они миллисекунды, и в top их не увидеть. А то, что ядро делает без
системного вызова, не увидит и strace. Клиенту в соседнем контейнере добавили потерю пакетов
(tc qdisc add dev eth0 root netem loss 30%) и запустили рядом tcpretrans.bt:
# bpftrace /usr/sbin/tcpretrans.bt
…
TIME PID LADDR:LPORT RADDR:RPORT STATE
…
22:44:26 87777 172.17.0.8:43106 172.17.0.3:8080 SYN_SENT
…
Повтор шлёт таймер TCP в ядре, поэтому в колонке PID тот, кто оказался на процессоре (на свободной машине 0),
а strace клиента показал бы разве что долгий connect. Цена на нагрузке
/file на общей ВМ: opensnoop-libbpf -p в шести прогонах отнял 11–25 % пропускной
способности, однострочник bpftrace со счётчиком открытий 4–33 %, а strace -f -p 95–97 %.
Unit-файл для Go-сервиса
Для опытов systemd поднят PID 1 в привилегированном контейнере; в продакшене так не делают, а для песочницы сойдёт:
$ docker run -d --name sd --privileged --cgroupns=private --tmpfs /run --tmpfs /run/lock -v $PWD/bin:/opt/bin:ro diag /sbin/init
$ docker exec sd systemctl is-system-running
running
# /etc/systemd/system/api.service (websvc установлен как /usr/local/bin/api)
[Unit]
Description=Demo Go API
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStart=/usr/local/bin/api
EnvironmentFile=/etc/api/api.env
DynamicUser=yes
Restart=on-failure
RestartSec=2s
LimitNOFILE=65536
TimeoutStopSec=30s
[Install]
WantedBy=multi-user.target
# /etc/api/api.env; START_DELAY изображает прогрев до открытия порта
ADDR=:8080
START_DELAY=3s
SHUTDOWN_TIMEOUT=20s
# systemctl status api --no-pager
● api.service - Demo Go API
…
Active: active (running) since Wed 2026-09-16 22:44:40 UTC; 51ms ago
…
Sep 16 22:44:37 e128f08c3686 systemd[1]: Starting api.service - Demo Go API...
Sep 16 22:44:40 e128f08c3686 api[173]: 2026/09/16 22:44:40 listening on :8080, pid 173
Sep 16 22:44:40 e128f08c3686 systemd[1]: Started api.service - Demo Go API.
Между «Starting» и «Started» три секунды прогрева. Юнит включают
systemctl daemon-reload && systemctl enable --now api; без daemon-reload
после правки файла systemd останется на старой версии и предупредит «changed on disk». Свойства даёт
systemctl show -p MainPID,NRestarts api, хвост журнала
journalctl -u api -f --since "10 min ago".
Type= задаёт момент, когда сервис считается запущенным: тогда вернётся systemctl start
и стартуют юниты с After=api.service. С simple это сразу после fork(), с
exec после успешного execve() (man systemd.service советует его вместо
simple), с notify когда процесс прислал READY=1.
# grep Type= /etc/systemd/system/api.service; time systemctl start api; curl -sS localhost:8080/file
Type=simple
real 0m0.006s
…
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Could not connect to server
# grep Type= /etc/systemd/system/api.service; time systemctl start api; curl -sS localhost:8080/file
Type=notify
real 0m3.012s
…
e128f08c3686
С опечаткой в ExecStart= systemctl start для simple вернул 0, а для exec 1 и «Job for api.service failed». Для
notify systemd кладёт в окружение NOTIFY_SOCKET (в прогоне
/run/systemd/notify), и о готовности сервис сообщает датаграммой READY=1 в этот
unix-сокет; адрес на @ означает абстрактный сокет. Хватит десятка строк, хотя есть и
daemon.SdNotify из github.com/coreos/go-systemd/v22:
func sdNotify(state string) error {
addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" {
return nil // запущен не под systemd
}
conn, err := net.Dial("unixgram", addr) // "@имя" Go сам считает абстрактным сокетом
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Write([]byte(state))
return err
}
// в main: net.Listen, потом sdNotify("READY=1"); по SIGTERM sdNotify("STOPPING=1")
DynamicUser=yes запускает сервис под временным UID из диапазона 61184–65519 (в прогоне
62104) и включает ProtectSystem=strict, ProtectHome=read-only,
PrivateTmp, NoNewPrivileges: писать можно только в выданные каталоги вроде
StateDirectory=. UID потом может достаться другому сервису, так что файлам с постоянным
владельцем нужен User=. EnvironmentFile= перечитывается при каждом старте.
LimitNOFILE=. Мягкий лимит дескрипторов у юнита по умолчанию 1024, но Go-процессу это почти не мешает. Юнит без LimitNOFILE:
# systemctl show -p LimitNOFILESoft,LimitNOFILE api; grep "open files" /proc/$(systemctl show -p MainPID --value api)/limits
LimitNOFILE=1048576
LimitNOFILESoft=1024
Max open files 1048575 1048576 files
# systemd-run --wait -q -P -p DynamicUser=yes sh -c "ulimit -Sn; ulimit -Hn"
1024
1048576
Go-процесс сам поднял мягкий лимит до жёсткого без единицы (глава 1.1), sh остался на 1024, и детям
из os/exec рантайм тоже возвращает исходный лимит. Жёсткий 1048576 унаследован от Docker, на обычной
машине systemd 257 даёт 1024:524288.
Restart=on-failure перезапускает при ненулевом коде, смерти от сигнала (кроме
SIGHUP, SIGINT, SIGTERM, SIGPIPE: для systemd это чистый
выход), таймауте и пропущенном watchdog; systemctl stop перезапуска не вызывает. Сервис падает на
старте, RestartSec=1s:
# journalctl -u api --since 22:45:04 --no-pager
…
Sep 16 22:45:04 e128f08c3686 api[627]: 2026/09/16 22:45:04 config: DB_DSN is empty
Sep 16 22:45:04 e128f08c3686 systemd[1]: api.service: Main process exited, code=exited, status=1/FAILURE
…
Sep 16 22:45:05 e128f08c3686 systemd[1]: api.service: Scheduled restart job, restart counter is at 1.
…
Sep 16 22:45:10 e128f08c3686 systemd[1]: api.service: Start request repeated too quickly.
…
Sep 16 22:45:10 e128f08c3686 systemd[1]: Failed to start api.service - Demo Go API.
После пятого перезапуска за 10 секунд юнит упёрся в StartLimitBurst=5 и
StartLimitIntervalSec=10s (они в секции [Unit]) и остался в failed до
ручного запуска или systemctl reset-failed. Растущую паузу, как у CrashLoopBackOff, дают
RestartSteps= и RestartMaxDelaySec= (systemd 254+).
Остановка: KillSignal и TimeoutStopSec
systemctl stop выполняет ExecStop=, шлёт KillSignal (SIGTERM),
ждёт TimeoutStopSec (по умолчанию 90 секунд) и добивает SIGKILL.
KillMode=control-group шлёт сигналы всем процессам cgroup юнита, mixed отдаёт
SIGTERM только главному. Сервис доигрывает запросы STOP_DELAY секунд (код - в статье «Веб-сервисы» раздела Go, глава 10.2),
в юните TimeoutStopSec=5s: с 1 секундой вышло Result=success, с 30 так:
# grep STOP_DELAY /etc/systemd/system/api.service; time systemctl stop api; journalctl -u api --since 23:09:32 --no-pager; systemctl show -p ActiveState,Result api
Environment=STOP_DELAY=30s
real 0m5.076s
…
Sep 16 23:09:32 3f1360d68971 systemd[1]: Stopping api.service - Demo Go API...
Sep 16 23:09:32 3f1360d68971 api[316]: 2026/09/16 23:09:32 got signal, shutting down
Sep 16 23:09:38 3f1360d68971 systemd[1]: api.service: State 'stop-sigterm' timed out. Killing.
Sep 16 23:09:38 3f1360d68971 systemd[1]: api.service: Killing process 316 (api) with signal SIGKILL.
…
Sep 16 23:09:38 3f1360d68971 systemd[1]: Stopped api.service - Demo Go API.
Result=timeout
ActiveState=failed
SIGTERM у сервиса есть TimeoutStopSec.
Уложился, и юнит остановлен чисто; не уложился, и systemd добивает процессы группы SIGKILL, а юнит
уходит в failed с результатом timeout.
Бюджет остановки в коде (context.WithTimeout вокруг srv.Shutdown) делают меньше
TimeoutStopSec с запасом на закрытие пула БД и сброс телеметрии. Сервис с Type=notify,
которому нужно дольше, продлевает срок сообщением EXTEND_TIMEOUT_USEC=….
Изоляция: что закрыть сервису
Сетевому сервису не нужны запись в /usr, чужие домашние каталоги и модули ядра. systemd закрывает
это опциями [Service] на тех же namespaces, capabilities и seccomp, что и у контейнеров, а
systemd-analyze security по списку опций оценивает, насколько юнит открыт, от 0 до 10.
# systemd-analyze security plain.service --no-pager | tail -1
→ Overall exposure level for plain.service: 9.6 UNSAFE :-{
# systemd-analyze security api.service --no-pager | awk '/^✗/ {print $1, $2, $NF} /Overall/'
…
✗ RestrictAddressFamilies=~AF_UNIX 0.1
✗ RestrictAddressFamilies=~AF_(INET|INET6) 0.3
✗ PrivateNetwork= 0.5
…
✗ IPAddressDeny= 0.2
→ Overall exposure level for api.service: 1.2 OK :-)
В plain.service один ExecStart=, а в api.service к юниту выше добавлены
опции из таблицы ниже и ещё почти два десятка: PrivateDevices, ProtectKernel*,
RestrictAddressFamilies, RestrictNamespaces, MemoryDenyWriteExecute,
SystemCallFilter=@system-service и другие. Сервис с ними работал, а оставшиеся 1.2 набежали в
основном за сеть. Вызов вне SystemCallFilter= по умолчанию убивает процесс сигналом
SIGSYS. Низкая оценка не значит, что уязвимостей нет, только что последствия будут меньше.
| Опция | Что делает | Что видно изнутри в прогоне |
|---|---|---|
NoNewPrivileges=yes | нельзя получить права через setuid и file capabilities | NoNewPrivs: 1 |
ProtectSystem=strict | вся ФС только для чтения, кроме API-ФС и выданных каталогов; full закрывает /usr, /boot, /etc | touch /etc/x: Read-only file system |
ProtectHome=yes | /home, /root, /run/user недоступны; read-only оставляет чтение | ls /home: Permission denied |
PrivateTmp=yes | свои /tmp и /var/tmp, очищаются после остановки | файлов хоста в /tmp нет |
CapabilityBoundingSet= | пустой набор capabilities, даже у root | CapBnd: 0000000000000000 |
Одного DynamicUser для домашних каталогов мало, он включает только ProtectHome=read-only:
в прогоне сервис с ним прочитал чужой /home/alice/.pgpass с правами 644, а с
ProtectHome=yes получил Permission denied на /home.
Timer вместо cron
Периодическая задача под systemd состоит из двух юнитов: .service с Type=oneshot делает
работу, .timer с тем же именем решает, когда её запускать. В Kubernetes эту роль играет CronJob (глава 3.3).
# /etc/systemd/system/report.service
[Service]
Type=oneshot
ExecStart=/bin/echo report generated
DynamicUser=yes
# /etc/systemd/system/report.timer
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.target
# systemctl enable --now report.timer && systemctl list-timers report.timer --no-pager
…
NEXT LEFT LAST PASSED UNIT ACTIVATES
Thu 2026-09-17 03:01:43 UTC 4h 15min - - report.timer report.service
…
# systemctl show -p AccuracyUSec,RandomizedDelayUSec,Persistent report.timer
AccuracyUSec=1min
…
Выражение времени заранее проверяет systemd-analyze calendar "Mon..Fri 09:00", а
list-timers показывает запуск уже со случайной задержкой (03:01:43). AccuracySec по
умолчанию минута: в её пределах systemd сдвигает срабатывания, чтобы реже будить машину.
Persistent=true хранит время запуска в /var/lib/systemd/timers/: из двух таймеров
OnCalendar=minutely, остановленных на 75 секунд, таймер с этой опцией после включения сразу
запустил задачу, а без неё ждал следующей минуты.
Задача на 25 секунд под таймером раз в 10 секунд и задача на 90 секунд в /etc/cron.d раз в минуту:
таймер второй копии не запустил, срабатывания склеились в запуск сразу после окончания (22:46:00, 22:46:25,
22:46:50), а cron начал новую копию в 22:47:01, хотя первая закончилась только в 22:47:31.
| cron | systemd timer | |
|---|---|---|
| Вывод задачи | почта или перенаправление в файл | journald, journalctl -u report |
| Машина была выключена | запуск пропущен | догоняет с Persistent=true |
| Задача не успела к следующему запуску | вторая копия поверх первой | вторая копия не стартует |
| Лимиты и изоляция | нет | всё из [Service] |
Вопросы
4cpu.max, сняты с очереди выполнения, и load average видит в среднем лишь использованную квоту. Ожидание видно в
cpu.pressure, nr_throttled и %wait у pidstat.Почему узел спокоен
Лимит CPU превращается в квоту cpu.max на период в 100 мс, и выбравшие её потоки стоят до
следующего периода при свободных ядрах узла. В прогоне четыре счётных процесса при --cpus=1
оставили load average узла на 1.35 и 1.21, а cpu.pressure контейнера показал 74 %.
Что показать
$ cat /sys/fs/cgroup/cpu.pressure # внутри контейнера: some и full почти равны
$ grep throttled /sys/fs/cgroup/cpu.stat # nr_throttled растёт между замерами
$ pidstat -u -C websvc 1 5 # колонка %wait
В Kubernetes то же дают метрики cAdvisor container_cpu_cfs_throttled_periods_total и
container_pressure_cpu_waiting_seconds_total. Следом проверь GOMAXPROCS (статья
«Docker», глава 2.1): до Go 1.25 он равнялся числу ядер узла, и потоки выбирали квоту в начале периода.
/proc/pressure/cpu и /proc/loadavg в контейнере показывают всю машину: растут
от соседей и молчат, пока контейнер задыхается в своей квоте. Доказывает только файл cgroup.
journalctl -k и счётчик
oom_kill в memory.events. CONSTRAINT_MEMCG означает лимит cgroup,
CONSTRAINT_NONE память всей машины. Жертву выбирают по памяти процесса плюс
oom_score_adj, пересчитанному в долю этого лимита.Где след
$ journalctl -k | grep -E 'invoked oom-killer|oom-kill:|Killed process'
$ cat /sys/fs/cgroup/<группа>/memory.events # oom, oom_kill
$ systemctl show -p Result api # Result=oom-kill
SIGKILL не перехватить, поэтому приложение молчит, а код 137 значит только «убит
SIGKILL»: его же дают kill -9 и добивание после таймаута остановки.
Какой лимит и почему он
Memory cgroup out of memoryиoom_memcg=: лимит группы, на машине память могла быть.Out of memory: Killed process: кончилась память машины, жертвой мог стать сосед.- «
X invoked oom-killer» называет того, чья аллокация не прошла; убит может быть другой. - Очки: rss + swap + таблицы страниц +
oom_score_adj × лимит / 1000; в прогоне процесс на 37 МиБ с поправкой 800 умер раньше соседа на 90 МиБ.
/proc/<pid>/oom_score считается от памяти всей машины, а выбор внутри cgroup идёт от её лимита.
И при memory.oom.group=0 убивают один процесс: контейнер с выжившим PID 1 выходит с кодом 0 и
OOMKilled=true.
ptrace останавливает поток на каждом
системном вызове, фильтр -e при -p этого не отменяет, и сервис с частыми вызовами в прогоне
потерял 95 % пропускной способности. Заменяют его perf, eBPF и pprof.Откуда цена
Ядро дважды на каждый вызов переключается в strace, поэтому цена растёт с частотой вызовов, а
не с работой процессора: /file упал с 67 до 3,5 тыс. запросов в секунду, p99 с 1,5 до 35 мс.
--seccomp-bpf переносит фильтр в ядро, но только для процесса, запущенного под
strace -f.
Если всё же strace
# timeout -s INT 5 strace -f -tt -T -e trace=%network,%file -o /tmp/trace.txt -p $(pgrep websvc)
-f обязателен, -T добавит время внутри вызова. Держи несколько секунд и пиши в
файл: даже эти секунды заметят клиенты, а проверка здоровья может снять реплику, так что её по возможности
выведи из балансировки.
Чем заменить
- Где тратится CPU:
perf top,perf record -g. - Какие файлы открываются и процессы запускаются:
opensnoop,execsnoopиз libbpf-tools. - Повторы TCP и другие события без системного вызова:
tcpretrans.bt, однострочники bpftrace. - Где стоят горутины: pprof goroutine (глава 3.3) или
kill -QUIT, но после дампа процесс завершится.
Type=notify и READY=1 после net.Listen;
обработка SIGTERM с бюджетом меньше TimeoutStopSec; Restart=on-failure с
паузой. Проверять по журналу: нет ли State 'stop-sigterm' timed out.Старт
С Type=simple в прогоне systemctl start вернулся за 6 мс, сразу после
fork(), а порт открылся через 3 секунды, и curl получил отказ. С
Type=notify start ждёт READY=1 в $NOTIFY_SOCKET, и юниты с
After=api.service стартуют после него.
Остановка
[Service]
Type=notify
Environment=SHUTDOWN_TIMEOUT=20s
TimeoutStopSec=30s
Restart=on-failure
RestartSec=2s
На stop и restart приходит SIGTERM, код через
signal.NotifyContext перестаёт принимать соединения и доигрывает текущие (статья «Веб-сервисы», глава 10.2). Бюджет
в коде меньше TimeoutStopSec с запасом: сервис, которому нужно 30 секунд при
TimeoutStopSec=5s, получил SIGKILL через 5 секунд. Пока новый процесс греется,
порт закрыт (если его не держит сокет-юнит systemd), поэтому реплики за балансировщиком перезапускают по
очереди.
Go-процесс без обработчика SIGTERM умирает мгновенно: memhog в прогоне
остановился за 5 мс, а systemd записал «Deactivated successfully» и Result=success, потому
что смерть от SIGTERM для него чистый выход. Запросы при этом оборваны. Смотри в
journalctl -u api -f, прошло ли между «got signal» и «bye» время доигрывания.
Что стоит запомнить
- 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, а затем стеки/системные вызовы коротким целевым наблюдением. Низкая занятость устройства не исключает задержку очереди или блокировку на конкретном пути. Замер должен помочь локализовать ожидание до включения тяжёлой трассировки.