24. strace, ltrace и чтение поведения процесса

Зачем лезть в процессы без gdb?

Когда бэкенд-приложение виснет, медленно отвечает или потребляет память, логи часто молчат. Они не показывают, какие системные ресурсы блокируют поток, или почему процесс не возвращается с сетевого вызова.

"Логи программы — это всего лишь дневник ее благих намерений. Когда процесс намертво зависает, он уже не может ничего записать в лог. Но ядро операционной системы помнит каждый сделанный шаг и никогда не врет."

Здесь на сцену выходят strace и ltrace — инструменты отладки на уровне ядра и динамической линковки, которые позволяют увидеть реальный диалог процесса с ОС.

Для Go-разработчика понимание этих утилит критично, потому что Go скрывает от нас нативные потоки, планирует горутины в пользовательском пространстве и по умолчанию линкуется статически. Это ломает привычные паттерны отладки из C/C++ или Java. Запустить отладчик gdb на боевом сервере под нагрузкой — верный способ уронить сервис. А вот умение быстро снять трассировку системных вызовов — ключевой навык инженера при разборе инцидентов.


strace: Трассировка системных вызовов

strace перехватывает все системные вызовы (syscalls) и сигналы, передаваемые процессом ядру. Это самый надежный способ понять, с чем процесс взаимодействует на уровне ОС.

Принцип работы и ключевые флаги

Bash
# Запуск нового процесса с трассировкой
strace -f -T -s 256 -o app.strace ./myapp

# Подключение к уже работающему процессу
strace -p 12345 -e trace=network,read,write -c

# Фильтрация только сетевых и файловых вызовов
strace -e trace=network,read,write,epoll_pwait -p $(pidof myapp)
Флаг Назначение
-f Отслеживать fork/exec дочерних процессов (критично для Go)
-T Показывать время выполнения каждого syscall в микросекундах
-c Суммарная статистика (count, time, errors) после завершения
-s Максимальная длина выводимой строки (по умолчанию 32 символа)
-e Фильтр вызовов (например, trace=network или trace=epoll_pwait)

Под капотом: ptrace и цена перехвата

strace работает через системный вызов ptrace(2). Это не магия, а официально поддерживаемый ядром механизм отладки (сродни тому, как работает GDB или Go debugger dlv).

  1. Трассировщик вызывает ptrace(PTRACE_ATTACH, pid, 0, 0).
  2. Ядро ставит трассируемый процесс на паузу и посылает ему сигнал SIGSTOP.
  3. strace использует ptrace(PTRACE_SYSCALL, ...) для установки точки останова на каждом входе и выходе из syscall.
  4. При вызове syscall процесс прерывается, управление передается strace. Тот снимает регистры, печатает вызов, восстанавливает контекст и разрешает выполнение дальше.
Assembly
; x86-64 syscall entry
mov rax, <номер_вызова>
mov rdi, <аргумент_1>
mov rsi, <аргумент_2>
; ...
syscall
; Возврат: результат в rax
sequenceDiagram
    autonumber
    participant Target as Целевой процесс (Go)
    participant Kernel as Ядро Linux
    participant Strace as Утилита strace

    Target->>Kernel: Инструкция SYSCALL (например, read)
    Note over Kernel: Точка останова PTRACE_SYSCALL
    Kernel->>Target: Остановка потока (SIGTRAP)
    Kernel->>Strace: Уведомление о входе в syscall
    Strace->>Kernel: Чтение регистров RAX, RDI, RSI...
    Strace->>Strace: Форматирование строки: read(3, buf, 4096)
    Strace->>Kernel: Возобновить выполнение (PTRACE_SYSCALL)
    Kernel->>Kernel: Реальное выполнение системного вызова
    Note over Kernel: Точка останова при возврате
    Kernel->>Strace: Уведомление о выходе из syscall
    Strace->>Kernel: Чтение результата из RAX (4096)
    Strace->>Strace: Допечатать: = 4096 <0.000045>
    Strace->>Kernel: Возобновить работу процесса
    Kernel->>Target: Возврат в User Space
  

ltrace: Трассировка библиотечных вызовов

ltrace перехватывает вызовы динамических библиотек (libpthread, libc, libssl и т.д.). Как и strace, он работает через ptrace (и требует тех же прав на трассировку процесса), но перехватывает не системные вызовы, а вызовы библиотечных функций.

Принцип работы

ltrace не перехватывает системные вызовы, а ловит вход в библиотечные функции, используя ptrace и точки останова:

  1. При старте процесса ld.so загружает нужные .so файлы.
  2. ltrace через ptrace (PTRACE_TRACEME / PTRACE_ATTACH) берёт процесс под контроль.
  3. Он находит в ELF-бинарнике таблицы импорта (.rela.plt) и ставит точки останова (int 3) на записи PLT.
  4. Когда процесс натыкается на точку останова, ядро останавливает его и будит ltrace; тот логирует вызов, возвращает исходную инструкцию и продолжает процесс.
Bash
ltrace -e malloc,free,calloc ./myapp
ltrace -c ./myapp  # Статистика по библиотекам
flowchart LR
    subgraph DynamicApp["Динамический бинарник (C/Python/Ruby)"]
        CallPLT["Вызов malloc()"] --> PLT[".plt секция (Trampoline ltrace)"]
        PLT --> Intercept["Логирование в ltrace"]
        Intercept --> RealLib["Реальный вызов malloc() в libc.so"]
    end

    subgraph GoApp["Go бинарник (Статическая линковка)"]
        GoCall["Вызов runtime.mallocgc()"] --> Direct["Прямой CALL по фиксированному адресу"]
        Direct --> InternalAlloc["Встроенный аллокатор Go (нет PLT/GOT)"]
    end

    style DynamicApp fill:#1e293b,stroke:#818cf8,color:#fff
    style GoApp fill:#064e3b,stroke:#34d399,color:#fff
  

Почему ltrace почти бесполезен в Go?

Go-компилятор по умолчанию создает статически линкованные бинарники (-linkmode=external или встроенные библиотеки). Это означает:

  1. Нет динамических символов: ld.so не знает, какие функции нужно перехватывать.
  2. CGO меняет правила: Если приложение использует CGO, ltrace может видеть вызовы C-библиотек, но не видит вызовы внутри Go-рантайма (runtime.malloc, netpoll, epoll_pwait).
  3. Нет PLT: ltrace работает через ptrace и точки останова на записях PLT (механизм LD_PRELOAD он не использует). У статически собранного Go-бинарника PLT нет, а собственный аллокатор Go malloc из libc не вызывает.

Практический пайплайн отладки для Backend

Как Senior-инженер, я не запускаю strace наугад. Я использую его как диагностический скальпель:

1. Поиск блокировок и hang-состояний

Если сервис не отвечает, подключаемся к PID и смотрим, где он застрял:

Bash
strace -p $(pidof myapp) -T -e trace=epoll_pwait,futex,read,write
  • Если видим epoll_pwait(-1, ...) -> это обычно штатное ожидание: простаивающие потоки M спят в epoll_pwait (с таймаутом до ближайшего таймера или -1). Сам по себе такой вызов не признак бага netpoller.
  • Если видим futex(0x..., FUTEX_WAIT, ...) -> чаще всего это штатный сон простаивающих потоков M. Блокировку горутины на sync.Mutex или канале паркует планировщик Go без системного вызова, так что по futex о захваченном мьютексе судить нельзя; для этого используйте профили mutex, block и goroutine.
  • Если видим recvfrom(0, ...) -> процесс ждет данные из сокета.

2. Анализ дисбаланса IO

Bash
strace -c -p PID

Вывод покажет, на какие вызовы уходит 90% времени. Если write или read доминируют — проблема в синхронном IO или отсутствии буферизации. Если epoll_pwait занимает много времени — проверьте, не переключает ли Go слишком много горутин в user-space без реального ожидания.

3. Сетевые проблемы

Bash
strace -e trace=network -p PID

Покажет connect, sendto, recvfrom, setsockopt. Если видим ECONNREFUSED или ETIMEDOUT — проблема на уровне сети или TIME_WAIT/CLOSE_WAIT состояний (см. 50. TIME_WAIT, CLOSE_WAIT и проблемы сокетов).

4. Альтернативы для production

strace слишком тяжел для боевого продакшена. Вместо него используют:

  • perf trace / bpftrace — перехват через eBPF, минимальный оверхед (~0.1–1%).
  • cat /proc/PID/syscall — быстрый снимок текущего состояния без отладчика.
  • gdb -p PID -batch -ex "info goroutines" — для Go-специфичной диагностики.

Итог

strace и ltrace — это диагностические инструменты, которые показывают "честный" слой взаимодействия приложения с ОС. Для Go-разработчика важно помнить:

  1. strace видит только ОС-потоки (M), а не горутины. Планирование горутин происходит в user-space и невидимо для трассировщика.
  2. Оверхед ptrace делает strace инструментом для разработки и staging, а не для production-мониторинга.
  3. ltrace бесполезен для чистого Go из-за статической линковки и собственного рантайма.
  4. Комбинация strace -T + pprof + netpoller логирования дает полную картину: от системного уровня до Go-рантайма.

Мы разобрали, как наблюдать за процессом на уровне ядра. Но чтобы понять, почему процесс делает именно эти вызовы, нужно знать, как ОС управляет ресурсами, особенно файлами и дескрипторами. В следующей статье мы закроем фундаментальный концепт Linux: 25. Файловые дескрипторы. Все в Linux является файлом.