Сервис упал ночью, в логах только строка о сегментной ошибке, воспроизвести сбой локально не выходит. Аварийный дамп памяти хранит точное состояние всех потоков в момент падения, а GDB умеет вытаскивать из него полный стек с локальными переменными. Задача делится на три части: убедиться, что ядро вообще пишет дампы, получить файл вместе с бинарником той же сборки и провести разбор. GDB читает формат ELF core и одинаково работает с образами от ядра и от службы сбора. Порядок ниже отработан до мелочей и занимает считаные минуты даже в часы пик.
Содержимое аварийного дампа и решения ядра о его записи
Когда процесс получает сигнал с фатальным действием вроде SIGSEGV или SIGABRT, ядро записывает образ памяти в файл, если лимит RLIMIT_CORE это позволяет. В дамп попадают страницы памяти всех потоков, регистры, карта отображений файлов и окружение процесса. Судьбу образа определяет параметр ядра /proc/sys/kernel/core_pattern: там лежит либо шаблон имени, либо конвейер на внешний обработчик. Шаблон поддерживает подстановки: %e подставляет имя программы, %p идентификатор процесса, %t время в секундах, %s номер сигнала, %h имя хоста, %% печатает сам знак процента. Значение core.%e.%p.%t раскладывает дампы по каталогу без путаницы, а каталог задают абсолютным путём. Формат независим от средства записи: образ, снятый на другом узле той же архитектуры, открывается так же, и это упрощает централизованный сбор на отдельном сервере. Сжатые образы переносятся по сети в разы быстрее сырых, поэтому центры сбора складывают их в архив с проверкой контрольных сумм. Текущее значение меняют командой sysctl -w kernel.core_pattern=/var/crash/core.%e.%p.%t, но каталог должен быть доступен процессу на запись, иначе ядро молча откажется от создания файла. Параметр kernel.core_uses_pid добавляет идентификатор процесса к имени, если шаблон его не содержит, и старые дампы не затираются новыми. При конвейерной записи параметр kernel.core_pipe_limit ограничивает параллельную обработку: ноль по умолчанию означает отсутствие лимита на число одновременно падающих процессов. Программы с повышенными привилегиями по умолчанию дампы не пишут, их поведение управляется параметром fs.suid_dumpable.
Настройка ulimit и сохранение дампов на сервере
Лимит задаётся командой ulimit -c, значение указывается в блоках по 1024 байта, слово unlimited снимает ограничение. Ключи -S и -H показывают мягкий и жёсткий лимиты по отдельности, а процессу важен мягкий. В текущем сеансе: ulimit -c unlimited. Постоянно: строки soft core unlimited и hard core unlimited в файле /etc/security/limits.conf:
* soft core unlimited
* hard core unlimited
Массовую настройку делают через конфигурационные шаблоны: Ansible или cloud-init вписывает лимиты в юниты при развёртывании, и новый узел сразу готов к сбору дампов. Для разовых проверок хватает временного ulimit в сеансе, но после закрытия терминала значение пропадает вместе с сеансом. Отдельной службе лимит назначает юнит systemd через директиву LimitCORE=infinity в секции Service, и после перезапуска службы проверка cat /proc/12345/limits показывает строку Max core size со значением unlimited. Уже работающему процессу значения меняет prlimit --core=unlimited:unlimited --pid 12345, для поднятия жёсткого лимита нужны права суперпользователя. Лимиты наследуются от родителя, поэтому настройку делают на входе в систему и в юнитах, а не вручную каждый раз. Нюанс с конвейером: если дампы уходят внешнему обработчику, ядру достаточно лимита хотя бы в один байт, и контроль размера берёт на себя сам обработчик.
Получение дампов от служб systemd и внутри контейнеров
Свежие дистрибутивы по умолчанию направляют дампы в службу systemd-coredump: в core_pattern стоит конвейер, служба сжимает образ и складывает в каталог /var/lib/systemd/coredump. Журнал падений смотрят командой coredumpctl list, подробности конкретного падения показывает coredumpctl info 12345: время, сигнал, стек и часть вывода. Выгрузку образа делает coredumpctl dump 12345 -o core.dump, а разбор сразу в отладчике запускает coredumpctl gdb 12345. Утилита принимает и шаблон: coredumpctl dump app печатает последний дамп программы app, что удобно при множестве падений. Размер ограничивается в файле /etc/systemd/coredump.conf: ProcessSizeMax=2G, ExternalSizeMax=2G, JournalSizeMax=767M, сжатие включено по умолчанию, а хранение настроено на внешний каталог. Значение ProcessSizeMax=0 полностью запрещает запись дампов, и его ставят службам, которые держат секреты в памяти. Параметр Storage=none оставляет только записи журнала без сохранения самих образов, что иногда разумно для узлов с малым диском. В контейнерах настройка общая для всей машины: параметр core_pattern живёт в ядре и не входит в пространство имён, поэтому дампы оказываются на хосте. Это стоит учитывать в кластере: файл ищут не внутри пода кластера, а на узле, и права на каталог проверяют заранее.
Первый запуск GDB на дампе и навигация по потокам
Запуск: gdb ./app core.3456. Отладчик печатает причину: Program terminated with signal SIGSEGV, Segmentation fault. Дальше работают стандартные команды: bt показывает стек текущего потока, info threads перечисляет все потоки с их текущими кадрами, thread apply all bt печатает стеки сразу всех, thread N переключает на нужный:
(gdb) info threads
Id Target Id Frame
* 1 Thread 0x7f2a4d (LWP 3457) 0x00007f2a4d0c2 in strlen ()
2 Thread 0x7f2a3c (LWP 3458) 0x00007f2a4c910 in read ()
Список связывает логический номер потока с LWP из планировщика, и по этому номеру поток легко найти в журнале системы. По кадрам перемещают frame 2, up и down, значения показывают print переменная, info locals и info args. Память просматривают x/32xg адрес, строки удобнее через x/s, а массивы печатают в развёрнутом виде конструкцией print *arr@10. Выравнивание вывода улучшает set print pretty on, а глубину раскрытия структур регулирует set print depth. Лимит печати длинных массивов и строк снимает set print elements 0, что спасает при разборе больших буферов. Дамп это снимок: указатели в нём валидны как адреса, но память живёт в файле, и изменений уже не будет. Адресное пространство и позиции библиотек зафиксированы, поэтому значения читаются так же, как при живой отладке.
Полный разбор стека командой bt full и поиск виновника
bt печатает кадры, а bt full добавляет значения локальных переменных в каждом кадре. Именно эта разница превращает загадочный адрес в конкретную строку и конкретное значение:
#0 0x00007f2a4d0c2 in strlen () from /lib/x86_64-linux-gnu/libc.so.6
#1 0x0000556644a1 in log_event (msg=0x0, len=-1) at logger.c:74
#2 0x0000556644b2 in main (argc=2, argv=0x7ffd1e) at main.c:33
Кадр с нулевым указателем сообщения и отрицательной длиной сразу объясняет падение в strlen. Если же bt выдаёт абсурдные адреса и знаки вопроса, стек повреждён: обычно виноват вызов функции с неверным типом или затёртый адрес возврата, и поиск продолжают в кадрах ниже. Строка Core was generated by в шапке подсказывает, какой бинарник искать, когда файлов несколько. Для оптимизированных сборок часть переменных показывается как <optimized out>: компилятор уложил их в регистры, и тут выручают info registers вместе с disassemble. Сборки без оптимизации или с уровнем -O1 дают полные значения, поэтому символы стоит собирать из той же конфигурации, что и релиз. Для многопоточных сервисов связка thread apply all bt full раскрывает картину целиком и показывает, кто держал блокировку, а кто ждал. Приём со смешением уровней помогает не утонуть в выводе: bt full по подозрительному потоку, а по остальным обычный bt, чтобы сравнить, чем заняты остальные нитки в момент сбоя. Регистры доступны напрямую: print $rax показывает значение регистра, а print (char*)$rdi читает строку из аргумента по соглашению вызова. На архитектуре x86_64 первые аргументы функций живут в регистрах, и в оптимизированном коде это главный источник значений.
Символы для релизных сборок от objcopy до debuginfod
Правильная схема хранит символы отдельно от пакета. Релиз собирается с флагом -g, затем objcopy --only-keep-debug app app.debug сохраняет отладочную информацию в отдельный файл, objcopy --strip-debug app снимает её из пакета, а objcopy --add-gnu-debuglink=app.debug app вшивает ссылку. GDB найдёт файл по ссылке или по build id: идентификатор смотрят readelf -n app, и отладчик сверяет его при загрузке, предупреждая о несовпадении сборок. Начиная с GDB 10 в отладчик встроен клиент debuginfod: при заданной переменной DEBUGINFOD_URLS он сам скачивает символы по идентификатору сборки с серверов проекта. Альтернативный путь дают утилиты elfutils: eu-strip -f app.debug app делает то же разделение одной командой, а eu-unstrip собирает всё обратно для лабораторного разбора. Хорошая привычка: вшивать в бинарник строку с версией и номером сборки, тогда соответствие проверяется одной командой strings app | grep version. Дамп привязан к конкретной сборке жёстко, поэтому бинарник и символы снимают из того же артефакта, из которого собран работающий процесс, и хранят артефакты столько же, сколько живут развёрнутые копии. Символы системных библиотек отдельно ставить не обязательно: отладчик напишет Missing separate debuginfo для libc, но собственные кадры приложения разберутся и без них. Версию библиотеки, попавшей на вершину стека, смотрят командой info sharedlibrary, и по ней определяют точный пакет.
Снятие дампа с живого процесса через gcore и порядок при инциденте
Ждать падения не обязательно: gcore 12345 снимает образ работающего процесса. На время записи утилита приостанавливает процесс механизмом ptrace, для больших резидентных наборов запись занимает секунды, поэтому тяжёлые сервисы снимают в часы наименьшей нагрузки. Принудительный дамп делает сигнал SIGABRT: команда kill -s SIGABRT 12345 при настроенном лимите пишет образ, после чего процесс завершается, и это применяют, когда других способов сохранить состояние нет.
Собранные материалы складывают в один каталог вместе с выводом dmesg за время сбоя, и появляется полный комплект для разбора. Последовательность действий выглядит так:
- Убедиться, что лимиты и шаблон core_pattern настроены заранее, до первого сбоя;
- Получить дамп командой coredumpctl dump либо снять живой процесс утилитой gcore;
- Забрать бинарник и символы той же сборки и сверить build id;
- Открыть дамп в GDB и проверить номер сигнала и кадр на вершине стека;
- Прогнать thread apply all bt full и найти аномальные значения и нулевые указатели;
- Сопоставить кадры с кодом работавшей версии и приложить вывод к дефекту.
Типичные промахи хорошо известны. Лимит снова нулевой после перезапуска службы, потому что настройки из limits.conf применяются при входе в систему, а юнит получает свои значения при старте. Бинарник не совпадает с дампом, и трассировка врёт адресами, поэтому сверка идентификатора сборки обязательна. Каталог из шаблона недоступен для записи, и дамп тихо не создаётся, а в контейнере его ищут не на том уровне. Сборка с уровнем -O3 прячет почти всё в регистры, и bt full возвращает сплошные optimized out, поэтому для отладочных конфигураций придуман уровень -Og. Дамп весит примерно столько же, сколько резидентная память процесса, и на узлах с сотнями сервисов место на диске планируют заранее.
Разбор дампа выигрывает у логов главное: он показывает не догадку, а состояние памяти в момент сбоя. Проверенный сценарий стоит прогнать на тестовой машине заранее: снять образ работающего сервиса, открыть его и пройти по кадрам, чтобы в реальный инцидент не тратить время на изучение команд. Один настроенный на всём парке механизм снятия образов превращает ночной сбой из загадки в двадцать минут работы, и это лучший аргумент потратить вечер на настройку лимитов и конвейера символов. Регулярный сбор приучает и к порядку в артефактах: без выстроенного хранения бинарников и символов отладка превращается в археологию по случайным файлам.