voidlearn
 

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

Процесс PID 1234 - своё адресное пространство Поток TID 1234 стек 8 МиБ (ulimit -s) G1 G2 G3 G4 горутины: стек от 2 КиБ, планирует рантайм Go Поток TID 1235 свои регистры и стек G5 G6 свободный слот общее с соседним потоком: куча, код, fd, mmap Ядро видит два task_struct. Про G1..G6 оно не знает ничего. Стоимость, порядок величины fork() процесса 15 мкс – 3 мс clone() потока ~6 мкс go func() - старт горутины 200 – 500 нс переключение процессов ~0.4 мкс + TLB переключение потоков ~0.35 мкс переключение горутин ~100 нс Разница не в тактах, а в том, входим ли мы в ядро и сбрасываем ли кэши. Итог для собеса: ядро планирует потоки, рантайм Go планирует горутины поверх потоков. Поэтому 100 000 горутин - норма, а 100 000 потоков - смерть машины.
Три уровня. Процесс изолирует, поток получает время на процессоре, горутина даёт дешёвую конкурентность. Цифры сняты на arm64 (Linux 7.0 в Docker Desktop, Go 1.27.1, одно ядро; fork - от пустого процесса до 1 ГиБ памяти): на другом железе они сдвинутся, но порядок останется.
РесурсМежду процессамиМежду потоками одного процессаМежду горутинами
Адресное пространство (код, куча)раздельноеобщееобщее
Стексвойсвой (8 МиБ виртуально)свой (от 2 КиБ, растёт)
Таблица файловых дескрипторовсвояобщаяобщая
Обработчики сигналовсвоиобщие (маска - своя)обрабатывает рантайм
Кто планируетядроядро (CFS/EEVDF)рантайм Go, в user space
Стоимость созданияот десятков мксединицы мкссотни нс
Формулировка, которую ждут

«Процесс - единица изоляции ресурсов, поток - единица планирования, горутина - единица конкурентности в user space. Переключение процессов дорого не столько из-за сохранения регистров, сколько из-за смены таблицы страниц: TLB и кэши остывают (без PCID/ASID TLB сбрасывается целиком). Потоки одного процесса делят таблицу страниц, поэтому их переключение дешевле. Горутины вообще не трогают ядро.»

Память процесса: сегменты, страницы, page fault

Каждый процесс видит собственное виртуальное адресное пространство. На x86-64 адрес занимает 48 бит, то есть 128 ТиБ на пользовательскую часть; верхняя половина зарезервирована под ядро и из user space недоступна. Виртуальные адреса в физические переводит аппаратный блок MMU по четырёхуровневой таблице страниц, а недавние переводы оседают в TLB. Ядро работает страницами по 4 КиБ (есть huge pages по 2 МиБ).

Классическая раскладка сегментов, снизу вверх:

  • .text хранит машинный код, отображён read-execute, разделяется между процессами того же бинаря;
  • .rodata держит константы и строковые литералы, read-only (поэтому s[0] = 'x' для строки в Go запрещено ещё и физически);
  • в .data лежат инициализированные глобальные переменные, прямо из файла;
  • .bss отдан глобальным нулям: в файле места не занимают, ядро выдаёт обнулённые страницы по требованию;
  • куча растёт вверх через brk/sbrk, а крупные куски идут через mmap;
  • mmap-область собирает разделяемые библиотеки, файлы, анонимные отображения; рантайм Go держит кучу только в таких анонимных аренах и через brk её не растит;
  • стек растёт вниз от верха пользовательской части, ограничен ulimit -s (обычно 8 МиБ).
Адресное пространство процесса, сверху вниз пространство ядра - недоступно 0xFFFF... Стек (stack) 8 МиБ по ulimit -s 0x7FFF... растёт вниз дыра: не отображено - SIGSEGV mmap .so, арены Go, большие блоки куча растёт вверх Куча (heap) - brk / mmap malloc в C; Go brk не трогает .bss - нули, в файле места нет .data - инициализированные глобалы .rodata - константы, строки (r--) .text - машинный код (r-x) 0x400000 Как это ложится на железо Виртуальные страницы по 4 КиБ V1 V2 V3 MMU+TLB физические фреймы Таблица страниц: 4 уровня, TLB кеширует переводы. Промах TLB - аппаратный обход таблицы, десятки нс. minor fault - без диска: нулевая страница или кэш major fault - чтение с диска/swap, от десятков мкс fork() и copy-on-write Копируются не страницы, а таблицы страниц; все страницы становятся read-only. Первая запись даёт fault, ядро копирует одну страницу 4 КиБ. Но не бесплатно: 1 ГиБ RSS - до ~2.5 мс. VSZ vs RSS - почему Go «ест гигабайт» VSZ - сколько адресного пространства занято. RSS - сколько физ. страниц реально отображено. Go заранее резервирует адреса: VSZ большой, RSS маленький. Смотреть надо RSS.
Память процесса. Слева карта: что где лежит. Справа видно, почему виртуальный размер не равен реальному потреблению и почему fork не копирует память.
# карта памяти живого процесса (Go 1.27, linux/arm64; на amd64 код начинается с 00400000)
$ cat /proc/$(pgrep myapp)/maps
00010000-00277000 r-xp 00000000 00:a9 80250   /usr/local/bin/myapp   # .text
00280000-0055f000 r--p 00270000 00:a9 80250   /usr/local/bin/myapp   # .rodata
...
43fd09c00000-43fd0a400000 rw-p 00000000 00:00 0                      # арена кучи Go, адрес случайный с Go 1.26
...
ffffda429000-ffffda44a000 rw-p 00000000 00:00 0                      [stack]

$ ps -o pid,vsz,rss,comm -p $(pgrep myapp)
    PID    VSZ   RSS COMMAND
   1312 1265524 14396 myapp      # VSZ 1.2 ГиБ, RSS 14 МиБ, для Go это норма

$ cat /proc/$(pgrep myapp)/status | grep -E 'VmRSS|Threads'
VmRSS:     14396 kB
Threads:      14              # столько потоков ОС держит рантайм
Глубже, чем спросят: overcommit и OOM killer

Linux по умолчанию переобещает память (vm.overcommit_memory=0): mmap почти всегда успешен, физическая страница выдаётся только при первом обращении. Поэтому «выделить память» и «получить память» перестают совпадать. Когда физической памяти реально не хватает, срабатывает OOM killer: он выбирает жертву по oom_score (грубо говоря, кто больше съел, тот и виноват) и посылает ей SIGKILL. Ни перехватить, ни красиво завершиться нельзя, и в логе останется только строка в dmesg. Это ровно тот же механизм, что даёт OOMKilled в Kubernetes, только там лимит задан на cgroup, а не на всю машину.

Стек и рекурсия: ОС против Go

Поток ОС получает стек фиксированным непрерывным куском виртуальной памяти, обычно 8 МиБ. В конце стека стоит guard page. Когда рекурсия доходит до неё, процессор генерирует исключение, ядро превращает его в SIGSEGV, процесс умирает с «segmentation fault». Никакого «StackOverflowError» в C нет, есть просто падение. В один кадр рекурсии влезает адрес возврата, сохранённые регистры и локальные переменные, то есть десятки байт; 8 МиБ хватает примерно на сотни тысяч простых кадров.

В Go всё иначе, и это любимый добивающий вопрос. Стек горутины начинается с 2 КиБ (с Go 1.19 рантайм может сразу дать больше, если по прошлой сборке мусора стеки в среднем крупнее) и растёт: в прологе почти каждой функции компилятор вставляет проверку «хватает ли места до границы стека»; если нет, вызывается morestack, рантайм выделяет вдвое больший сегмент, копирует туда старый стек и правит указатели на него (стек перемещаемый, поэтому указатель на локальную переменную в Go всегда корректен: переменная либо убежит в кучу, либо переедет вместе со стеком). На 64-битных платформах дальше 1 ГБ стек не растёт (debug.SetMaxStack). На этой границе рантайм не паникует, а падает фатально.

func rec(n int) int { return rec(n+1) }   // бесконечная рекурсия

func main() {
    defer func() {
        // не сработает: stack overflow даёт fatal error рантайма, а не панику
        if r := recover(); r != nil { fmt.Println("caught", r) }
    }()
    rec(0)
}

// runtime: goroutine stack exceeds 1000000000-byte limit
// runtime: sp=0x218af32e0390 stack=[0x218af32e0000, 0x218b132e0000]
// fatal error: stack overflow
На чём ловят

«Можно ли поймать переполнение стека через recover?» - Нет. recover ловит только panic. Переполнение стека, конкурентная запись в мапу (concurrent map writes), дедлок всех горутин и OOM рантайма дают fatal error: процесс умирает мгновенно, defer не выполняются. Второй вопрос-добивка: «почему в Go можно вернуть указатель на локальную переменную?» Виноват escape-анализ: компилятор увидит, что адрес уходит наружу, и разместит значение в куче.

Системные вызовы

Системный вызов открывает единственную легальную дверь из пользовательского кода в ядро. На x86-64 программа кладёт номер вызова и аргументы в регистры и выполняет инструкцию syscall; процессор переключается в нулевое кольцо, ядро выполняет работу и возвращает управление. Дорого здесь не само переключение режима (это десятки наносекунд), а всё вокруг: проверка аргументов, возможная блокировка, а с митигацией Meltdown (KPTI) ещё и смена таблицы страниц на каждом входе и выходе, без PCID со сбросом TLB. Простой вызов вроде getppid() стоит порядка сотни наносекунд (замер на arm64 без KPTI, ядро 7.0: 130 нс из C, 180 нс из Go). Отсюда общее правило: батчить (writev вместо десяти write, буферизованный bufio.Writer вместо посимвольной записи).

Часть «вызовов» ядро вообще отдаёт без входа в себя, через vDSO: так работают clock_gettime и gettimeofday, поэтому time.Now() в Go стоит наносекунды, а не микросекунды.

Путь блокирующего вызова из Go горутина f.Read(buf) рантайм Go entersyscall G помечается CPU syscall кольцо 3 в кольцо 0 ядро sys_read VFS, драйвер, ожидание возврат exitsyscall свой P или свободный Цена: 130–180 нс на «пустой» вызов (замер: arm64 без KPTI), с KPTI дороже, плюс промахи кэша и TLB после возврата. vDSO: clock_gettime и gettimeofday исполняются прямо в процессе, без перехода в ядро - отсюда дешёвый time.Now(). sysmon отбирает P у потока, застрявшего в syscall: через 20 мкс, если P ждёт работа, иначе через 10 мс. Сетевой ввод-вывод в Go вообще не блокирует поток: сокеты неблокирующие, ожиданием заведует netpoller поверх epoll. Таблица файловых дескрипторов процесса 0 stdin 1 stdout 2 stderr 3 /var/log/app.log 4 TCP 10.0.0.5:5432 (пул БД) 5 TCP 0.0.0.0:8080 (listener) struct file в ядре смещение, флаги, счётчик ссылок inode, сокет, pipe, eventfd, epoll «всё есть файл»
Syscall и дескрипторы. fd работает просто индексом в таблице процесса; за ним стоит объект ядра. Не закрыл, и индекс занят, пока жив процесс (забытый файл в Go закроет разве что финализатор после сборки мусора), а счётчик тикает в сторону ulimit -n.
# чем занят процесс: смотрим по системным вызовам
$ strace -p 1312 -f                       # -f идёт за потоками, для Go обязательно
$ strace -c -f -p 1312                    # сводка: сколько вызовов и сколько времени в каждом
$ strace -f -e trace=network ./myapp      # только сетевые
$ strace -f -e trace=openat,close ./myapp # ловим утечку дескрипторов

# альтернативы, когда strace слишком дорог (на частых вызовах тормозит процесс в десятки и сотни раз)
$ perf trace -p 1312
$ bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'

Файловые дескрипторы и «too many open files»

Под дескриптором прячется маленькое целое число, индекс в таблице открытых файлов процесса. За ним стоит структура ядра, и она занимает память ядра. Лимит на количество задаётся ulimit -n (мягкий и жёсткий). У systemd-юнита по умолчанию мягкий так и остаётся 1024 при жёстком 524288, а для сетевого сервиса 1024 смешно мало. Программа на Go с версии 1.19 при старте сама поднимает мягкий лимит до жёсткого (с 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.Client{} со своим http.Transport внутри функции, которую зовут на каждый запрос, значит гарантированно потечь (клиент без своего транспорта берёт общий, по умолчанию, и не течёт): у каждого транспорта свой пул соединений, старые сокеты не переиспользуются и висят, пока не отвалятся по таймауту. http.Client потокобезопасен, поэтому делай его один на процесс, настраивай MaxIdleConnsPerHost (дефолт 2, для одного бэкенда мало) и всегда ставь Timeout. Симптом в проде: растёт число открытых сокетов (у незакрытых тел ответов они висят в CLOSE_WAIT), потом «too many open files», потом сервис перестаёт принимать входящие, ведь accept() тоже нужен дескриптор.

Сигналы

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

Сигнал№Кто и когда шлётПо умолчаниюПерехватить?
SIGTERM15kill, docker stop, k8s при удалении подазавершениеда - это «просьба закрыться»
SIGINT2Ctrl+C в терминалезавершениеда
SIGQUIT3Ctrl+\завершение + core dumpда, но в Go по умолчанию печатает стеки всех горутин
SIGKILL9kill -9, OOM killer, k8s после grace periodмгновенная смертьнет, никогда
SIGSTOP19заморозка процессаостановканет
SIGHUP1закрытие терминала; по традиции - «перечитай конфиг»завершениеда
SIGUSR1/210/12только то, что ты сам придумалзавершениеда - удобно для «сбрось профиль»
SIGPIPE13запись в закрытый сокет/pipeзавершениеда; Go игнорирует его для всего, кроме stdout/stderr
SIGCHLD17ребёнок завершилсяигнорируетсяда - сюда вешают wait() против зомби
// Типовой graceful shutdown на Go 1.16+
func main() {
    // NotifyContext отменяет ctx при первом же SIGINT/SIGTERM
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{Addr: ":8080", Handler: router}

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatal(err)
        }
    }()

    <-ctx.Done()                 // приехал SIGTERM
    stop()                       // вернуть дефолтный обработчик: второй Ctrl+C убьёт сразу
    log.Println("shutting down")

    // даём доработать текущим запросам, но не бесконечно
    shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()

    if err := srv.Shutdown(shCtx); err != nil {  // перестаёт принимать новые, ждёт активные
        log.Println("forced shutdown:", err)
    }
    // и только теперь закрываем пул БД, consumer'ы Kafka, флашим трейсы
    pool.Close()
    tracerProvider.Shutdown(context.Background())
}
Правило: останавливай в порядке, обратном запуску

Сначала перестать принимать новую работу (HTTP-сервер, consumer брокера), потом дождаться активной, потом закрыть исходящие ресурсы (пул БД, Redis, продюсеры), и в самом конце флашнуть телеметрию. Если закрыть пул БД первым, все доигрывающие запросы упадут с ошибкой, и ты сам создашь всплеск 5xx на каждом деплое. И держи общий бюджет меньше, чем terminationGracePeriodSeconds в k8s (по умолчанию 30 с), иначе тебя дорежут SIGKILL.

Убить процесс, jobs, fg, bg

# найти
$ pgrep -a myapp                 # PID + командная строка
$ ps aux | grep '[m]yapp'        # скобки, чтобы grep не нашёл сам себя
$ pidof myapp

# убить, от вежливого к грубому
$ kill 1312                      # SIGTERM по умолчанию, вежливо
$ kill -TERM 1312                # то же явно
$ kill -HUP 1312                 # «перечитай конфиг»
$ kill -QUIT 1312                # в Go: дамп стеков всех горутин в stderr, спасает при зависании
$ kill -9 1312                   # SIGKILL, последнее средство: без flush, без cleanup

# по имени
$ pkill myapp                    # по имени процесса
$ pkill -f 'myapp --config=prod' # -f матчит всю командную строку (регулярка, осторожнее)
$ pkill -u deploy -f worker      # только процессы пользователя deploy
$ killall myapp                  # точное совпадение имени; на Solaris killall убивает вообще всё
$ systemctl stop myapp           # правильный способ, если это systemd-юнит
$ kill -0 1312                   # ничего не шлёт, только проверяет: жив ли и есть ли права

# управление задачами в текущем шелле
$ ./myapp &                      # запустить в фоне
$ jobs -l                        # список задач шелла с PID
$ fg %1                          # вернуть первую задачу на передний план
$ bg %1                          # продолжить её в фоне (после Ctrl+Z)
# Ctrl+Z шлёт SIGTSTP (пауза), Ctrl+C SIGINT, Ctrl+\ SIGQUIT
$ nohup ./myapp &                # переживёт закрытие терминала (игнорирует SIGHUP)
$ disown -h %1                   # то же для уже запущенной задачи
Почему kill -9 плохой рефлекс

SIGKILL обрабатывает ядро, процесс о нём даже не узнаёт: не выполнятся defer, не закроются транзакции, не сбросятся буферы, не снимутся файлы-локи, сообщение из брокера останется вычитанным, но неподтверждённым. Если процесс не умирает от SIGTERM, это баг, который надо чинить, а не обходить девяткой. И главный случай, когда даже -9 не работает: процесс в состоянии D (uninterruptible sleep). Он застрял в системном вызове к сломанному диску, и ждать придётся ядро.

Куда смотреть: логи, порты, нагрузка

# Логи
$ journalctl -u myapp -f                     # хвост живого юнита
$ journalctl -u myapp --since '10 min ago' -p err   # только ошибки за 10 минут
$ journalctl -u myapp -b -1                  # логи предыдущей загрузки
$ journalctl --disk-usage && journalctl --vacuum-time=7d
$ tail -f /var/log/syslog                    # Ubuntu и Debian до 12, на RHEL /var/log/messages
$ dmesg -T | grep -i -E 'oom|killed'         # сюда пишет OOM killer, первое место при 137
$ docker logs -f --tail 100 --since 10m app  # логи контейнера (это stdout/stderr PID 1)
$ kubectl logs -f deploy/app -c app --tail=100
$ kubectl logs pod/app-xyz --previous        # логи упавшего контейнера, главный трюк при CrashLoop

# Кто слушает порт
$ ss -ltnp                       # listening, tcp, numeric, process, основной инструмент
$ ss -ltnp 'sport = :8080'
$ ss -s                          # сводка по сокетам: сколько в TIME-WAIT и т.д.
$ ss -tanp state established | wc -l
$ lsof -i :8080                  # то же, но lsof есть не везде и он медленнее
$ fuser -k 8080/tcp              # убить того, кто занял порт
$ netstat -tulpn                 # legacy, во многих образах уже нет

# Нагрузка
$ uptime                         # load average 1/5/15
$ top -H -p 1312                 # -H: показать потоки процесса
$ htop                           # то же, но по-человечески
$ vmstat 1 5                     # r (runnable), b (blocked), si/so (swap), wa
$ iostat -x 1                    # %util, r_await/w_await: узкое место по диску
$ pidstat -d 1                   # ввод-вывод по процессам
$ free -h                        # смотреть надо на available, а не на free

Load average в Linux не означает «загрузку CPU в процентах». Это экспоненциально сглаженное за 1/5/15 минут число задач, которые либо выполняются, либо готовы выполняться, либо находятся в непрерываемом ожидании (state D). Последнее придумали именно в Linux: ожидание диска тоже поднимает LA. Читается так: делим на число ядер. При nproc = 8 LA 8.0 значит, что машина занята на всю. LA 24 даёт очередь втрое длиннее числа ядер. А LA 40 при 2 % CPU почти наверняка означает, что сдох диск или сетевое хранилище вроде NFS и все висят в D. Три числа дают направление: 1.2 8.4 15.9 читается как «пик был и рассасывается», 15.9 8.4 1.2 как «растёт прямо сейчас».

Свободная память. Классическая паника новичка: free -h показывает 200 МиБ free при 32 ГиБ ОЗУ. Это нормально: Linux использует всю незанятую память под page cache и буферы, потому что за простаивающую память заплачено впустую. Кэш ядро отдаёт мгновенно, как только он кому-то понадобится. Смотреть надо на колонку available: она и показывает, сколько реально можно занять, не уходя в swap. Тревожно, когда available близок к нулю, или когда в vmstat ненулевые si/so (идёт свопинг).

iowait (%wa в top) считает долю времени, когда CPU простаивал и при этом ждал ввода-вывода хотя бы один процесс. Высокий iowait означает «процессор свободен, но работа упирается в диск». Ожидание ответа по сети (медленная удалённая БД) iowait не даёт: такие процессы спят в состоянии S. Метрика лукавая: если параллельно есть счётный процесс, занимающий CPU, iowait упадёт до нуля, хотя диск так же тормозит. Поэтому подтверждать надо через iostat -x (r_await/w_await, %util) и число процессов в состоянии D.

Зомби (Z в ps, «defunct») остаётся от процесса, который уже завершился, а родитель не вызвал wait()/waitpid() и не забрал код возврата. Ядро держит запись в таблице процессов ради этого кода: памяти зомби не занимает, но занимает PID. Убить зомби нельзя, он уже мёртв: kill -9 на него не действует. Помогает либо починить родителя, либо убить его (тогда зомби усыновляет init/PID 1 и немедленно «пожинает»). В Go зомби появляются, если ты запускаешь подпроцессы через exec.Command и не вызываешь cmd.Wait(). Отдельный контейнерный случай: твоё приложение стало PID 1 и не жнёт сирот, так что зомби копятся, пока не кончатся PID; лечится флагом docker run --init (tini) или процессом-надзирателем.

cgroups и namespaces - на чём стоят контейнеры

Что физически представляет собой контейнер? Ответ короткий и для многих неожиданный: обычный процесс Linux. Никакой отдельной сущности «контейнер» в ядре нет; есть два независимых механизма, которыми процессу подрезают обзор и аппетит. Путают их на собесе постоянно.

Namespace показывает процессу урезанную версию реальности. У ядра есть глобальные списки: все процессы, все сетевые интерфейсы, все точки монтирования. Namespace создаёт для процесса отдельный такой список, и процесс видит только его. Бытовая аналогия: комната с односторонним стеклом, где ты видишь только то, что внутри, и искренне считаешь, что это весь мир. Аналогия ломается в одном месте: стены тут не защитные. Ядро у всех общее, и дыра в нём видна из любой комнаты.

Cgroup, наоборот, занимается счётчиками. Процессы объединяются в группу, и ядро для всей группы считает потраченный 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
Суть: процесс - единица изоляции (своё адресное пространство), поток - единица планирования ядром внутри процесса, горутина - единица конкурентности, которую планирует рантайм Go в user space и о которой ядро не знает.

Память

У процесса своё виртуальное адресное пространство, своя таблица файловых дескрипторов, свой cwd, свои uid/gid и свои обработчики сигналов. Потоки одного процесса делят абсолютно всё это: код, кучу, глобальные переменные, дескрипторы. Потоку принадлежат только стек (обычно 8 МиБ виртуально, по ulimit -s), регистры, TLS, маска сигналов, приоритет и привязка к ядрам. Отсюда и разница в модели программирования: два процесса нужно связывать через IPC, а два потока уже видят одну память, и потому им нужны мьютексы.

В Linux и то и другое живёт как одна структура task_struct, созданная clone() с разными флагами. С CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD получается поток (тот же PID), без них процесс. Планировщик их не различает.

Переключение контекста

Прямая стоимость переключения сводится к сохранению и восстановлению регистров и работе планировщика, это десятые доли микросекунды. Но главная цена косвенная. При переключении между процессами меняется таблица страниц (CR3), TLB и кэши L1/L2 остывают (без PCID/ASID TLB сбрасывается целиком), и следующие сотни обращений к памяти будут медленными. Между потоками одного процесса таблица страниц та же, поэтому TLB выживает и переключение дешевле. Плюс само создание: fork() стоит от десятков микросекунд до миллисекунд (растёт с памятью процесса), clone() потока единицы микросекунд.

Где здесь горутина

Горутина живёт в куче процесса Go как структура runtime.g. Планировщик Go работает по схеме G–M–P: G это горутина, M поток ОС, P логический процессор (их GOMAXPROCS, по умолчанию по числу доступных ядер). Рантайм мультиплексирует тысячи G на десяток M. Переключить горутину значит сменить несколько регистров и перейти на другой стек: без входа в ядро, 50–200 нс. Стек начинается с 2 КиБ (с Go 1.19 рантайм может сразу дать больше, по среднему размеру стеков на прошлой сборке мусора) и растёт копированием, а не резервируется на 8 МиБ. Поэтому миллион горутин живёт спокойно, а миллион потоков нет: только под стеки ушло бы 8 ТиБ виртуальной памяти, и планировщик ядра захлебнулся бы.

Чем добить

«Горутина не бесплатна: это от 2 КиБ стека плюс структура g, и обходится она не дешевле потока, если внутри неё сидит блокирующий системный вызов: тогда рантайм отдаёт P другому потоку, а этот M висит в ядре. Число потоков процесса Go видно в /proc/PID/status в поле Threads, и если оно растёт до сотен, значит, где-то много блокирующих syscall или cgo-вызовов. Жёсткий предел стоит на 10 000 потоков (runtime/debug.SetMaxThreads).»

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

Сегменты

Снизу вверх: .text (код, r-x), .rodata (константы, r--), .data (инициализированные глобалы), .bss (глобальные нули, в файле места не занимают), куча (растёт вверх), область mmap (библиотеки, файлы, крупные аллокации, арены Go), стек (растёт вниз от верхней границы пользовательской части). Живьём это видно через cat /proc/PID/maps.

Виртуальная память

Адрес, которым оперирует программа, - виртуальный. Перевод в физический делает MMU по четырёхуровневой таблице страниц; результаты кэшируются в TLB. Единица - страница 4 КиБ. Это даёт три вещи: изоляцию (чужие страницы просто не отображены), возможность дать процессу больше памяти, чем есть физически, и разделение общих страниц (один .text libc на все процессы).

Как ОС управляет

  • Ленивое выделение. mmap только резервирует диапазон. Физическую страницу выдают при первом обращении, и это minor page fault (микросекунда).
  • Major page fault ловим, когда нужной страницы в RAM нет: читаем с диска или из swap: десятки микросекунд на SSD, миллисекунды на HDD.
  • Copy-on-write. При fork() копируются не данные, а таблицы страниц; все страницы становятся read-only, и физическое копирование происходит постранично при первой записи.
  • Page cache. Вся свободная память уходит под кэш файлов и отдаётся мгновенно, когда нужна.
  • Overcommit и OOM killer. Ядро обещает больше, чем имеет; когда память кончилась реально, выбирается жертва по oom_score и получает SIGKILL.
Ловушка: «почему Go-процесс занимает 1.2 ГиБ?»

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

Суть: у потока ОС стек фиксированный (8 МиБ) и упирается в guard page - получаем SIGSEGV; у горутины стек начинается от 2 КиБ и растёт копированием до 1 ГБ, а на пределе рантайм падает с fatal error: stack overflow, которую нельзя поймать через recover.

Как это устроено в ОС

Каждый вызов функции кладёт на стек кадр: адрес возврата, сохранённые регистры, локальные переменные и аргументы. Стек растёт вниз, к меньшим адресам. Снизу стоит guard page - страница без прав доступа. Когда рекурсия дотягивается до неё, MMU генерирует исключение, ядро превращает его в SIGSEGV, и процесс умирает с «segmentation fault». Размер стека потока фиксируется при создании (ulimit -s, обычно 8 МиБ) и не меняется, потому что стек должен быть непрерывным куском адресов, а расти ему некуда: соседние адреса уже могут быть заняты.

Как это в Go

Стек горутины «сегментированный» только исторически; с Go 1.3 он перемещаемый. Компилятор вставляет в пролог почти каждой функции проверку границы стека; при нехватке вызывается morestack: рантайм выделяет вдвое больший кусок, копирует старый стек и корректирует все указатели внутрь него. Возвращать указатель на локальную переменную в Go безопасно по другой причине: escape-анализ это видит и размещает её в куче. А указатели на стек внутри самой горутины при копировании переедут вместе со стеком.

func rec(n int) int { return rec(n + 1) }

func main() {
    defer func() { recover() }()   // не поможет
    rec(0)
}
// runtime: goroutine stack exceeds 1000000000-byte limit
// fatal error: stack overflow

Практические следствия

  • Глубокая рекурсия в Go «стоит» кучу мелких morestack и копирований - обход глубокого дерева лучше переписать на явный стек-слайс.
  • Крупные локальные массивы (var buf [64 << 10]byte) тоже вызывают рост стека и его копирование (больше 128 КиБ компилятор с Go 1.24 сам уносит в кучу); лучше make в кучу или sync.Pool.
  • Предел меняется через debug.SetMaxStack, но если ты об этом задумался, скорее всего, у тебя бесконечная рекурсия.
На чём ловят

«Поймается ли recover-ом переполнение стека?» - нет. Не ловятся recover-ом: stack overflow, concurrent map writes, all goroutines are asleep - deadlock!, out of memory рантайма и любой fatal error. Это не паника, а фатальная ошибка: defer-ы не выполняются, процесс умирает сразу.

Суть: syscall - переход из пользовательского кода в ядро через аппаратную инструкцию; дорог не сам переход, а сопутствующая работа ядра, остывшие кэши и, с митигацией Meltdown (KPTI), смена таблицы страниц на каждом входе и выходе. Смотреть - strace -f, для сводки - strace -c.

Механика

На x86-64 программа кладёт номер вызова и аргументы в регистры и выполняет syscall. Процессор переключается из третьего кольца в нулевое, ядро проверяет аргументы, делает работу (возможно, блокируется), возвращает результат в регистре. Стоимость «пустого» вызова вроде getpid() порядка сотни наносекунд (замер на arm64 без KPTI, ядро 7.0: 120–180 нс); с митигацией Meltdown дороже, KPTI меняет таблицу страниц на каждом входе в ядро и выходе, а без PCID ещё и сбрасывает TLB. Плюс после возврата холодные кэши.

Часть вызовов ядро отдаёт бесплатно через vDSO: clock_gettime, gettimeofday. Поэтому time.Now() в Go стоит десятки наносекунд, а не микросекунду.

Как Go с этим живёт

Перед блокирующим вызовом рантайм делает entersyscall; если вызов затянулся (от 20 мкс, когда P ждёт работа, иначе до 10 мс), фоновый поток sysmon отбирает у застрявшего M его P и отдаёт другому потоку - планировщик не останавливается. Для сети всё ещё лучше: сокеты в Go всегда неблокирующие, ожиданием заведует netpoller поверх epoll, так что десять тысяч соединений обслуживаются несколькими потоками.

Инструменты

$ strace -f -p 1312               # -f обязателен: Go многопоточный
$ strace -c -f -p 1312            # сводка: топ вызовов по времени и количеству
$ strace -f -e trace=openat,close ./app   # ловим утечку дескрипторов
$ perf trace -p 1312              # дешевле strace
$ bpftrace -e 'tracepoint:syscalls:sys_enter_write { @[comm]=count(); }'
Глубже

strace работает через ptrace и останавливает процесс на каждом вызове; если вызовы частые, программа замедляется в десятки и сотни раз. На проде это уже инцидент сам по себе; там правильнее perf/eBPF. Раз syscall дорог, его батчат. bufio.Writer вместо голого Write, writev вместо серии записей, чтение большими блоками. Классический профиль «90 % времени в write» почти всегда означает, что кто-то пишет в лог по одной строке без буфера.

Суть: fd - индекс в таблице открытых файлов процесса; лимит задаётся ulimit -n (часто 1024 по умолчанию, но Go с 1.19 сам поднимает мягкий лимит почти до жёсткого). В Go чаще всего это незакрытый resp.Body, незакрытые rows или новый http.Transport на каждый запрос.

Что это

Дескриптор это маленькое целое, индекс в таблице процесса. За ним в ядре стоит struct file со смещением, флагами и счётчиком ссылок, а за ней уже inode, сокет, pipe, eventfd или epoll. 0/1/2 заняты под stdin/stdout/stderr. В Unix «всё есть файл», поэтому и TCP-соединение это fd, и epoll-инстанс тоже fd.

Диагностика

$ ulimit -n; ulimit -Hn              # мягкий и жёсткий лимиты
$ cat /proc/1312/limits | grep 'open files'
$ ls /proc/1312/fd | wc -l           # сколько занято сейчас
$ lsof -p 1312 | awk '{print $5}' | sort | uniq -c | sort -rn
$ ss -tanp | grep CLOSE-WAIT | wc -l # верный признак незакрытых тел ответов

Типовые причины в Go

  • Не закрыт resp.Body. Даже если тело не нужно, закрывать обязательно, и лучше дочитать (io.Copy(io.Discard, resp.Body)), иначе соединение не вернётся в keep-alive-пул (с Go 1.27 закрытие само дочитывает до 256 КиБ).
  • Не закрыт rows из database/sql при раннем return. Соединение не возвращается в пул, пул пустеет, приложение висит.
  • Новый http.Transport (или http.Client со своим транспортом) на каждый запрос. У каждого транспорта свой пул, старые сокеты копятся; клиент без своего транспорта берёт общий, по умолчанию, и не течёт.
  • defer f.Close() внутри цикла - закроется только в конце функции.
  • Отсутствие таймаутов: подвисшие соединения держат fd, пока их не прибьёт TCP.
  • Реально высокая нагрузка: 5000 одновременных соединений при жёстком лимите 4096 - лимит и правда надо поднять (мягкий Go поднимает почти до жёсткого сам).

Как поднять лимит

# systemd unit
[Service]
LimitNOFILE=65535

# docker
docker run --ulimit nofile=65535:65535 app

# /etc/security/limits.conf (для интерактивных сессий)
deploy soft nofile 65535
deploy hard nofile 65535
Как звучит сильный ответ

«Сначала я смотрю, утечка это или честная нагрузка: ls /proc/PID/fd | wc -l в динамике и разбивка по типам через lsof. Если растёт монотонно и не падает, это утечка: ищу незакрытые тела и rows. Много CLOSE_WAIT значит, что не закрываем мы. А на честной нагрузке поднимаю LimitNOFILE и заодно проверяю MaxIdleConnsPerHost. Поднять лимит первым делом, не разобравшись, значит отложить падение на пару часов.»

Суть: SIGTERM - вежливая просьба закрыться, её нужно перехватывать и делать graceful shutdown; SIGKILL и SIGSTOP перехватить нельзя в принципе - их обрабатывает ядро.

Кто что значит

  • SIGTERM (15) значит «заверши работу». Шлют kill, docker stop, kubelet при удалении пода, systemd при systemctl stop. Перехватывается, и именно на нём строится graceful shutdown.
  • SIGINT (2), он же Ctrl+C. Семантически то же самое; в Go обрабатывают вместе с SIGTERM.
  • SIGKILL (9) убивает мгновенно, обработчик поставить нельзя. Шлют kill -9, OOM killer и kubelet после истечения grace period.
  • SIGQUIT (3) приходит по Ctrl+\. В Go по умолчанию печатает стеки всех горутин и завершает процесс с кодом 2: бесплатный способ понять, где всё зависло (GOTRACEBACK=all для этого не нужен).
  • SIGHUP (1) исторически означал «терминал закрыт», а по традиции демонов «перечитай конфиг». Стандарта нет, это соглашение.
  • SIGSTOP/SIGCONT замораживают и размораживают; SIGSTOP тоже неперехватываемый.
  • SIGPIPE (13) прилетает при записи в закрытый сокет. Go его игнорирует для всех дескрипторов, кроме stdout/stderr, и превращает в ошибку EPIPE.

Как правильно реагировать

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
stop()                       // второй сигнал теперь убьёт немедленно, так и надо

shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
_ = srv.Shutdown(shCtx)      // перестать принимать новые, доиграть текущие
consumer.Close()             // потом входящие из брокера
pool.Close()                 // и только потом исходящие ресурсы

Порядок важен: сначала закрываем входящие источники работы, потом ждём активную работу, потом закрываем исходящие ресурсы, последними флашим трейсы и метрики. Общий бюджет должен быть меньше, чем terminationGracePeriodSeconds (в k8s по умолчанию 30 с), иначе получишь SIGKILL посреди работы.

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

Процесс с PID 1 не имеет обработчиков сигналов по умолчанию: ядро специально не применяет к нему default action для перехватываемых сигналов. Если приложение не поставило свой обработчик SIGTERM, оно его просто проигнорирует, docker stop подождёт 10 секунд и пришлёт SIGKILL. Go-бинаря это не касается: обработчики ставит сам рантайм, и даже без перехвата в коде процесс сразу выходит (с Go 1.27 с кодом 143, раньше с кодом 2), только без graceful shutdown. Второй вариант той же беды: ENTRYPOINT в shell-форме превращается в /bin/sh -c "app", и PID 1 становится sh, который сигнал никому не передаёт (так в Debian и Ubuntu, где sh - это dash; busybox sh в Alpine единственную команду запускает через exec, и PID 1 достаётся приложению). Лечится exec-формой ENTRYPOINT ["/app"] или --init/tini.

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

Ровно два: SIGKILL (9) и SIGSTOP (19). Их нельзя ни поймать, ни заблокировать, ни проигнорировать. Всё остальное - можно.

Суть: pkill/killall по имени, kill PID после pgrep, а для сервиса - systemctl stop. По умолчанию все шлют SIGTERM, и это правильно.
$ pgrep -a myapp                  # найти: PID + командная строка
$ kill $(pgrep myapp)             # классика
$ pkill myapp                     # по имени процесса, но по подстроке: заденет и myapp-worker
$ pkill -f 'myapp --config=prod'  # -f: матчить всю командную строку
$ pkill -u deploy -f worker       # сузить по пользователю
$ killall myapp                   # точное имя; на Solaris killall убивает вообще всё
$ systemctl stop myapp            # правильный способ для systemd-юнита
$ kill -QUIT $(pgrep myapp)       # Go: дамп стеков всех горутин перед смертью
$ kill -9 $(pgrep myapp)          # последнее средство

Чем отличаются

  • kill работает по PID и ничего не знает про имена.
  • pkill матчит имя регуляркой; -f расширяет матч на всю командную строку. Опасен: pkill -f go убьёт больше, чем ты хотел, поэтому всегда сначала pgrep -af с тем же паттерном.
  • killall требует точного совпадения имени процесса, без регулярок.
  • systemctl stop убивает не процесс, а всю cgroup юнита - вместе с детьми, что важно, если приложение форкает воркеров.

jobs / fg / bg

Это управление задачами внутри одного шелла. ./app & запускает в фоне, jobs -l показывает список с номерами и PID, fg %1 возвращает задачу на передний план, bg %1 продолжает её в фоне. Ctrl+Z шлёт SIGTSTP (пауза), Ctrl+C - SIGINT, Ctrl+\ - SIGQUIT. Но фоновая задача остаётся ребёнком шелла, и при закрытии терминала получит SIGHUP. Чтобы выжила, есть nohup ./app &, disown -h %1 или setsid; а по-хорошему долгоживущее отправляют в systemd-юнит или в tmux/screen.

Суть: systemd - journalctl -u, классика - /var/log/* и dmesg, контейнеры - docker logs / kubectl logs (это просто stdout/stderr PID 1). Порт - ss -ltnp.

Логи

$ journalctl -u myapp -f                          # хвост
$ journalctl -u myapp --since '10 min ago' -p err # только ошибки (голый stderr журнал пишет как info)
$ journalctl -u myapp -b -1                       # прошлая загрузка
$ journalctl -k | grep -i oom                     # сообщения ядра
$ dmesg -T | grep -i -E 'oom|killed process'      # кто был убит OOM killer
$ tail -f /var/log/syslog                         # Ubuntu и Debian до 12; RHEL: /var/log/messages
$ docker logs -f --tail 100 --since 10m app
$ kubectl logs -f deploy/app --tail=100
$ kubectl logs pod/app-xyz --previous             # логи предыдущего, упавшего контейнера

Про контейнеры: у контейнера нет «файла лога». Приложение обязано писать в stdout/stderr, а runtime сам складывает это в файл на ноде (у Docker по умолчанию json-file, у kubelet свой текстовый формат CRI) и отдаёт по docker logs/kubectl logs. Писать логи в файл внутри контейнера - антипаттерн: их никто не соберёт, а диск ноды они забьют.

Кто слушает порт

$ ss -ltnp                       # l listening, t tcp, n numeric, p process
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096               *:8080            *:*    users:(("myapp",pid=1312,fd=4))
$ ss -ltnp 'sport = :8080'
$ ss -Htanp state established | wc -l  # сколько активных соединений (-H: без заголовка)
$ ss -s                          # сводка, в т.ч. сколько в TIME-WAIT
$ lsof -i :8080                  # альтернатива; медленнее, есть не везде
$ fuser -k 8080/tcp              # убить того, кто держит порт (шлёт SIGKILL, не SIGTERM)
$ netstat -tulpn                 # legacy, в slim-образах отсутствует
Нюанс с контейнерами

ss с хоста не увидит сокеты внутри контейнера: у него свой сетевой namespace. Зайти внутрь помогает nsenter -t $(docker inspect -f '{{.State.Pid}}' app) -n ss -ltnp. А в distroless-образе ss просто нет, там спасают kubectl debug с ephemeral-контейнером.

Суть: load average - это число задач в очереди на выполнение плюс задачи в непрерываемом ожидании ввода-вывода, сглаженное за 1/5/15 минут; делить надо на число ядер. А «мало free» - норма: Linux отдал память под page cache, смотреть надо available.

Что читать в top

  • строка %Cpu(s): us (user), sy (kernel), wa (iowait), st (steal - время отбирает гипервизор, верный признак «шумного соседа» на облачной VM);
  • RES это резидентная память (реальная), VIRT виртуальная (у Go всегда огромна, это норма), SHR разделяемая;
  • состояние S: R выполняется, S спит, D ждёт ввода-вывода непрерываемо (диск!), Z зомби;
  • top -H -p PID покажет потоки: видно, сколько их наплодил рантайм Go.

Load average

Три числа - экспоненциально сглаженные средние за 1, 5 и 15 минут. В Linux (в отличие от классического Unix) в счёт идут и runnable-задачи, и задачи в состоянии D. Поэтому LA растёт и от нехватки CPU, и от тормозящего диска или NFS.

  • Нормировать на ядра: nproc = 8, LA 8.0 означает загрузку ровно на 100 %.
  • LA 24 при 8 ядрах даёт очередь втрое длиннее, чем можно обслужить, и задержки растут.
  • LA 40 при %us около нуля и высоком %wa упирается в диск или NFS, а не в CPU: все висят в D.
  • Динамика: 1.2 8.4 15.9 значит, пик прошёл; 15.9 8.4 1.2 - растёт прямо сейчас.

Память и кэши

$ free -h
              total   used    free   shared  buff/cache  available
Mem:           31Gi   9.1Gi   412Mi    120Mi       21Gi        21Gi
#                             ^^^^^ пугает                    ^^^^ вот что важно

Незанятая память - потраченные впустую деньги, поэтому Linux заполняет её page cache. Кэш отдаётся мгновенно по первому требованию, и колонка free почти всегда маленькая, это нормально. Реальный показатель тут available: сколько можно занять, не уходя в swap. Тревожно, когда available близок к нулю или когда в vmstat 1 ненулевые si/so: начался свопинг, и задержки вырастут на порядки.

Что добавить про контейнеры

Внутри контейнера top и free показывают хост, а не cgroup: эти данные берутся из /proc/meminfo, который namespace не изолирует. Реальные цифры лежат в /sys/fs/cgroup/memory.current и memory.max. Отсюда же известный парадокс: free в поде говорит «свободно 20 ГиБ», а контейнер уходит в OOMKilled на 512 МиБ.

Суть: iowait - доля времени, когда CPU простаивал, но кто-то ждал ввода-вывода. Зомби - завершившийся процесс, чей код возврата не забрал родитель через wait(); убить его нельзя, лечится через родителя.

iowait

Формально %wa - idle-время процессора, в течение которого была хотя бы одна задача, заблокированная на вводе-выводе. Высокий iowait означает: «CPU свободен, упираемся в диск или сетевое хранилище вроде NFS». Метрика лукавая по двум причинам. Во-первых, если параллельно крутится счётная задача, iowait упадёт до нуля, хотя диск тормозит так же. Во-вторых, она усреднена по ядрам. Поэтому подтверждать надо через iostat -x 1 (r_await/w_await показывают задержку запроса, %util занятость устройства) и через число процессов в состоянии D в ps aux. В Go-сервисе высокий iowait обычно означает не «мой код медленный», а «диск под нами тормозит»; медленная удалённая БД iowait не даёт, горутины ждут её в netpoller.

Зомби

Когда процесс завершается, ядро освобождает его память и дескрипторы, но оставляет запись в таблице процессов с кодом возврата, вдруг родителю интересно. Родитель обязан вызвать wait()/waitpid() и «пожать» ребёнка. Пока он этого не сделал, ребёнок висит в состоянии Z (defunct). Зомби не занимает ни памяти, ни CPU, зато занимает PID, а PID конечны (/proc/sys/kernel/pid_max).

  • kill -9 на зомби бесполезен: он уже мёртв.
  • Чинить надо родителя: заставить его сделать wait() (например, прислав SIGCHLD) или убить, тогда зомби усыновит PID 1 и немедленно пожнёт.
  • В Go: если ты запустил exec.Command и не вызвал cmd.Wait() (или использовал Start без Wait), получишь зомби. cmd.Run() делает Start+Wait сам.
  • В контейнере твоё приложение работает как PID 1 и обязано жать осиротевшие процессы. Обычные приложения этого не умеют, поэтому есть docker run --init (tini) и shareProcessNamespace в k8s.
$ ps aux | awk '$8 ~ /^Z/ {print}'      # найти зомби
$ ps -o ppid= -p 4711                    # кто родитель, его и чинить
$ ps -eo state | grep -c D               # сколько застряло в непрерываемом ожидании
Не путать с сиротой

Сирота - живой процесс, чей родитель умер; его усыновляет PID 1, и он спокойно работает дальше. Зомби, наоборот, мёртвый процесс, чей живой родитель не забрал код возврата. Путая их, ты сразу показываешь, что тему знаешь по верхам.

Суть: namespaces отвечают «что процесс видит», cgroups - «сколько ему можно». Контейнер = обычный процесс + namespaces + cgroup + корневая ФС из образа. Никакой виртуализации, ядро общее с хостом.

Namespaces - изоляция видимости

  • pid даёт свою нумерацию: приложение становится PID 1 и не видит чужих процессов;
  • у net свои интерфейсы, свои порты, свои правила iptables (поэтому два контейнера могут оба слушать 8080);
  • mnt держит своё дерево монтирования (сюда подставляется ФС образа);
  • uts отвечает за hostname, ipc за разделяемую память;
  • user маппит uid: root внутри может быть непривилегированным снаружи;
  • cgroup и time задают вид на иерархию cgroups и на часы.

Cgroups (v2) - ограничение ресурсов

  • cpu.max задаёт квоту и период, например 200000 100000 = 2 ядра; превышение даёт троттлинг (задачу останавливают до конца периода);
  • memory.max ставит жёсткий потолок, за которым OOM внутри группы; memory.high мягкий, включает агрессивный reclaim;
  • pids.max, io.max, io.weight.

Контейнер собирается из этого плюс pivot_root на OverlayFS, урезанные capabilities (нет CAP_SYS_ADMIN и т. д.) и seccomp-профиль, запрещающий опасные системные вызовы. Именно поэтому контейнер стартует за доли секунды: это просто clone() с флагами, а не загрузка ОС.

systemd vs docker

systemd тоже управляет cgroups: каждый юнит живёт в своей группе, и через [Service] задаются MemoryMax=, CPUQuota=, LimitNOFILE=, политика перезапуска Restart=on-failure, а stdout уходит в journald. Он умеет и часть namespaces (PrivateTmp=, ProtectSystem=). Разница в другом: systemd обычно запускает бинарь в файловой системе этой машины, и зависимости ставишь на неё сам (запускать сервис из образа он тоже умеет, но так делают редко), а docker приносит с собой неизменяемый образ со всей ФС и полную сетевую изоляцию. Для одиночного Go-сервиса на выделенной VM systemd-юнит - вполне взрослый и более простой выбор; контейнеры выигрывают, когда нужны одинаковые артефакты между окружениями и оркестрация.

Добивка, которую любят

«Namespaces не изолируют /proc/cpuinfo и /proc/meminfo. Процесс внутри контейнера видит все ядра и всю память хоста, хотя его cgroup даёт ему полядра и 512 МиБ. Отсюда исторические проблемы с GOMAXPROCS (лечится automaxprocs, а с Go 1.25 рантайм читает лимит cgroup сам) и с JVM, где эту же проблему закрыли флагом UseContainerSupport.»

Суть: сгенерировать ed25519-пару, положить публичный ключ в ~/.ssh/authorized_keys нужного пользователя, закрыть их правами 700 и 600, проверить вход второй сессией и только потом выключить парольную аутентификацию.
$ ssh-keygen -t ed25519 -C 'german@laptop'        # приватный с парольной фразой
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@1.2.3.4
$ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys   # на сервере
$ ssh -i ~/.ssh/id_ed25519 deploy@1.2.3.4 'echo ok'
# /etc/ssh/sshd_config.d/00-hardening.conf: PasswordAuthentication no, PermitRootLogin no
$ sudo sshd -t && sudo systemctl reload ssh      # в RHEL юнит sshd

Что здесь важно проговорить

  • ed25519, а не RSA: короче, быстрее, современная кривая. RSA берут, только если сервер древний, и тогда минимум 4096 бит.
  • Права. При StrictModes yes sshd молча откажет, если домашний каталог, ~/.ssh или authorized_keys доступны на запись группе или всем (Debian и Ubuntu прощают запись группе, если в ней нет никого, кроме владельца). Поэтому и ставят 700 и 600 - с запасом. Это самая частая причина «ключ не работает». Смотри ssh -vvv на клиенте и journalctl -u ssh на сервере.
  • Не закрывай текущую сессию, пока новым терминалом не проверил вход по ключу.
  • sshd -t перед reload. Синтаксическая ошибка в конфиге при restart оставит тебя снаружи.
  • Отдельный пользователь под деплой, root-логин выключен, sudo выдан по правилам.
  • Ключ для CI держи отдельно, без парольной фразы, с ограничениями прямо в authorized_keys: command="/usr/local/bin/deploy",no-pty,no-port-forwarding.
  • ~/.ssh/config с алиасами и ProxyJump bastion пригождается каждый день; ssh-agent избавляет от постоянного ввода фразы.
Дальше по уровню зрелости

Раздавать ключи руками не масштабируется. Дальше authorized_keys раскладывает Ansible или конфиг-менеджмент, потом идут SSH-сертификаты (короткоживущие, подписанные CA, отзывать ключи при увольнении не надо), потом bastion с записью сессий. На собесе достаточно упомянуть, что ты знаешь про этот путь.

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

  • Процесс изолирует адресное пространство, а потоки процесса делят его ресурсы.
  • Ядро планирует потоки; горутины планирует рантайм 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; повторю замер во времени при одинаковой нагрузке. Если число дескрипторов монотонно растёт, ищу путь, где файлы или сокеты не закрываются. Если число стабильно у лимита, выясняю, оправдан ли пик и закрываются ли ресурсы после ошибок.