open atlas
↑ К треку
Инженерная практика ENG · 08 · 02

Core dump-ы и post-mortem: ulimit, core_pattern, сопоставление по build-id и разматывание SIGSEGV постфактум

Core dump замораживает упавший процесс для офлайн-вскрытия — но лишь если ulimit -c и core_pattern разрешают. Дампы достигают гигабайтов. gdb сопоставляет дамп с символами по build-id; несовпавшая сборка даёт мусорный стек — классическая ловушка works-in-dev, cores-in-prod.

ENG Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Платёжный сервис падал раз в неделю с SIGSEGV, который никто не мог воспроизвести, и два месяца у команды была лишь однострочная запись в логе. Первый фикс был не в коде — а в обнаружении, что ulimit -c в контейнере был 0, поэтому ядро выбрасывало каждый core dump. Его выставили, и следующее падение оставило core на 6 ГБ. gdb открыл его и напечатал стек, что был чистым мусором: кадры ??, адреса без символов, backtrace, указывающий в никуда. Причина — вторая ловушка: бинарь в работающем контейнере был пересобран и передеплоен с момента локальной копии инженера, поэтому build-id не совпадали, и gdb разматывал дамп против неверной таблицы символов. Как только из реестра вытащили точный бинарь по его build-id, те же 6 ГБ замороженной памяти размотались в чистый стек, кончающийся use-after-free в пути ретраев — баг, что был невидим два месяца, всё это время сидел в дампе.

Генерация core: ulimit, core_pattern, coredumpctl

Прежде чем вскрывать падение, ответь на более жёсткий вопрос: ядро вообще сохранило дамп? Большинство команд узнают о настройке захвата core в самый неподходящий момент — когда баг, который им был нужен, уже исчез.

Core dump — это запись ядром образа памяти падающего процесса (адресное пространство, регистры, стек каждого потока) в файл, чтобы вскрыть его после исчезновения процесса. Но три гейта решают, получишь ли ты его. Первый — попроцессный лимит ресурсов ulimit -c: если он 0 (дефолт во многих базовых образах контейнеров и юнитах systemd), ядро тихо отбрасывает дамп. Второй — /proc/sys/kernel/core_pattern решает, куда он идёт: буквальный шаблон пути вроде core.%p.%e или, если начинается с |, пайп к программе-обработчику. На современных systemd-хостах core_pattern пайпит в systemd-coredump, и дампы достаёшь через coredumpctl list и coredumpctl gdb <pid> вместо охоты за файлом. Третий — в контейнерах лимит и шаблон живут в namespace ядра хоста, поэтому core_pattern, что пайпит к обработчику, отсутствующему в mount namespace контейнера, проваливается — частая причина «я выставил ulimit и всё равно ничего».

Размер не мал. Core содержит всю записываемую отображённую память, поэтому сервис, держащий несколько гигабайтов кучи, рождает многогигабайтный core — 6 ГБ из Hook обычны. Это имеет операционный вес: дампы могут забить диск и занимают секунды записи, пока процесс мёртв, поэтому прод-настройки их ограничивают, направляют на выделенное хранилище и иногда срезают ненужные отображения. Дамп нужен, но планируй его размер.

Викторина

Контейнеризованный сервис падает с SIGSEGV, но core-файл так и не появляется, даже после добавления разработчиком 'ulimit -c unlimited' в entrypoint. Какая наиболее вероятная оставшаяся причина?

Почему это работает

Почему обработчик core_pattern живёт на хосте даже для падения в контейнере? Потому что сброс core — действие ядра, а core_pattern — единственная хост-глобальная настройка ядра: попроцессного core_pattern не бывает. Когда процесс в контейнере получает segfault, ядро хоста вычисляет хостовый core_pattern и запускает хостовый обработчик. Поэтому идеально корректный ulimit -c unlimited внутри контейнера всё равно направляет дамп через то, что говорит core_pattern ноды, а на ноде Kubernetes это часто путь или обработчик, что контейнер никогда не видит. Урок: настраивай захват core на уровне ноды, а не только образа.

Символы, build-id и расхождение dev-vs-prod

У тебя есть дамп — почему gdb печатает одни ??? Почти всегда причина — расхождение build-id (хеш, вшитый в бинарь на этапе линковки), и это ловит опытных инженеров чаще, чем новичков, потому что выглядит как баг отладчика, а не как gap в процессе.

Core dump — это сырая память и значения регистров, он понятия не имеет, что за функция 0x4a3f10. Чтобы превратить адреса в имена функций и номера строк, нужны совпадающие debug-символы, и «совпадающие» точно: каждый ELF-бинарь (ELF — Executable and Linkable Format, стандартный формат исполняемых файлов Linux) несёт build-id, хеш, впечённый на линковке, а дамп записывает build-id бинаря и разделяемых библиотек, что были загружены. gdb (или debuginfod) использует этот build-id, чтобы достать точный .debug-файл. Если навести gdb на бинарь, что был перекомпилирован — пусть из того же исходника с другим timestamp или флагом компилятора, — build-id различаются, таблица символов больше не ложится на адреса в дампе, и получаешь мусорный стек из Hook: кадры ?? и бессмысленные номера строк. Это ловушка «works in dev, cores in prod»: твоя локальная пересборка — не прод-бинарь, даже если исходник идентичен.

Профессиональная настройка разделяет заботы: шипи стрипнутый бинарь в прод (мелкий, без .debug), храни полную debug-инфу как отдельный .debug-файл, совпадающий по build-id, и дай debuginfod или твоему символ-серверу отдавать её по требованию. Тогда core из прода размотается против точных символов, хоть работающий бинарь не нёс ни одного. Проверяй совпадение явно — readelf -n core и readelf -n binary показывают build-id; если различаются, стоп, ведь каждый кадр, что печатает gdb, — ложь.

Разматывание SIGSEGV постфактум

С совпавшим core и символами вскрытие механично. bt идёт по сохранённым кадрам стека от точки падения внутрь, используя DWARF CFI (call-frame information), чтобы найти кадр каждого вызывающего даже при кастомной раскладке кадра. Верхний кадр — где ударил fault; info registers показывает адрес сбоя (на x86 затронутый адрес в регистре, что записывает ядро), и SIGSEGV по адресу 0x0 кричит о null-разыменовании, а дикий высокий адрес намекает на испорченный указатель или use-after-free. Можно frame N в любой вызывающий, print локалы (с оговорками про оптимизацию из прошлого урока) и прочесть код _exit: core от SIGABRT обычно значит ассерт или обнаруженную libc порчу кучи (free(): invalid pointer), указывая на собственный диагноз аллокатора, а не на сырой fault. Дамп — единственный замороженный миг, без реплея и шагов, поэтому реконструируешь историю из замороженного стека, регистров и кучи — ровно поэтому use-after-free, что падает лишь иногда, становится находимым: одного захваченного мига достаточно. Теперь, когда увидишь кадры ?? в gdb, первый вопрос не «дамп битый?», а «совпадают ли build-id?» — запусти readelf -n на обоих, прежде чем читать хоть один кадр.

Викторина

gdb открывает 6-ГБ прод-core, но backtrace — сплошь кадры '??' без имён символов, тогда как тот же gdb показывает чистый стек на core с твоего ноутбука. Наиболее вероятная причина?

Вспомните перед уходом
  1. 01
    Какие три гейта решают, родит ли падающий контейнеризованный процесс пригодный core dump?
  2. 02
    Почему прод-core иногда разматывается в кадры ?? и как build-id это чинит?
Итог

Core dump — это снимок ядром умирающего процесса: его адресное пространство, стек каждого потока и регистры, записанный, чтобы провести вскрытие после исчезновения процесса, превращая невоспроизводимое падение в замороженный миг, что осматриваешь не спеша. Но захват гейтован. ulimit -c должен быть ненулевым, а он по дефолту ноль во многих образах контейнеров и юнитах systemd, поэтому самое первое падение часто рождает ничего. /proc/sys/kernel/core_pattern решает назначение: шаблон пути или пайп к обработчику вроде systemd-coredump, достаётся через coredumpctl. Принципиально, core_pattern — единственная хост-глобальная настройка ядра без попроцессного переопределения, поэтому падение в контейнере направляется шаблоном ноды через обработчик ноды — вот почему корректный ulimit внутри контейнера всё равно даёт ничего, если захват не настроен на ноде. Планируй и размер: core держит всю записываемую память, поэтому многогигабайтные дампы вроде 6 ГБ из Hook обычны и могут забить диски. Чтение core требует символов, совпавших по build-id, хешу с линковки, записанному для бинаря и каждой загруженной библиотеки. Наведи gdb на перекомпилированный бинарь — пусть из идентичного исходника — и build-id разойдутся, таблица символов больше не ляжет на адреса дампа, и bt размотается в мусор ??. Это ловушка works-in-dev, cores-in-prod: твоя локальная пересборка — не прод-бинарь. Профессиональный путь шипит стрипнутые бинари, хранит полный .debug, совпавший по build-id, отдаёт его через debuginfod и проверяет id через readelf -n до доверия кадру. С совпавшим core вскрытие механично: bt идёт по кадрам через DWARF CFI (call-frame information — таблица раскрутки стека, вшитая компилятором), info registers даёт адрес сбоя (0x0 значит null-разыменование, дикий адрес намекает на use-after-free или порчу), frame и print читают цепочку вызывающих, а core от SIGABRT указывает на ассерт или собственный диагноз порчи кучи от libc — всё из одного замороженного мига, без реплея. Теперь, когда столкнёшься с невоспроизводимым падением в проде, чеклист начинается с двух вопросов до открытия gdb: ненулевой ли ulimit на ноде и совпадает ли build-id твоего бинаря с тем, что упал?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.