1.1Процессы и ядро
Сопоставишь процессы, потоки ОС и горутины, а затем разберёшь память процесса, дескрипторы и сигналы. Это поможет понять, какие ограничения задаёт ядро, а какие - рантайм Go.
Три уровня: процесс, поток, горутина
Процесс даёт изоляцию: собственное виртуальное адресное пространство, своя таблица файловых дескрипторов, свой текущий каталог, свои uid/gid, свои обработчики сигналов. Два процесса по умолчанию не видят память друг друга совсем, для обмена нужен явный механизм (pipe, сокет, разделяемая память, файл).
Поток живёт внутри процесса, и планирует потоки ядро. В Linux и процесс, и поток
сводятся к одной структуре ядра task_struct, созданной системным вызовом
clone() с разным набором флагов: если передать CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD,
получится поток (общая память, общие дескрипторы, тот же PID); без них выйдет процесс. Отсюда деталь
для собеса: Linux не различает процессы и потоки на уровне планировщика, он планирует
задачи; различие только в том, сколько ресурсов эти задачи делят. Потоки одного процесса делят
всё, кроме регистров, стека, errno, TLS, маски сигналов, приоритета и привязки к ядрам.
Горутина вообще не сущность ядра. Ядро о ней не знает. Это структура
runtime.g внутри процесса Go, которую планирует сам рантайм по модели
G–M–P. Переключение горутин не выходит в ядро вообще:
рантайм сохраняет несколько регистров и прыгает на другой стек, поэтому оно в разы дешевле
переключения потоков.
| Ресурс | Между процессами | Между потоками одного процесса | Между горутинами |
|---|---|---|---|
| Адресное пространство (код, куча) | раздельное | общее | общее |
| Стек | свой | свой (8 МиБ виртуально) | свой (от 2 КиБ, растёт) |
| Таблица файловых дескрипторов | своя | общая | общая |
| Обработчики сигналов | свои | общие (маска - своя) | обрабатывает рантайм |
| Кто планирует | ядро | ядро (CFS/EEVDF) | рантайм Go, в user space |
| Стоимость создания | от десятков мкс | единицы мкс | сотни нс |
«Процесс - единица изоляции ресурсов, поток - единица планирования, горутина - единица конкурентности в user space. Переключение процессов дорого не столько из-за сохранения регистров, сколько из-за смены таблицы страниц: TLB и кэши остывают (без PCID/ASID TLB сбрасывается целиком). Потоки одного процесса делят таблицу страниц, поэтому их переключение дешевле. Горутины вообще не трогают ядро.»
Память процесса: сегменты, страницы, page fault
Каждый процесс видит собственное виртуальное адресное пространство. На x86-64 адрес занимает 48 бит, то есть 128 ТиБ на пользовательскую часть; верхняя половина зарезервирована под ядро и из user space недоступна. Виртуальные адреса в физические переводит аппаратный блок MMU по четырёхуровневой таблице страниц, а недавние переводы оседают в TLB. Ядро работает страницами по 4 КиБ (есть huge pages по 2 МиБ).
Классическая раскладка сегментов, снизу вверх:
.textхранит машинный код, отображён read-execute, разделяется между процессами того же бинаря;.rodataдержит константы и строковые литералы, read-only (поэтомуs[0] = 'x'для строки в Go запрещено ещё и физически);- в
.dataлежат инициализированные глобальные переменные, прямо из файла; .bssотдан глобальным нулям: в файле места не занимают, ядро выдаёт обнулённые страницы по требованию;- куча растёт вверх через
brk/sbrk, а крупные куски идут черезmmap; - mmap-область собирает разделяемые библиотеки, файлы, анонимные отображения; рантайм Go держит кучу только в таких анонимных аренах и через brk её не растит;
- стек растёт вниз от верха пользовательской части, ограничен
ulimit -s(обычно 8 МиБ).
fork не копирует память.# карта памяти живого процесса (Go 1.27, linux/arm64; на amd64 код начинается с 00400000)
$ cat /proc/$(pgrep myapp)/maps
00010000-00277000 r-xp 00000000 00:a9 80250 /usr/local/bin/myapp # .text
00280000-0055f000 r--p 00270000 00:a9 80250 /usr/local/bin/myapp # .rodata
...
43fd09c00000-43fd0a400000 rw-p 00000000 00:00 0 # арена кучи Go, адрес случайный с Go 1.26
...
ffffda429000-ffffda44a000 rw-p 00000000 00:00 0 [stack]
$ ps -o pid,vsz,rss,comm -p $(pgrep myapp)
PID VSZ RSS COMMAND
1312 1265524 14396 myapp # VSZ 1.2 ГиБ, RSS 14 МиБ, для Go это норма
$ cat /proc/$(pgrep myapp)/status | grep -E 'VmRSS|Threads'
VmRSS: 14396 kB
Threads: 14 # столько потоков ОС держит рантайм
Linux по умолчанию переобещает память (vm.overcommit_memory=0): mmap
почти всегда успешен, физическая страница выдаётся только при первом обращении. Поэтому «выделить
память» и «получить память» перестают совпадать. Когда физической памяти реально не хватает,
срабатывает OOM killer: он выбирает жертву по oom_score (грубо говоря, кто больше
съел, тот и виноват) и посылает ей SIGKILL. Ни перехватить, ни красиво завершиться
нельзя, и в логе останется только строка в dmesg. Это ровно тот же механизм, что
даёт OOMKilled в Kubernetes, только там лимит задан на cgroup, а не на всю
машину.
Стек и рекурсия: ОС против Go
Поток ОС получает стек фиксированным непрерывным куском виртуальной памяти, обычно 8 МиБ.
В конце стека стоит guard page. Когда рекурсия
доходит до неё, процессор генерирует исключение, ядро превращает его в SIGSEGV,
процесс умирает с «segmentation fault». Никакого «StackOverflowError» в C нет, есть просто
падение. В один кадр рекурсии влезает адрес возврата, сохранённые регистры и локальные переменные,
то есть десятки байт; 8 МиБ хватает примерно на сотни тысяч простых кадров.
В Go всё иначе, и это любимый добивающий вопрос. Стек горутины начинается с 2 КиБ
(с Go 1.19 рантайм может сразу дать больше, если по прошлой сборке мусора стеки в среднем крупнее) и
растёт: в прологе почти каждой функции компилятор вставляет проверку «хватает ли места
до границы стека»; если нет, вызывается morestack, рантайм выделяет вдвое больший
сегмент, копирует туда старый стек и правит указатели на него (стек перемещаемый, поэтому
указатель на локальную переменную в Go всегда корректен: переменная либо убежит в кучу,
либо переедет вместе со стеком). На 64-битных платформах дальше 1 ГБ стек не растёт
(debug.SetMaxStack). На этой границе рантайм не паникует, а падает фатально.
func rec(n int) int { return rec(n+1) } // бесконечная рекурсия
func main() {
defer func() {
// не сработает: stack overflow даёт fatal error рантайма, а не панику
if r := recover(); r != nil { fmt.Println("caught", r) }
}()
rec(0)
}
// runtime: goroutine stack exceeds 1000000000-byte limit
// runtime: sp=0x218af32e0390 stack=[0x218af32e0000, 0x218b132e0000]
// fatal error: stack overflow
«Можно ли поймать переполнение стека через recover?» - Нет.
recover ловит только panic. Переполнение стека, конкурентная запись
в мапу (concurrent map writes), дедлок всех горутин и OOM рантайма дают
fatal error: процесс умирает мгновенно, defer не выполняются.
Второй вопрос-добивка: «почему в Go можно вернуть указатель на локальную переменную?»
Виноват escape-анализ: компилятор увидит, что адрес уходит наружу, и разместит значение в куче.
Системные вызовы
Системный вызов открывает единственную легальную дверь из пользовательского кода в ядро.
На x86-64 программа кладёт номер вызова и аргументы в регистры и выполняет инструкцию syscall;
процессор переключается в нулевое кольцо, ядро выполняет работу и возвращает управление.
Дорого здесь не само переключение режима (это десятки наносекунд), а всё вокруг: проверка
аргументов, возможная блокировка, а с митигацией Meltdown (KPTI)
ещё и смена таблицы страниц на каждом входе и выходе, без PCID со сбросом TLB. Простой вызов вроде
getppid() стоит порядка сотни наносекунд (замер на arm64 без KPTI, ядро 7.0: 130 нс из C,
180 нс из Go). Отсюда общее правило: батчить
(writev вместо десяти write, буферизованный bufio.Writer
вместо посимвольной записи).
Часть «вызовов» ядро вообще отдаёт без входа в себя, через vDSO: так работают clock_gettime и gettimeofday,
поэтому time.Now() в Go стоит наносекунды, а не микросекунды.
ulimit -n.# чем занят процесс: смотрим по системным вызовам
$ strace -p 1312 -f # -f идёт за потоками, для Go обязательно
$ strace -c -f -p 1312 # сводка: сколько вызовов и сколько времени в каждом
$ strace -f -e trace=network ./myapp # только сетевые
$ strace -f -e trace=openat,close ./myapp # ловим утечку дескрипторов
# альтернативы, когда strace слишком дорог (на частых вызовах тормозит процесс в десятки и сотни раз)
$ perf trace -p 1312
$ bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'
Файловые дескрипторы и «too many open files»
Под дескриптором прячется маленькое целое число, индекс в таблице открытых файлов процесса. За ним
стоит структура ядра, и она занимает память ядра. Лимит на количество задаётся ulimit -n
(мягкий и жёсткий). У systemd-юнита по умолчанию мягкий так и остаётся 1024 при жёстком 524288, а для сетевого
сервиса 1024 смешно мало. Программа на Go с версии 1.19 при старте сама поднимает мягкий лимит до жёсткого
(с 1.24 на единицу ниже), так что упирается она в жёсткий. Docker раньше раздавал контейнерам по 1048576, а с Docker Engine 29
(containerd 2) на хостах с systemd контейнеры получают те же 1024 и 524288. Каждое TCP-соединение, каждый открытый файл, каждый epoll
съедает дескриптор, а каждый os.Pipe сразу два.
$ ulimit -n # мягкий лимит текущей сессии
1024
$ ulimit -Hn # жёсткий: выше него без root не поднять
524288
$ cat /proc/1312/limits | grep 'open files' # Go поднял мягкий (с 1.24 до жёсткого минус один)
Max open files 524287 524288 files
$ ls /proc/1312/fd | wc -l # сколько занято прямо сейчас
3008 # и растёт с каждым запросом
$ lsof -p 1312 | awk '{print $5}' | sort | uniq -c | sort -rn
3000 IPv4 # 3000 сокетов, почти наверняка утекают соединения
3 REG
3 CHR
2 a_inode
2 DIR
1 TYPE
1 IPv6
# поднять постоянно
# systemd unit: [Service] LimitNOFILE=65535
# docker: docker run --ulimit nofile=65535:65535
# k8s: поля в Pod API нет, лимит наследуется от рантайма на ноде (containerd)
// Плохо: типовые причины утечки fd в Go
resp, err := http.Get(url)
if err != nil { return err }
// ранний return: тело не прочитано и не закрыто,
// сокет висит в CLOSE_WAIT, в пул не вернётся
if resp.StatusCode != http.StatusOK { return errStatus }
rows, _ := db.Query("SELECT ...")
for rows.Next() { ... }
// rows.Close() не вызван при раннем return:
// соединение не отдано в пул
for _, name := range files {
f, _ := os.Open(name)
process(f) // defer внутри цикла тоже
} // не спасёт: закроется в конце функции
// Хорошо
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()
// дочитать для keep-alive: сам Close с Go 1.27 дочитывает не больше 256 КиБ
defer io.Copy(io.Discard, resp.Body)
rows, err := db.Query("SELECT ...")
if err != nil { return err }
defer rows.Close() // идемпотентен, вызывать всегда
for _, name := range files {
func() { // своя область видимости
f, err := os.Open(name)
if err != nil { return }
defer f.Close()
process(f)
}()
}
Создать &http.Client{} со своим http.Transport внутри функции,
которую зовут на каждый запрос, значит гарантированно потечь (клиент без своего транспорта берёт общий,
по умолчанию, и не течёт): у каждого транспорта свой пул
соединений, старые сокеты не переиспользуются и висят, пока не отвалятся по таймауту.
http.Client потокобезопасен, поэтому делай его один на процесс, настраивай
MaxIdleConnsPerHost (дефолт 2, для одного бэкенда мало) и всегда ставь
Timeout. Симптом в проде: растёт число открытых сокетов (у незакрытых тел ответов они
висят в CLOSE_WAIT), потом «too many open files», потом сервис перестаёт принимать входящие, ведь
accept() тоже нужен дескриптор.
Сигналы
Сигнал приходит процессу асинхронно, от ядра или от другого процесса. У каждого есть номер, действие по умолчанию и признак «перехватываемый».
| Сигнал | № | Кто и когда шлёт | По умолчанию | Перехватить? |
|---|---|---|---|---|
SIGTERM | 15 | kill, docker stop, k8s при удалении пода | завершение | да - это «просьба закрыться» |
SIGINT | 2 | Ctrl+C в терминале | завершение | да |
SIGQUIT | 3 | Ctrl+\ | завершение + core dump | да, но в Go по умолчанию печатает стеки всех горутин |
SIGKILL | 9 | kill -9, OOM killer, k8s после grace period | мгновенная смерть | нет, никогда |
SIGSTOP | 19 | заморозка процесса | остановка | нет |
SIGHUP | 1 | закрытие терминала; по традиции - «перечитай конфиг» | завершение | да |
SIGUSR1/2 | 10/12 | только то, что ты сам придумал | завершение | да - удобно для «сбрось профиль» |
SIGPIPE | 13 | запись в закрытый сокет/pipe | завершение | да; Go игнорирует его для всего, кроме stdout/stderr |
SIGCHLD | 17 | ребёнок завершился | игнорируется | да - сюда вешают wait() против зомби |
// Типовой graceful shutdown на Go 1.16+
func main() {
// NotifyContext отменяет ctx при первом же SIGINT/SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: router}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // приехал SIGTERM
stop() // вернуть дефолтный обработчик: второй Ctrl+C убьёт сразу
log.Println("shutting down")
// даём доработать текущим запросам, но не бесконечно
shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil { // перестаёт принимать новые, ждёт активные
log.Println("forced shutdown:", err)
}
// и только теперь закрываем пул БД, consumer'ы Kafka, флашим трейсы
pool.Close()
tracerProvider.Shutdown(context.Background())
}
Сначала перестать принимать новую работу (HTTP-сервер, consumer брокера), потом дождаться
активной, потом закрыть исходящие ресурсы (пул БД, Redis, продюсеры), и в самом конце
флашнуть телеметрию. Если закрыть пул БД первым, все доигрывающие запросы упадут с ошибкой, и ты
сам создашь всплеск 5xx на каждом деплое. И держи общий бюджет меньше, чем
terminationGracePeriodSeconds в k8s (по умолчанию 30 с), иначе тебя дорежут
SIGKILL.
Убить процесс, jobs, fg, bg
# найти
$ pgrep -a myapp # PID + командная строка
$ ps aux | grep '[m]yapp' # скобки, чтобы grep не нашёл сам себя
$ pidof myapp
# убить, от вежливого к грубому
$ kill 1312 # SIGTERM по умолчанию, вежливо
$ kill -TERM 1312 # то же явно
$ kill -HUP 1312 # «перечитай конфиг»
$ kill -QUIT 1312 # в Go: дамп стеков всех горутин в stderr, спасает при зависании
$ kill -9 1312 # SIGKILL, последнее средство: без flush, без cleanup
# по имени
$ pkill myapp # по имени процесса
$ pkill -f 'myapp --config=prod' # -f матчит всю командную строку (регулярка, осторожнее)
$ pkill -u deploy -f worker # только процессы пользователя deploy
$ killall myapp # точное совпадение имени; на Solaris killall убивает вообще всё
$ systemctl stop myapp # правильный способ, если это systemd-юнит
$ kill -0 1312 # ничего не шлёт, только проверяет: жив ли и есть ли права
# управление задачами в текущем шелле
$ ./myapp & # запустить в фоне
$ jobs -l # список задач шелла с PID
$ fg %1 # вернуть первую задачу на передний план
$ bg %1 # продолжить её в фоне (после Ctrl+Z)
# Ctrl+Z шлёт SIGTSTP (пауза), Ctrl+C SIGINT, Ctrl+\ SIGQUIT
$ nohup ./myapp & # переживёт закрытие терминала (игнорирует SIGHUP)
$ disown -h %1 # то же для уже запущенной задачи
kill -9 плохой рефлекс
SIGKILL обрабатывает ядро, процесс о нём даже не узнаёт: не выполнятся
defer, не закроются транзакции, не сбросятся буферы, не снимутся файлы-локи,
сообщение из брокера останется вычитанным, но неподтверждённым. Если процесс не умирает от
SIGTERM, это баг, который надо чинить, а не обходить девяткой. И главный
случай, когда даже -9 не работает: процесс в состоянии D
(uninterruptible sleep). Он застрял в системном вызове к сломанному диску,
и ждать придётся ядро.
Куда смотреть: логи, порты, нагрузка
# Логи
$ journalctl -u myapp -f # хвост живого юнита
$ journalctl -u myapp --since '10 min ago' -p err # только ошибки за 10 минут
$ journalctl -u myapp -b -1 # логи предыдущей загрузки
$ journalctl --disk-usage && journalctl --vacuum-time=7d
$ tail -f /var/log/syslog # Ubuntu и Debian до 12, на RHEL /var/log/messages
$ dmesg -T | grep -i -E 'oom|killed' # сюда пишет OOM killer, первое место при 137
$ docker logs -f --tail 100 --since 10m app # логи контейнера (это stdout/stderr PID 1)
$ kubectl logs -f deploy/app -c app --tail=100
$ kubectl logs pod/app-xyz --previous # логи упавшего контейнера, главный трюк при CrashLoop
# Кто слушает порт
$ ss -ltnp # listening, tcp, numeric, process, основной инструмент
$ ss -ltnp 'sport = :8080'
$ ss -s # сводка по сокетам: сколько в TIME-WAIT и т.д.
$ ss -tanp state established | wc -l
$ lsof -i :8080 # то же, но lsof есть не везде и он медленнее
$ fuser -k 8080/tcp # убить того, кто занял порт
$ netstat -tulpn # legacy, во многих образах уже нет
# Нагрузка
$ uptime # load average 1/5/15
$ top -H -p 1312 # -H: показать потоки процесса
$ htop # то же, но по-человечески
$ vmstat 1 5 # r (runnable), b (blocked), si/so (swap), wa
$ iostat -x 1 # %util, r_await/w_await: узкое место по диску
$ pidstat -d 1 # ввод-вывод по процессам
$ free -h # смотреть надо на available, а не на free
Load average в Linux не означает «загрузку CPU в процентах». Это экспоненциально сглаженное
за 1/5/15 минут число задач, которые либо выполняются, либо готовы выполняться, либо находятся
в непрерываемом ожидании (state D). Последнее придумали именно в Linux: ожидание диска
тоже поднимает LA. Читается так: делим на число ядер. При nproc = 8 LA 8.0 значит,
что машина занята на всю. LA 24 даёт очередь втрое длиннее числа ядер. А LA 40 при
2 % CPU почти наверняка означает, что сдох диск или сетевое хранилище вроде NFS и все висят в D. Три числа
дают направление: 1.2 8.4 15.9 читается как «пик был и рассасывается»,
15.9 8.4 1.2 как «растёт прямо сейчас».
Свободная память. Классическая паника новичка: free -h показывает 200 МиБ
free при 32 ГиБ ОЗУ. Это нормально: Linux использует всю незанятую память под page cache и буферы, потому что за простаивающую память заплачено впустую. Кэш ядро
отдаёт мгновенно, как только он кому-то понадобится. Смотреть надо на колонку
available: она и показывает, сколько реально можно занять, не уходя в swap. Тревожно,
когда available близок к нулю, или когда в vmstat ненулевые si/so
(идёт свопинг).
iowait (%wa в top) считает долю времени, когда CPU простаивал и при этом
ждал ввода-вывода хотя бы один процесс. Высокий iowait означает «процессор свободен,
но работа упирается в диск». Ожидание ответа по сети (медленная удалённая БД) iowait не даёт: такие процессы спят в состоянии S. Метрика лукавая: если параллельно есть счётный процесс,
занимающий CPU, iowait упадёт до нуля, хотя диск так же тормозит. Поэтому подтверждать надо
через iostat -x (r_await/w_await, %util) и число процессов в
состоянии D.
Зомби (Z в ps, «defunct») остаётся от процесса, который уже завершился,
а родитель не вызвал wait()/waitpid() и не забрал код возврата. Ядро
держит запись в таблице процессов ради этого кода: памяти зомби не занимает, но занимает PID.
Убить зомби нельзя, он уже мёртв: kill -9 на него не действует. Помогает либо
починить родителя, либо убить его (тогда зомби усыновляет init/PID 1
и немедленно «пожинает»). В Go зомби появляются, если ты запускаешь подпроцессы через
exec.Command и не вызываешь cmd.Wait(). Отдельный контейнерный случай:
твоё приложение стало PID 1 и не жнёт сирот, так что зомби копятся, пока не кончатся PID;
лечится флагом docker run --init (tini) или процессом-надзирателем.
cgroups и namespaces - на чём стоят контейнеры
Что физически представляет собой контейнер? Ответ короткий и для многих неожиданный: обычный процесс Linux. Никакой отдельной сущности «контейнер» в ядре нет; есть два независимых механизма, которыми процессу подрезают обзор и аппетит. Путают их на собесе постоянно.
Namespace показывает процессу урезанную версию реальности. У ядра есть глобальные списки: все процессы, все сетевые интерфейсы, все точки монтирования. Namespace создаёт для процесса отдельный такой список, и процесс видит только его. Бытовая аналогия: комната с односторонним стеклом, где ты видишь только то, что внутри, и искренне считаешь, что это весь мир. Аналогия ломается в одном месте: стены тут не защитные. Ядро у всех общее, и дыра в нём видна из любой комнаты.
Cgroup, наоборот, занимается счётчиками. Процессы объединяются в группу, и ядро для всей группы считает потраченный 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(мягкий: за ним ядро начинает троттлинг - заставляет процесс освобождать память и тормозит его, но не убивает),pids.max,io.max.
Контейнер = обычный процесс Linux + набор namespaces + cgroup + корневая ФС из образа
(pivot_root поверх OverlayFS) + урезанные capabilities + seccomp-профиль. Никакой
«виртуализации» тут нет: ядро одно, общее с хостом.
systemd vs docker. systemd тоже управляет cgroups: каждый юнит живёт в своей группе,
через [Service] задаются MemoryMax=, CPUQuota=,
LimitNOFILE=, Restart=on-failure, а логи со stdout уходят в journald.
Разница в том, что systemd управляет процессами на этой машине с её файловой системой,
а docker добавляет к тому же ядру упакованную неизменяемую ФС (образ) и сетевую изоляцию.
Для одиночного сервиса на VM systemd-юнит подходит не хуже, а возни с ним обычно меньше.
# посмотреть лимиты cgroup v2 изнутри контейнера
$ cat /sys/fs/cgroup/cpu.max
200000 100000 # 200 мс CPU на каждые 100 мс = 2 ядра
$ cat /sys/fs/cgroup/memory.max
536870912 # 512 МиБ, к этому и привязывают GOMEMLIMIT
$ cat /sys/fs/cgroup/memory.current
412876800
# какие namespaces у процесса
$ ls -l /proc/1312/ns
lrwxrwxrwx 1 root root 0 Sep 16 21:27 net -> 'net:[4026534208]'
lrwxrwxrwx 1 root root 0 Sep 16 21:27 pid -> 'pid:[4026534206]'
# зайти в сетевой namespace контейнера с хоста
$ nsenter -t $(docker inspect -f '{{.State.Pid}}' app) -n ss -ltnp
nproc внутри контейнера врёт
Namespaces не изолируют /proc/cpuinfo и sysconf(_SC_NPROCESSORS_ONLN):
процесс внутри контейнера видит все ядра хоста, даже если его cgroup даёт ему полядра.
Именно отсюда растёт вся боль с GOMAXPROCS в Kubernetes: до Go 1.25 рантайм ставил
GOMAXPROCS = runtime.NumCPU(), то есть 64 на 64-ядерной ноде, при лимите в 1 ядро,
и сервис получал жестокий CFS-троттлинг. Разбираем это в статье «Docker», глава 2.1.
SSH по ключу на свежий сервер
# 1. Локально: сгенерировать пару (ed25519 короче и быстрее RSA, дефолт с OpenSSH 9.5)
$ ssh-keygen -t ed25519 -C "german@laptop" -f ~/.ssh/id_ed25519
# приватный ключ с парольной фразой; публичный в ~/.ssh/id_ed25519.pub
# 2. Положить публичный ключ на сервер
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@1.2.3.4
# руками почти то же самое (ssh-copy-id ещё ставит umask 077 и не добавляет ключ повторно):
$ cat ~/.ssh/id_ed25519.pub | ssh deploy@1.2.3.4 \
'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'
# 3. Права: sshd молча откажет, если каталог или файл доступны на запись другим
$ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
$ chown -R deploy:deploy ~/.ssh
# 4. Проверить, не закрывая текущую сессию
$ ssh -i ~/.ssh/id_ed25519 deploy@1.2.3.4 'echo ok'
$ ssh -vvv deploy@1.2.3.4 # если не пускает, здесь видно, какой ключ был предложен
# 5. Закрутить сервер: /etc/ssh/sshd_config.d/00-hardening.conf
# (sshd берёт первое значение, а sshd_config в Debian, Ubuntu и RHEL первым делом
# подключает sshd_config.d/*.conf; если там лежит 50-cloud-init.conf с
# PasswordAuthentication yes, строка в самом sshd_config ему проиграет)
# PasswordAuthentication no
# PermitRootLogin no
# PubkeyAuthentication yes
$ sudo sshd -t && sudo systemctl reload ssh # -t: проверить конфиг до перезапуска; в RHEL юнит sshd
$ sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication) ' # что sshd применил на деле
# 6. Удобство на клиенте: ~/.ssh/config
# Host prod
# HostName 1.2.3.4
# User deploy
# IdentityFile ~/.ssh/id_ed25519
# ProxyJump bastion # через бастион
$ ssh prod
Три вещи, которые отличают ответ мидла от джуна: (1) не закрывай текущую сессию, пока не
проверил вход новой, иначе останешься за дверью; (2) sshd -t перед
reload; (3) права на ~/.ssh, из-за которых чаще всего и выходит «ключ
не работает, а всё вроде правильно»: sshd отказывает молча, и видно это только на сервере, в
journalctl -u ssh. Плюс: ключ для CI заводи отдельный, без парольной фразы,
с ограничениями в authorized_keys (command=, no-pty).
Вопросы
12Память
У процесса своё виртуальное адресное пространство, своя таблица файловых дескрипторов,
свой cwd, свои uid/gid и свои обработчики сигналов. Потоки одного процесса делят абсолютно
всё это: код, кучу, глобальные переменные, дескрипторы. Потоку принадлежат только стек
(обычно 8 МиБ виртуально, по ulimit -s), регистры, TLS, маска сигналов,
приоритет и привязка к ядрам.
Отсюда и разница в модели программирования: два процесса нужно связывать через IPC,
а два потока уже видят одну память, и потому им нужны мьютексы.
В Linux и то и другое живёт как одна структура task_struct, созданная
clone() с разными флагами. С CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD
получается поток (тот же PID), без них процесс. Планировщик их не различает.
Переключение контекста
Прямая стоимость переключения сводится к сохранению и восстановлению регистров и работе
планировщика, это десятые доли микросекунды. Но главная цена косвенная. При переключении
между процессами меняется таблица страниц (CR3), TLB и кэши L1/L2 остывают
(без PCID/ASID TLB сбрасывается целиком), и следующие сотни обращений к памяти будут медленными.
Между потоками одного процесса таблица страниц та же, поэтому TLB выживает и переключение
дешевле. Плюс само создание: fork() стоит от десятков микросекунд до миллисекунд
(растёт с памятью процесса), clone() потока единицы микросекунд.
Где здесь горутина
Горутина живёт в куче процесса Go как структура runtime.g. Планировщик Go работает
по схеме G–M–P: G это горутина, M поток ОС, P логический процессор
(их GOMAXPROCS, по умолчанию по числу доступных ядер). Рантайм мультиплексирует
тысячи G на десяток M. Переключить горутину значит сменить несколько регистров и перейти
на другой стек: без входа в ядро, 50–200 нс. Стек начинается с 2 КиБ (с Go 1.19 рантайм
может сразу дать больше, по среднему размеру стеков на прошлой сборке мусора) и растёт
копированием, а не резервируется на 8 МиБ. Поэтому миллион горутин живёт спокойно,
а миллион потоков нет: только под стеки ушло бы 8 ТиБ виртуальной памяти, и планировщик
ядра захлебнулся бы.
«Горутина не бесплатна: это от 2 КиБ стека плюс структура g, и обходится она
не дешевле потока, если внутри неё сидит блокирующий системный вызов: тогда
рантайм отдаёт P другому потоку, а этот M висит в ядре. Число потоков процесса Go
видно в /proc/PID/status в поле Threads, и если оно растёт
до сотен, значит, где-то много блокирующих syscall или cgo-вызовов.
Жёсткий предел стоит на 10 000 потоков (runtime/debug.SetMaxThreads).»
Сегменты
Снизу вверх: .text (код, r-x), .rodata (константы, r--),
.data (инициализированные глобалы), .bss (глобальные нули,
в файле места не занимают), куча (растёт вверх), область mmap
(библиотеки, файлы, крупные аллокации, арены Go), стек (растёт вниз от верхней границы
пользовательской части). Живьём это видно через cat /proc/PID/maps.
Виртуальная память
Адрес, которым оперирует программа, - виртуальный. Перевод в физический делает MMU по
четырёхуровневой таблице страниц; результаты кэшируются в TLB. Единица - страница 4 КиБ.
Это даёт три вещи: изоляцию (чужие страницы просто не отображены), возможность дать процессу
больше памяти, чем есть физически, и разделение общих страниц (один .text
libc на все процессы).
Как ОС управляет
- Ленивое выделение.
mmapтолько резервирует диапазон. Физическую страницу выдают при первом обращении, и это minor page fault (микросекунда). - Major page fault ловим, когда нужной страницы в RAM нет: читаем с диска или из swap: десятки микросекунд на SSD, миллисекунды на HDD.
- Copy-on-write. При
fork()копируются не данные, а таблицы страниц; все страницы становятся read-only, и физическое копирование происходит постранично при первой записи. - Page cache. Вся свободная память уходит под кэш файлов и отдаётся мгновенно, когда нужна.
- Overcommit и OOM killer. Ядро обещает больше, чем имеет; когда память кончилась
реально, выбирается жертва по
oom_scoreи получаетSIGKILL.
Потому что смотрят на VSZ. VSZ показывает, сколько виртуального пространства
зарезервировано, а рантайм Go заранее резервирует адреса под кучу и свои служебные
структуры, и это ничего не стоит.
Реальное потребление видно в RSS (VmRSS в /proc/PID/status),
а в контейнере правильнее container_memory_working_set_bytes: именно по нему
kubelet решает, кого вытеснить с ноды (сам OOMKill по лимиту делает ядро). И RSS у Go не всегда падает
сразу после GC: память возвращается ядру через MADV_FREE/MADV_DONTNEED
с задержкой, и на графике это выглядит как «утечка», которой нет.
SIGSEGV; у горутины стек начинается от 2 КиБ и растёт копированием
до 1 ГБ, а на пределе рантайм падает с fatal error: stack overflow,
которую нельзя поймать через recover.Как это устроено в ОС
Каждый вызов функции кладёт на стек кадр: адрес возврата, сохранённые регистры, локальные
переменные и аргументы. Стек растёт вниз, к меньшим адресам. Снизу стоит guard page -
страница без прав доступа. Когда рекурсия дотягивается до неё, MMU генерирует исключение,
ядро превращает его в SIGSEGV, и процесс умирает с «segmentation fault».
Размер стека потока фиксируется при создании (ulimit -s, обычно 8 МиБ) и
не меняется, потому что стек должен быть непрерывным куском адресов, а расти ему некуда:
соседние адреса уже могут быть заняты.
Как это в Go
Стек горутины «сегментированный» только исторически; с Go 1.3 он перемещаемый.
Компилятор вставляет в пролог почти каждой функции проверку границы стека; при нехватке
вызывается morestack: рантайм выделяет вдвое больший кусок, копирует старый
стек и корректирует все указатели внутрь него. Возвращать указатель на локальную
переменную в Go безопасно по другой причине: escape-анализ это видит и размещает её в куче.
А указатели на стек внутри самой горутины при копировании переедут вместе со стеком.
func rec(n int) int { return rec(n + 1) }
func main() {
defer func() { recover() }() // не поможет
rec(0)
}
// runtime: goroutine stack exceeds 1000000000-byte limit
// fatal error: stack overflow
Практические следствия
- Глубокая рекурсия в Go «стоит» кучу мелких
morestackи копирований - обход глубокого дерева лучше переписать на явный стек-слайс. - Крупные локальные массивы (
var buf [64 << 10]byte) тоже вызывают рост стека и его копирование (больше 128 КиБ компилятор с Go 1.24 сам уносит в кучу); лучшеmakeв кучу илиsync.Pool. - Предел меняется через
debug.SetMaxStack, но если ты об этом задумался, скорее всего, у тебя бесконечная рекурсия.
«Поймается ли recover-ом переполнение стека?» - нет. Не ловятся recover-ом:
stack overflow, concurrent map writes, all goroutines are
asleep - deadlock!, out of memory рантайма и любой fatal error.
Это не паника, а фатальная ошибка: defer-ы не выполняются, процесс умирает
сразу.
strace -f, для сводки - strace -c.Механика
На x86-64 программа кладёт номер вызова и аргументы в регистры и выполняет syscall.
Процессор переключается из третьего кольца в нулевое, ядро проверяет аргументы, делает
работу (возможно, блокируется), возвращает результат в регистре. Стоимость «пустого»
вызова вроде getpid() порядка сотни наносекунд (замер на arm64 без KPTI,
ядро 7.0: 120–180 нс); с митигацией Meltdown дороже, KPTI меняет таблицу страниц на каждом
входе в ядро и выходе, а без PCID ещё и сбрасывает TLB. Плюс после возврата холодные кэши.
Часть вызовов ядро отдаёт бесплатно через vDSO: clock_gettime, gettimeofday. Поэтому
time.Now() в Go стоит десятки наносекунд, а не микросекунду.
Как Go с этим живёт
Перед блокирующим вызовом рантайм делает entersyscall; если вызов затянулся
(от 20 мкс, когда P ждёт работа, иначе до 10 мс), фоновый поток sysmon отбирает
у застрявшего M его P и отдаёт другому потоку - планировщик не останавливается. Для сети всё ещё лучше: сокеты
в Go всегда неблокирующие, ожиданием заведует netpoller поверх epoll,
так что десять тысяч соединений обслуживаются несколькими потоками.
Инструменты
$ strace -f -p 1312 # -f обязателен: Go многопоточный
$ strace -c -f -p 1312 # сводка: топ вызовов по времени и количеству
$ strace -f -e trace=openat,close ./app # ловим утечку дескрипторов
$ perf trace -p 1312 # дешевле strace
$ bpftrace -e 'tracepoint:syscalls:sys_enter_write { @[comm]=count(); }'
strace работает через ptrace и останавливает процесс на каждом
вызове; если вызовы частые, программа замедляется в десятки и сотни раз. На проде это уже инцидент сам по себе; там правильнее
perf/eBPF. Раз syscall дорог, его батчат.
bufio.Writer вместо голого Write, writev вместо
серии записей, чтение большими блоками. Классический профиль «90 % времени в
write» почти всегда означает, что кто-то пишет в лог по одной строке
без буфера.
ulimit -n (часто 1024 по умолчанию, но Go с 1.19 сам поднимает мягкий лимит почти до
жёсткого). В Go чаще всего это незакрытый
resp.Body, незакрытые rows или новый http.Transport
на каждый запрос.Что это
Дескриптор это маленькое целое, индекс в таблице процесса. За ним в ядре стоит
struct file со смещением, флагами и счётчиком ссылок, а за ней уже inode,
сокет, pipe, eventfd или epoll. 0/1/2 заняты под stdin/stdout/stderr. В Unix «всё есть файл»,
поэтому и TCP-соединение это fd, и epoll-инстанс тоже fd.
Диагностика
$ ulimit -n; ulimit -Hn # мягкий и жёсткий лимиты
$ cat /proc/1312/limits | grep 'open files'
$ ls /proc/1312/fd | wc -l # сколько занято сейчас
$ lsof -p 1312 | awk '{print $5}' | sort | uniq -c | sort -rn
$ ss -tanp | grep CLOSE-WAIT | wc -l # верный признак незакрытых тел ответов
Типовые причины в Go
- Не закрыт
resp.Body. Даже если тело не нужно, закрывать обязательно, и лучше дочитать (io.Copy(io.Discard, resp.Body)), иначе соединение не вернётся в keep-alive-пул (с Go 1.27 закрытие само дочитывает до 256 КиБ). - Не закрыт
rowsизdatabase/sqlпри раннемreturn. Соединение не возвращается в пул, пул пустеет, приложение висит. - Новый
http.Transport(илиhttp.Clientсо своим транспортом) на каждый запрос. У каждого транспорта свой пул, старые сокеты копятся; клиент без своего транспорта берёт общий, по умолчанию, и не течёт. defer f.Close()внутри цикла - закроется только в конце функции.- Отсутствие таймаутов: подвисшие соединения держат fd, пока их не прибьёт TCP.
- Реально высокая нагрузка: 5000 одновременных соединений при жёстком лимите 4096 - лимит и правда надо поднять (мягкий Go поднимает почти до жёсткого сам).
Как поднять лимит
# systemd unit
[Service]
LimitNOFILE=65535
# docker
docker run --ulimit nofile=65535:65535 app
# /etc/security/limits.conf (для интерактивных сессий)
deploy soft nofile 65535
deploy hard nofile 65535
«Сначала я смотрю, утечка это или честная нагрузка: ls /proc/PID/fd | wc -l
в динамике и разбивка по типам через lsof. Если растёт монотонно и не падает,
это утечка: ищу незакрытые тела и rows. Много CLOSE_WAIT
значит, что не закрываем мы. А на честной нагрузке поднимаю
LimitNOFILE и заодно проверяю MaxIdleConnsPerHost.
Поднять лимит первым делом, не разобравшись, значит отложить падение на пару часов.»
SIGTERM - вежливая просьба закрыться, её нужно
перехватывать и делать graceful shutdown; SIGKILL и SIGSTOP
перехватить нельзя в принципе - их обрабатывает ядро.Кто что значит
SIGTERM(15) значит «заверши работу». Шлютkill,docker stop, kubelet при удалении пода, systemd приsystemctl stop. Перехватывается, и именно на нём строится graceful shutdown.SIGINT(2), он же Ctrl+C. Семантически то же самое; в Go обрабатывают вместе с SIGTERM.SIGKILL(9) убивает мгновенно, обработчик поставить нельзя. Шлютkill -9, OOM killer и kubelet после истечения grace period.SIGQUIT(3) приходит по Ctrl+\. В Go по умолчанию печатает стеки всех горутин и завершает процесс с кодом 2: бесплатный способ понять, где всё зависло (GOTRACEBACK=allдля этого не нужен).SIGHUP(1) исторически означал «терминал закрыт», а по традиции демонов «перечитай конфиг». Стандарта нет, это соглашение.SIGSTOP/SIGCONTзамораживают и размораживают;SIGSTOPтоже неперехватываемый.SIGPIPE(13) прилетает при записи в закрытый сокет. Go его игнорирует для всех дескрипторов, кроме stdout/stderr, и превращает в ошибкуEPIPE.
Как правильно реагировать
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
stop() // второй сигнал теперь убьёт немедленно, так и надо
shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
_ = srv.Shutdown(shCtx) // перестать принимать новые, доиграть текущие
consumer.Close() // потом входящие из брокера
pool.Close() // и только потом исходящие ресурсы
Порядок важен: сначала закрываем входящие источники работы, потом ждём активную работу,
потом закрываем исходящие ресурсы, последними флашим трейсы и метрики. Общий бюджет
должен быть меньше, чем terminationGracePeriodSeconds (в k8s по умолчанию 30 с),
иначе получишь SIGKILL посреди работы.
Процесс с PID 1 не имеет обработчиков сигналов по умолчанию: ядро специально не
применяет к нему default action для перехватываемых сигналов. Если приложение не поставило
свой обработчик SIGTERM, оно его просто проигнорирует, docker stop подождёт
10 секунд и пришлёт SIGKILL. Go-бинаря это не касается: обработчики ставит сам рантайм, и даже
без перехвата в коде процесс сразу выходит (с Go 1.27 с кодом 143, раньше с кодом 2), только
без graceful shutdown. Второй вариант той же беды: ENTRYPOINT в shell-форме
превращается в /bin/sh -c "app", и PID 1 становится sh, который
сигнал никому не передаёт (так в Debian и Ubuntu, где sh - это dash; busybox sh в Alpine
единственную команду запускает через exec, и PID 1 достаётся приложению). Лечится exec-формой ENTRYPOINT ["/app"] или
--init/tini.
Ровно два: SIGKILL (9) и SIGSTOP (19). Их нельзя ни поймать,
ни заблокировать, ни проигнорировать. Всё остальное - можно.
pkill/killall по имени,
kill PID после pgrep, а для сервиса -
systemctl stop. По умолчанию все шлют SIGTERM, и это правильно.$ pgrep -a myapp # найти: PID + командная строка
$ kill $(pgrep myapp) # классика
$ pkill myapp # по имени процесса, но по подстроке: заденет и myapp-worker
$ pkill -f 'myapp --config=prod' # -f: матчить всю командную строку
$ pkill -u deploy -f worker # сузить по пользователю
$ killall myapp # точное имя; на Solaris killall убивает вообще всё
$ systemctl stop myapp # правильный способ для systemd-юнита
$ kill -QUIT $(pgrep myapp) # Go: дамп стеков всех горутин перед смертью
$ kill -9 $(pgrep myapp) # последнее средство
Чем отличаются
killработает по PID и ничего не знает про имена.pkillматчит имя регуляркой;-fрасширяет матч на всю командную строку. Опасен:pkill -f goубьёт больше, чем ты хотел, поэтому всегда сначалаpgrep -afс тем же паттерном.killallтребует точного совпадения имени процесса, без регулярок.systemctl stopубивает не процесс, а всю cgroup юнита - вместе с детьми, что важно, если приложение форкает воркеров.
jobs / fg / bg
Это управление задачами внутри одного шелла. ./app & запускает в фоне,
jobs -l показывает список с номерами и PID, fg %1 возвращает задачу
на передний план, bg %1 продолжает её в фоне. Ctrl+Z шлёт
SIGTSTP (пауза), Ctrl+C - SIGINT, Ctrl+\ - SIGQUIT.
Но фоновая задача остаётся ребёнком шелла, и при закрытии терминала получит
SIGHUP. Чтобы выжила, есть nohup ./app &, disown -h %1
или setsid; а по-хорошему долгоживущее отправляют в systemd-юнит или в
tmux/screen.
journalctl -u, классика -
/var/log/* и dmesg, контейнеры - docker logs /
kubectl logs (это просто stdout/stderr PID 1). Порт - ss -ltnp.Логи
$ journalctl -u myapp -f # хвост
$ journalctl -u myapp --since '10 min ago' -p err # только ошибки (голый stderr журнал пишет как info)
$ journalctl -u myapp -b -1 # прошлая загрузка
$ journalctl -k | grep -i oom # сообщения ядра
$ dmesg -T | grep -i -E 'oom|killed process' # кто был убит OOM killer
$ tail -f /var/log/syslog # Ubuntu и Debian до 12; RHEL: /var/log/messages
$ docker logs -f --tail 100 --since 10m app
$ kubectl logs -f deploy/app --tail=100
$ kubectl logs pod/app-xyz --previous # логи предыдущего, упавшего контейнера
Про контейнеры: у контейнера нет «файла лога». Приложение обязано
писать в stdout/stderr, а runtime сам складывает это в файл на ноде (у Docker по умолчанию
json-file, у kubelet свой текстовый формат CRI) и отдаёт по
docker logs/kubectl logs. Писать логи в файл внутри контейнера -
антипаттерн: их никто не соберёт, а диск ноды они забьют.
Кто слушает порт
$ ss -ltnp # l listening, t tcp, n numeric, p process
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 4096 *:8080 *:* users:(("myapp",pid=1312,fd=4))
$ ss -ltnp 'sport = :8080'
$ ss -Htanp state established | wc -l # сколько активных соединений (-H: без заголовка)
$ ss -s # сводка, в т.ч. сколько в TIME-WAIT
$ lsof -i :8080 # альтернатива; медленнее, есть не везде
$ fuser -k 8080/tcp # убить того, кто держит порт (шлёт SIGKILL, не SIGTERM)
$ netstat -tulpn # legacy, в slim-образах отсутствует
ss с хоста не увидит сокеты внутри контейнера: у него свой сетевой namespace.
Зайти внутрь помогает nsenter -t $(docker inspect -f '{{.State.Pid}}' app) -n ss -ltnp.
А в distroless-образе ss просто нет, там спасают
kubectl debug с ephemeral-контейнером.
available.Что читать в top
- строка
%Cpu(s):us(user),sy(kernel),wa(iowait),st(steal - время отбирает гипервизор, верный признак «шумного соседа» на облачной VM); RESэто резидентная память (реальная),VIRTвиртуальная (у Go всегда огромна, это норма),SHRразделяемая;- состояние
S:Rвыполняется,Sспит,Dждёт ввода-вывода непрерываемо (диск!),Zзомби; top -H -p PIDпокажет потоки: видно, сколько их наплодил рантайм Go.
Load average
Три числа - экспоненциально сглаженные средние за 1, 5 и 15 минут. В Linux (в отличие от
классического Unix) в счёт идут и runnable-задачи, и задачи в состоянии
D. Поэтому LA растёт и от нехватки CPU, и от тормозящего диска или NFS.
- Нормировать на ядра:
nproc = 8, LA 8.0 означает загрузку ровно на 100 %. - LA 24 при 8 ядрах даёт очередь втрое длиннее, чем можно обслужить, и задержки растут.
- LA 40 при
%usоколо нуля и высоком%waупирается в диск или NFS, а не в CPU: все висят вD. - Динамика:
1.2 8.4 15.9значит, пик прошёл;15.9 8.4 1.2- растёт прямо сейчас.
Память и кэши
$ free -h
total used free shared buff/cache available
Mem: 31Gi 9.1Gi 412Mi 120Mi 21Gi 21Gi
# ^^^^^ пугает ^^^^ вот что важно
Незанятая память - потраченные впустую деньги, поэтому Linux заполняет её page cache. Кэш отдаётся мгновенно по первому требованию, и колонка
free почти всегда маленькая, это нормально. Реальный показатель тут
available: сколько можно занять, не уходя в swap. Тревожно, когда
available близок к нулю или когда в vmstat 1 ненулевые
si/so: начался свопинг, и задержки вырастут на порядки.
Внутри контейнера top и free показывают хост, а не cgroup:
эти данные берутся из /proc/meminfo, который namespace не изолирует. Реальные
цифры лежат в /sys/fs/cgroup/memory.current и memory.max.
Отсюда же известный парадокс: free в поде говорит «свободно 20 ГиБ»,
а контейнер уходит в OOMKilled на 512 МиБ.
wait(); убить его нельзя, лечится через родителя.iowait
Формально %wa - idle-время процессора, в течение которого была хотя бы одна
задача, заблокированная на вводе-выводе. Высокий iowait означает: «CPU свободен, упираемся
в диск или сетевое хранилище вроде NFS». Метрика лукавая по двум причинам. Во-первых, если параллельно крутится
счётная задача, iowait упадёт до нуля, хотя диск тормозит так же. Во-вторых, она усреднена
по ядрам. Поэтому подтверждать надо через iostat -x 1
(r_await/w_await показывают задержку запроса, %util занятость устройства) и через
число процессов в состоянии D в ps aux. В Go-сервисе высокий
iowait обычно означает не «мой код медленный», а «диск под нами тормозит»; медленная
удалённая БД iowait не даёт, горутины ждут её в netpoller.
Зомби
Когда процесс завершается, ядро освобождает его память и дескрипторы, но оставляет запись
в таблице процессов с кодом возврата, вдруг родителю интересно. Родитель обязан вызвать
wait()/waitpid() и «пожать» ребёнка. Пока он этого не сделал,
ребёнок висит в состоянии Z (defunct). Зомби не занимает ни памяти, ни CPU,
зато занимает PID, а PID конечны (/proc/sys/kernel/pid_max).
kill -9на зомби бесполезен: он уже мёртв.- Чинить надо родителя: заставить его сделать
wait()(например, приславSIGCHLD) или убить, тогда зомби усыновит PID 1 и немедленно пожнёт. - В Go: если ты запустил
exec.Commandи не вызвалcmd.Wait()(или использовалStartбезWait), получишь зомби.cmd.Run()делаетStart+Waitсам. - В контейнере твоё приложение работает как PID 1 и обязано жать осиротевшие процессы.
Обычные приложения этого не умеют, поэтому есть
docker run --init(tini) иshareProcessNamespaceв k8s.
$ ps aux | awk '$8 ~ /^Z/ {print}' # найти зомби
$ ps -o ppid= -p 4711 # кто родитель, его и чинить
$ ps -eo state | grep -c D # сколько застряло в непрерываемом ожидании
Сирота - живой процесс, чей родитель умер; его усыновляет PID 1, и он спокойно работает дальше. Зомби, наоборот, мёртвый процесс, чей живой родитель не забрал код возврата. Путая их, ты сразу показываешь, что тему знаешь по верхам.
Namespaces - изоляция видимости
pidдаёт свою нумерацию: приложение становится PID 1 и не видит чужих процессов;- у
netсвои интерфейсы, свои порты, свои правила iptables (поэтому два контейнера могут оба слушать 8080); mntдержит своё дерево монтирования (сюда подставляется ФС образа);utsотвечает за hostname,ipcза разделяемую память;userмаппит uid: root внутри может быть непривилегированным снаружи;cgroupиtimeзадают вид на иерархию cgroups и на часы.
Cgroups (v2) - ограничение ресурсов
cpu.maxзадаёт квоту и период, например200000 100000= 2 ядра; превышение даёт троттлинг (задачу останавливают до конца периода);memory.maxставит жёсткий потолок, за которым OOM внутри группы;memory.highмягкий, включает агрессивный reclaim;pids.max,io.max,io.weight.
Контейнер собирается из этого плюс pivot_root на OverlayFS, урезанные
capabilities (нет CAP_SYS_ADMIN и т. д.) и seccomp-профиль,
запрещающий опасные системные вызовы. Именно поэтому контейнер стартует за доли секунды:
это просто clone() с флагами, а не загрузка ОС.
systemd vs docker
systemd тоже управляет cgroups: каждый юнит живёт в своей группе, и через
[Service] задаются MemoryMax=, CPUQuota=,
LimitNOFILE=, политика перезапуска Restart=on-failure, а stdout
уходит в journald. Он умеет и часть namespaces (PrivateTmp=,
ProtectSystem=). Разница в другом: systemd обычно запускает бинарь в файловой
системе этой машины, и зависимости ставишь на неё сам (запускать сервис из образа
он тоже умеет, но так делают редко), а docker приносит с
собой неизменяемый образ со всей ФС и полную сетевую изоляцию. Для одиночного Go-сервиса
на выделенной VM systemd-юнит - вполне взрослый и более простой выбор; контейнеры выигрывают,
когда нужны одинаковые артефакты между окружениями и оркестрация.
«Namespaces не изолируют /proc/cpuinfo и /proc/meminfo. Процесс
внутри контейнера видит все ядра и всю память хоста, хотя его cgroup даёт ему полядра
и 512 МиБ. Отсюда исторические проблемы с GOMAXPROCS (лечится
automaxprocs, а с Go 1.25 рантайм читает лимит cgroup сам) и с JVM,
где эту же проблему закрыли флагом UseContainerSupport.»
~/.ssh/authorized_keys нужного пользователя, закрыть их правами 700 и 600,
проверить вход второй сессией и только потом выключить парольную аутентификацию.$ ssh-keygen -t ed25519 -C 'german@laptop' # приватный с парольной фразой
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@1.2.3.4
$ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys # на сервере
$ ssh -i ~/.ssh/id_ed25519 deploy@1.2.3.4 'echo ok'
# /etc/ssh/sshd_config.d/00-hardening.conf: PasswordAuthentication no, PermitRootLogin no
$ sudo sshd -t && sudo systemctl reload ssh # в RHEL юнит sshd
Что здесь важно проговорить
- ed25519, а не RSA: короче, быстрее, современная кривая. RSA берут, только если сервер древний, и тогда минимум 4096 бит.
- Права. При
StrictModes yessshd молча откажет, если домашний каталог,~/.sshилиauthorized_keysдоступны на запись группе или всем (Debian и Ubuntu прощают запись группе, если в ней нет никого, кроме владельца). Поэтому и ставят 700 и 600 - с запасом. Это самая частая причина «ключ не работает». Смотриssh -vvvна клиенте иjournalctl -u sshна сервере. - Не закрывай текущую сессию, пока новым терминалом не проверил вход по ключу.
sshd -tперед reload. Синтаксическая ошибка в конфиге приrestartоставит тебя снаружи.- Отдельный пользователь под деплой, root-логин выключен, sudo выдан по правилам.
- Ключ для CI держи отдельно, без парольной фразы, с ограничениями прямо в
authorized_keys:command="/usr/local/bin/deploy",no-pty,no-port-forwarding. ~/.ssh/configс алиасами иProxyJump bastionпригождается каждый день;ssh-agentизбавляет от постоянного ввода фразы.
Раздавать ключи руками не масштабируется. Дальше authorized_keys
раскладывает Ansible или конфиг-менеджмент, потом идут SSH-сертификаты
(короткоживущие, подписанные CA, отзывать ключи при увольнении не надо), потом
bastion с записью сессий. На собесе достаточно упомянуть, что ты знаешь про этот путь.
Что стоит запомнить
- Процесс изолирует адресное пространство, а потоки процесса делят его ресурсы.
- Ядро планирует потоки; горутины планирует рантайм Go поверх потоков.
- Файловый дескриптор принадлежит процессу и ссылается на открытый объект ядра.
Частые ошибки
- Не считай одну горутину отдельным потоком ОС.
- Большой VSZ сам по себе не означает, что процесс занял столько физической памяти.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери снимок процесса
Прочитай вывод двух команд. Сколько процессов, потоков ОС и горутин здесь подтверждено? Какие выводы по памяти можно сделать, а какие нельзя?
$ ps -o pid,vsz,rss,comm -p 1312
PID VSZ RSS COMMAND
1312 1265524 14396 myapp
$ grep -E 'VmRSS|Threads' /proc/1312/status
VmRSS: 14396 kB
Threads: 14
Ответ и разбор
В выводе подтверждён один процесс с PID 1312 и четырнадцатью потоками ОС. Число горутин отсюда узнать нельзя: его знает рантайм Go, например через метрики или отладочные средства. VSZ около 1,2 ГиБ - виртуальное адресное пространство, RSS около 14 МиБ - резидентная память на момент снимка. По одному снимку нельзя заключить, что процесс утёк по памяти или что вся зарезервированная область физически занята.
Код-ревью
2. Проверь обработку сигнала
Сервис должен завершаться по SIGTERM в Kubernetes и systemd. Найди проблему в обработчике и предложи исправление.
func main() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM)
<-ch
os.Exit(0)
}
Ответ и разбор
Код получает SIGTERM, но не останавливает HTTP-сервер, не ждёт текущие запросы и обходит defer через os.Exit. Нужно передать сигнал в контекст остановки, вызвать Server.Shutdown с ограниченным временем и дождаться завершения обслуживающей горутины. Если у процесса есть другие ресурсы, закрывать их после drain в том же порядке жизненного цикла.
Применение
3. Разведи три уровня
Сервис показывает 80 000 активных горутин и 12 потоков ОС. Менеджер предлагает поднять лимит потоков до 80 000. Что сначала проверишь и почему эта мера не следует из приведённых чисел?
Ответ и разбор
Число горутин не равно числу потоков: планировщик Go размещает множество горутин на меньшем числе потоков. Сначала проверю, растёт ли число потоков со временем, сколько CPU доступно процессу, есть ли блокировки в системных вызовах и не упирается ли рантайм в ограничения. Само число 80 000 горутин может быть допустимым, а увеличение лимита потоков не устраняет причину задержки и повышает расход ресурсов.
Применение
4. Найди владельца дескрипторов
После релиза ulimit -n не менялся, но процесс через несколько часов получает too many open files. Составь первые три проверки, которые отличат рост открытых файлов от недостаточного лимита.
Ответ и разбор
Сравню число записей в /proc/<pid>/fd с лимитом процесса из /proc/<pid>/limits; сниму распределение целей fd через ls -l /proc/<pid>/fd; повторю замер во времени при одинаковой нагрузке. Если число дескрипторов монотонно растёт, ищу путь, где файлы или сокеты не закрываются. Если число стабильно у лимита, выясняю, оправдан ли пик и закрываются ли ресурсы после ошибок.