Утром 9 июня 2026 на сервере начали массово падать воркеры nginx с SIGSEGV. За несколько минут мастер-процесс успел перезапустить их больше двадцати раз — и каждый новый воркер падал снова. В этой статье я разберу полный процесс диагностики: от первых строчек в логах до получения backtrace из core dump и решения через пересборку динамического модуля.
Статья задумывалась как однодневный кейс. Но на следующее утро история получила второй акт: мой корректный фикс сам стал причиной новых крашей. Поэтому здесь же — разбор редкого случая, когда security-обновление Ubuntu ломает ABI динамических модулей, и выводы про unattended-upgrades, который в этой истории оказался одновременно виновником и спасителем.
В error.log появились вот такие записи:
Signal 11 — это SIGSEGV, классический сегфолт. Три воркера за две секунды означают, что проблема воспроизводится стабильно и, скорее всего, связана с конкретным запросом или инициализацией. Мастер-процесс запускает новый воркер, тот падает снова — и так по кругу.
Первым делом я решил посмотреть, что предшествовало крашам. Команда grep -B 20 даёт контекст до нужных строк:
Самое интересное в выводе — вот эта строка, появившаяся за девять секунд до первого краша:
Это сообщение из модуля файлового кэша nginx: запись в proxy-кэше намертво залочена. Первая гипотеза — повреждённый кэш-файл, с которым воркеры не могут нормально работать. Параллельно смотрю в access.log на промежуток времени между этим алертом и первым крашем — подозрительных запросов нет.
Параллельно проверяю сборку:
В выводе замечаю несколько важных вещей: флаг --with-cc-opt='-g' означает, что в бинаре встроены отладочные символы. Среди модулей есть --with-http_perl_module=dynamic — Perl-модуль, известный нестабильностью при работе с несколькими воркерами. И есть путь к динамическим модулям: --modules-path=/usr/lib/nginx/modules.
Очищаю кэш и перезапускаю nginx:
Крашей стало меньше, но они не прекратились. Значит, кэш — не причина, а совпадение по времени.
Проверяю, реально ли загружается Perl-модуль:
Вывод разочаровывает: Perl-модуль не загружается — только MIME-тип в mime.types. Ещё один ложный след.
В выводе error.log есть пометка core dumped. Смотрю, куда ядро пишет дампы:
Вывод:
Дампы перехватывает apport — Ubuntu-инструмент для сбора отчётов о крашах. Они хранятся в /var/crash/:
Вижу файл _usr_sbin_nginx.*.crash. Распаковываю:
Файл CoreDump на 339 МБ — это полный дамп памяти воркера в момент краша.
Устанавливаю gdb. С nginx-dbgsym — пакетом отладочных символов — возникает конфликт версий: в ddebs-репозитории лежит символьный пакет для версии 1.24.0-2ubuntu7.9, а установлен nginx 1.24.0-2ubuntu7.10. Но это не критично: флаг -g в сборке означает, что символы уже встроены в сам бинарь.
Вывод однозначный:
Причина найдена. Модуль ngx_http_headers_more_filter_module передаёт битый указатель в ngx_strncasecmp(). Функция пытается читать по невалидному адресу — SIGSEGV.
Смотрю на дату файла модуля:
Апрель 2024 года. Проверяю политику пакетов:
Картина проясняется:
При обновлении nginx пакет с модулем не подтянулся. Nginx обновился, ABI изменился, модуль остался старый — и при первом же обращении к заголовкам ответа падает с сегфолтом.
Здесь стоит ответить на вопрос, который напрашивается сам: почему nginx вообще загрузил несовместимый модуль без единого предупреждения? Потому что проверка совместимости у load_module — это сравнение сигнатуры: версия nginx, размеры базовых типов, набор включённых при сборке фич. Раскладку внутренних структур сигнатура не покрывает. Если в структуре сдвинулись поля, модуль загрузится молча, а упадёт уже в рантайме — на первом же чтении по «уехавшему» смещению. Ровно это и показал бэктрейс: модуль читает то, что считает указателем на имя заголовка, и передаёт его в ngx_strncasecmp().
Проверяю, где именно модуль используется:
Одна строка в nginx.conf:
Просто подмена заголовка Server. Нативный add_header здесь не поможет — он не перезаписывает встроенные заголовки nginx.
Поскольку в репозиториях нет совместимой версии, собираю модуль самостоятельно под точную версию установленного nginx.
Шаг 1. Зависимости для сборки
Шаг 2. Исходники nginx точно под установленную версию
Добавляю deb-src репозитории (без них apt source не работает):
Проверяю, что версия совпадает:
Точное совпадение — важно. Здесь мне повезло: самая свежая версия в репозитории совпала с установленной. Вообще apt source без указания версии тянет самую новую из доступных, а не установленную — правильнее жёстко пинить:
Почему это важно, станет ясно во втором акте.
Шаг 3. Исходники headers-more-nginx-module
Клонирую сразу в /opt, а не в /tmp — исходники модуля ещё пригодятся.
Шаг 4. Configure и сборка только динамического модуля
Ключевой флаг — --with-compat: он включает режим совместимости для динамических модулей, выравнивая сигнатуру под установленный nginx:
Полная пересборка nginx не нужна — make modules собирает только .so-файл.
Шаг 5. Замена модуля
Шаг 6. Проверка и перезапуск
Пустой вывод. Крашей больше нет.
Проверяю, что заголовок подменяется корректно:
Всё работает как должно.
Первая версия этой статьи заканчивалась предыдущим абзацем. Утром 10 июня error.log встретил знакомой картиной: те же exited on signal 11 (core dumped), мастер снова перезапускает воркеры по кругу. Вчерашний фикс был проверен и curl'ом, и часом наблюдения за логами — значит, ночью что-то изменилось.
Сначала возвращаю сайт к жизни. Комментирую единственную директиву модуля в nginx.conf и перезапускаюсь:
Краши прекратились. Логично: фильтр headers-more без сконфигурированных команд не трогает заголовки, и до чтения по битым смещениям дело не доходит. Но сам несовместимый .so по-прежнему загружен в мастер-процесс — это костыль, а не решение. Забегая вперёд: в LP-баге, до которого мы дойдём ниже, есть отчёты о периодических крашах даже при загруженном, но не используемом модуле.
Теперь диагностика. В /var/crash/ уже лежит свежий дамп — вчерашний gdb-рецепт даёт тот же бэктрейс с ngx_strncasecmp() из headers-more. Только модуль теперь не апрельский из пакета, а мой, вчерашней сборки:
Что изменилось за ночь, рассказывает dpkg.log:
Нюанс: zgrep по нескольким файлам выводит совпадения в порядке перебора файлов, а не по времени — без sort свежие строки легко проглядеть.
В 06:55 утра, пока я спал, nginx обновился с .10 на .11. Смотрю, что внутри:
SECURITY REGRESSION. ABI change breaking external modules. Всё, что я вчера раскапывал через gdb, — здесь одной строкой.
По ссылке из changelog — баг с важностью Critical и тегом regression-update:
https://bugs.launchpad.net/ubuntu/+source/nginx/+bug/2155992
Хронология по нему восстанавливается полностью.
Security-обновление 1.24.0-2ubuntu7.10, вышедшее утром 9 июня, содержало патч для CVE-2026-49975, который добавил поле в середину одной из внутренних структур nginx. Все поля после него сдвинулись — и каждый сторонний динамический модуль, собранный против старой раскладки, начал читать мусор по старым смещениям. Под раздачу попал весь зоопарк из universe: headers-more, uploadprogress, modsecurity, lua, passenger, xslt, nchan. У части пользователей воркеры падали, даже когда модуль был просто загружен через modules-enabled и ни одна его директива не использовалась. Обновление приехало всем через unattended-upgrades, так что в баге быстро собрались отчёты вида «упал весь парк серверов». Затронуты оказались все поддерживаемые релизы — от 22.04 до 26.04, у каждого свой набор сломанных версий.
Canonical отреагировала в тот же день: мейнтейнер security-команды выпустил 1.24.0-2ubuntu7.11, где проблемный патч просто отключён «до выяснения». ABI вернулся к старому, апрельскому. Пересобирать пакеты libnginx-mod-* сочли ненужным — после отката они снова совместимы.
А теперь сама ирония, по шагам:
Я собрал модуль под новый ABI быстрее, чем Canonical успела этот ABI откатить. Фикс был правильным — он просто прожил ровно сутки. Если бы я 9-го числа ничего не делал, утренний апдейт 10-го починил бы всё сам. Но кто ж знал.
Раз ABI откатили к старому, совместим снова дистрибутивный модуль апреля 2024 года. Возвращаю самосборный .so на пакетный:
Контроль: переустановленный файл должен побайтово совпасть с вчерашним бэкапом — это ведь тот же пакет той же версии 1:0.37-2build1:
Суммы совпадают — круг замкнулся. Раскомментирую more_set_headers и перезапускаю:
Именно restart, не reload. Мастер-процесс держит уже загруженный .so в памяти; при reload dlopen по тому же пути вернёт старую копию, и подменённый на диске файл в работу не пойдёт. Новый бинарь модуля применяется только полным перезапуском.
Проверки:
Прибираю за собой — бэкап и сборочные каталоги больше не нужны:
Я автообновления не настраивал. Но в Ubuntu Server их и не нужно настраивать: unattended-upgrades включён из коробки. Пакет входит в стандартную установку, при установке сам создаёт /etc/apt/apt.conf.d/20auto-upgrades и никакого явного согласия не спрашивает:
Запускают его systemd-таймеры: apt-daily.timer обновляет списки пакетов, apt-daily-upgrade.timer ставит обновления — по расписанию в 6:00 со случайной задержкой до часа:
Сверяю с историей в dpkg.log: 28 апреля — 06:23, 16 мая — 06:56, 10 июня — 06:55. Почерк совпадает. По умолчанию ставятся только пакеты из кармана -security — оба обновления nginx (.10 и .11) шли именно через noble-security, поэтому и прилетели. Полный журнал работы — в /var/log/unattended-upgrades/unattended-upgrades.log: там видны оба прогона, 9 и 10 июня.
Кстати, те самые 14 неприменённых версий ядра, которые всплыли в первый день, — тоже его работа: ядра приезжали автоматически, а перезагрузка по умолчанию выключена (Automatic-Reboot "false").
Главный источник сюрпризов в этой истории — не сам баг, а то, что обновления приезжают молча. Дальше три стратегии работы с автообновлениями, от мягкой к радикальной, и общие меры, нужные при любой из них.
Вариант 1. Оставить автообновления, включить почтовые отчёты
За двое суток unattended-upgrades успел и сломать прод (.10), и сам же его починить (.11), пока я спал, — счёт 1:1. Отключать механизм, который чинит сам себя, я не готов, но хочу знать о каждом его ходе. Настройка — локальным drop-in'ом, чтобы не трогать конфиг пакета (50unattended-upgrades — это conffile, при обновлениях пакета правки в нём будут конфликтовать):
on-change — письмо только когда что-то реально установлено (есть ещё always и only-on-error). Нужен рабочий sendmail-совместимый MTA, и root должен куда-то резолвиться через /etc/aliases — либо пишите адрес явно. У меня на этой же машине живёт почтовый сервер, так что вопрос закрыт. Проверить без установки чего-либо:
С этой настройкой вся утренняя загадка второго дня — «я ничего не менял, почему всё сломалось?» — решалась бы одним письмом во входящих.
Вариант 2. Вывести nginx-стек из-под автообновлений
Компромисс: всё остальное обновляется само, а nginx и его модули — только руками, в окно обслуживания и со smoke-тестом. В тот же drop-in:
Записи в списке — регулярные выражения с привязкой к началу имени пакета: две строки накрывают nginx, nginx-common, nginx-extras и все libnginx-mod-*. Цену компромисса надо понимать: задержка security-патчей nginx становится вашей личной ответственностью, а этой весной noble патчил nginx почти ежемесячно — .7 в апреле, .8 в мае, .10 и .11 в июне.
Вариант 3. Полностью отключить автообновления
Мой выбор. Радикальный путь для тех, кто хочет, чтобы на сервере менялось ровно то, что меняет администратор:
Или руками в /etc/apt/apt.conf.d/20auto-upgrades:
Первую строку оставляю в «1»: обновление списков само ничего не ставит, зато apt list --upgradable всегда показывает актуальную картину. Таймеры apt-daily трогать не нужно — при «0» прогон завершается, ничего не делая.
Цену тоже считаем честно. В этой истории автообновления сначала сломали прод, а потом сами же его починили; отключая их, вы забираете себе обе роли. Это обязательный ритуал: регулярное окно (хотя бы раз в неделю), в нём — apt update, осмотр apt list --upgradable, осознанная установка, smoke-тест после. Плюс подписка на рассылку ubuntu-security-announce (USN), чтобы критичные патчи не ждали планового окна.
Общее: исправленный скрипт пересборки
Скрипт пересборки остаётся аварийным инструментом — на случай нового окна, когда репозиторный модуль несовместим с текущим nginx, как 9 июня. Но второй день вскрыл в моей первой версии два бага. Первый: apt source без пина тянет самую свежую версию из репозитория, а не установленную — запусти я его утром 10-го против ещё не обновлённого nginx, собрал бы модуль под .11 для работающего .10. Второй: reload не применяет подменённый .so — мастер держит старую копию в памяти, нужен restart. Исправленная версия:
В deb-src источники стоит добавить и noble-security: security-версии копируются в noble-updates, но не мгновенно.
И главное правило, которое я вывел за эти двое суток: самосборный .so — это не фикс, а обязательство. Он привязан к конкретному ABI и живёт до первого его изменения в любую сторону. После каждого обновления nginx выбор один из двух: пересобрать заново или вернуться на пакетный модуль, если ABI снова совпадает.
Общее: smoke-тест после любого обновления nginx
Три команды, тридцать секунд:
Пришло письмо от unattended-upgrades с nginx в списке (вариант 1) или обновились руками (варианты 2–3) — прогнали.
Общее: мониторинг воркеров
Если nginx падает, мониторинг должен сигналить раньше, чем это заметите вы. Через Netdata или простую проверку:
Наличие таких строк — немедленный алерт.
Общее: следить за судьбой CVE-2026-49975
Важно понимать: в .11 патч именно отключён, а не переписан — CVE-2026-49975 в noble прямо сейчас снова не закрыта. Canonical обещает перевыпустить исправление после разбирательства; в баге уже предлагают очевидный путь — добавлять поле в конец структуры, не ломая смещения. Гарантий нет, поэтому следующее security-обновление nginx я встречу подписанным на LP #2155992 и со smoke-тестом наготове.
Общее: перезагружаться в плановое окно
Из первого дня: сервер работал на ядре 6.8.0-110-generic при установленном 6.8.0-124-generic — 14 версий без перезагрузки, которые втихую привёз всё тот же unattended-upgrades. К крашам nginx это отношения не имело, но рассинхрон ядра и userspace — мина замедленного действия. Регулярный ребут в окно обслуживания закрывает вопрос.
Двое суток, одна строка конфига, две зеркальные поломки. Ключевые точки:
Если nginx падает с SIGSEGV и у вас есть динамические модули — первым делом смотрите даты и версии .so и последние строки dpkg.log. Иногда честный ответ на вопрос «что я вчера сломал?» — «ничего: это приехало ночью». И иногда самый надёжный фикс — тот, который способен пережить следующее утро.
Статья задумывалась как однодневный кейс. Но на следующее утро история получила второй акт: мой корректный фикс сам стал причиной новых крашей. Поэтому здесь же — разбор редкого случая, когда security-обновление Ubuntu ломает ABI динамических модулей, и выводы про unattended-upgrades, который в этой истории оказался одновременно виновником и спасителем.
Симптомы
В error.log появились вот такие записи:
Bash:
2026/06/09 08:59:25 [alert] 794004#794004: worker process 794042 exited on signal 11 (core dumped)
2026/06/09 08:59:25 [alert] 794004#794004: worker process 794044 exited on signal 11 (core dumped)
2026/06/09 08:59:27 [alert] 794004#794004: worker process 794046 exited on signal 11 (core dumped)
Signal 11 — это SIGSEGV, классический сегфолт. Три воркера за две секунды означают, что проблема воспроизводится стабильно и, скорее всего, связана с конкретным запросом или инициализацией. Мастер-процесс запускает новый воркер, тот падает снова — и так по кругу.
Первичная диагностика
Первым делом я решил посмотреть, что предшествовало крашам. Команда grep -B 20 даёт контекст до нужных строк:
Bash:
grep -B 20 "exited on signal 11" /var/log/nginx/error.log | tail -40
Самое интересное в выводе — вот эта строка, появившаяся за девять секунд до первого краша:
Bash:
2026/06/09 08:58:33 [alert] ignore long locked inactive cache entry 1519f056a62c381cc8ffb16f1d0f1e59, count:4
Это сообщение из модуля файлового кэша nginx: запись в proxy-кэше намертво залочена. Первая гипотеза — повреждённый кэш-файл, с которым воркеры не могут нормально работать. Параллельно смотрю в access.log на промежуток времени между этим алертом и первым крашем — подозрительных запросов нет.
Параллельно проверяю сборку:
Bash:
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'version|module|with-'
В выводе замечаю несколько важных вещей: флаг --with-cc-opt='-g' означает, что в бинаре встроены отладочные символы. Среди модулей есть --with-http_perl_module=dynamic — Perl-модуль, известный нестабильностью при работе с несколькими воркерами. И есть путь к динамическим модулям: --modules-path=/usr/lib/nginx/modules.
Ложный след: кэш и Perl-модуль
Очищаю кэш и перезапускаю nginx:
Bash:
systemctl stop nginx
find /var/cache/nginx -type f -delete
systemctl start nginx
Крашей стало меньше, но они не прекратились. Значит, кэш — не причина, а совпадение по времени.
Проверяю, реально ли загружается Perl-модуль:
Bash:
grep -r "load_module" /etc/nginx/nginx.conf /etc/nginx/modules-enabled/ 2>/dev/null
grep -r "perl" /etc/nginx/ 2>/dev/null
Вывод разочаровывает: Perl-модуль не загружается — только MIME-тип в mime.types. Ещё один ложный след.
Получение core dump
В выводе error.log есть пометка core dumped. Смотрю, куда ядро пишет дампы:
Bash:
cat /proc/sys/kernel/core_pattern
Вывод:
Bash:
|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -F%F -- %E
Дампы перехватывает apport — Ubuntu-инструмент для сбора отчётов о крашах. Они хранятся в /var/crash/:
Bash:
ls -lht /var/crash/ | head -10
Вижу файл _usr_sbin_nginx.*.crash. Распаковываю:
Bash:
cd /tmp
apport-unpack /var/crash/_usr_sbin_nginx.*.crash nginx_crash/
ls -lh nginx_crash/
Файл CoreDump на 339 МБ — это полный дамп памяти воркера в момент краша.
Backtrace через gdb
Устанавливаю gdb. С nginx-dbgsym — пакетом отладочных символов — возникает конфликт версий: в ddebs-репозитории лежит символьный пакет для версии 1.24.0-2ubuntu7.9, а установлен nginx 1.24.0-2ubuntu7.10. Но это не критично: флаг -g в сборке означает, что символы уже встроены в сам бинарь.
Bash:
apt install gdb -y
gdb /usr/sbin/nginx /tmp/nginx_crash/CoreDump \
-batch \
-ex "set pagination off" \
-ex "info threads" \
-ex "thread apply all bt" \
2>&1 | head -60
Вывод однозначный:
Bash:
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x00005ad26149d623 in ngx_strncasecmp ()
#1 0x00007f1fc39ac77d in ?? () from /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
#2 0x00007f1fc39acced in ngx_http_headers_more_exec_cmd () from /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
#3 0x00007f1fc39acdb2 in ?? () from /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
Причина найдена. Модуль ngx_http_headers_more_filter_module передаёт битый указатель в ngx_strncasecmp(). Функция пытается читать по невалидному адресу — SIGSEGV.
Анализ причины
Смотрю на дату файла модуля:
Bash:
ls -la /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
-rw-r--r-- 1 root root 28648 Apr 17 2024 /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
Апрель 2024 года. Проверяю политику пакетов:
Bash:
apt-cache policy nginx libnginx-mod-http-headers-more-filter
Картина проясняется:
— nginx установлен версии 1.24.0-2ubuntu7.10 (обновился вместе с security-патчем)
— libnginx-mod-http-headers-more-filter — версия 1:0.37-2build1 из noble/universe, последний раз обновлялся в апреле 2024
— в Ubuntu-репозиториях нет обновлённой версии модуля, совместимой с nginx .10
При обновлении nginx пакет с модулем не подтянулся. Nginx обновился, ABI изменился, модуль остался старый — и при первом же обращении к заголовкам ответа падает с сегфолтом.
Здесь стоит ответить на вопрос, который напрашивается сам: почему nginx вообще загрузил несовместимый модуль без единого предупреждения? Потому что проверка совместимости у load_module — это сравнение сигнатуры: версия nginx, размеры базовых типов, набор включённых при сборке фич. Раскладку внутренних структур сигнатура не покрывает. Если в структуре сдвинулись поля, модуль загрузится молча, а упадёт уже в рантайме — на первом же чтении по «уехавшему» смещению. Ровно это и показал бэктрейс: модуль читает то, что считает указателем на имя заголовка, и передаёт его в ngx_strncasecmp().
Проверяю, где именно модуль используется:
Bash:
grep -r "more_set_headers\|more_clear_headers" /etc/nginx/ 2>/dev/null
Одна строка в nginx.conf:
Bash:
more_set_headers 'Server: https://sysadmin.guru';
Просто подмена заголовка Server. Нативный add_header здесь не поможет — он не перезаписывает встроенные заголовки nginx.
Решение первого дня: пересборка модуля из исходников
Поскольку в репозиториях нет совместимой версии, собираю модуль самостоятельно под точную версию установленного nginx.
Шаг 1. Зависимости для сборки
Bash:
apt install dpkg-dev build-essential libpcre3-dev zlib1g-dev libssl-dev git -y
Шаг 2. Исходники nginx точно под установленную версию
Добавляю deb-src репозитории (без них apt source не работает):
Bash:
echo "deb-src http://ru.archive.ubuntu.com/ubuntu noble main restricted universe multiverse
deb-src http://ru.archive.ubuntu.com/ubuntu noble-updates main restricted universe multiverse" \
| tee /etc/apt/sources.list.d/sources-src.list
apt update
cd /tmp
apt source nginx
Проверяю, что версия совпадает:
Bash:
cat /tmp/nginx-1.24.0/debian/changelog | head -1
# nginx (1.24.0-2ubuntu7.10) noble-security
Точное совпадение — важно. Здесь мне повезло: самая свежая версия в репозитории совпала с установленной. Вообще apt source без указания версии тянет самую новую из доступных, а не установленную — правильнее жёстко пинить:
Bash:
apt source nginx=$(dpkg-query -W -f '${Version}' nginx)
Почему это важно, станет ясно во втором акте.
Шаг 3. Исходники headers-more-nginx-module
Bash:
git clone https://github.com/openresty/headers-more-nginx-module /opt/headers-more-nginx-module
Клонирую сразу в /opt, а не в /tmp — исходники модуля ещё пригодятся.
Шаг 4. Configure и сборка только динамического модуля
Ключевой флаг — --with-compat: он включает режим совместимости для динамических модулей, выравнивая сигнатуру под установленный nginx:
Bash:
cd /tmp/nginx-1.24.0
./configure --with-compat --add-dynamic-module=/opt/headers-more-nginx-module
make modules
Полная пересборка nginx не нужна — make modules собирает только .so-файл.
Шаг 5. Замена модуля
Bash:
# Бэкап старого на всякий случай
cp /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so \
/usr/share/nginx/modules/ngx_http_headers_more_filter_module.so.bak
# Устанавливаю новый
cp /tmp/nginx-1.24.0/objs/ngx_http_headers_more_filter_module.so \
/usr/share/nginx/modules/
ls -la /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
Шаг 6. Проверка и перезапуск
Bash:
nginx -t && systemctl restart nginx
Bash:
sleep 60 && grep "signal 11" /var/log/nginx/error.log | tail -5
Пустой вывод. Крашей больше нет.
Проверяю, что заголовок подменяется корректно:
Bash:
curl -sI https://sysadmin.guru | grep -i server
# Server: https://sysadmin.guru
Всё работает как должно.
День второй: краши вернулись
Первая версия этой статьи заканчивалась предыдущим абзацем. Утром 10 июня error.log встретил знакомой картиной: те же exited on signal 11 (core dumped), мастер снова перезапускает воркеры по кругу. Вчерашний фикс был проверен и curl'ом, и часом наблюдения за логами — значит, ночью что-то изменилось.
Сначала возвращаю сайт к жизни. Комментирую единственную директиву модуля в nginx.conf и перезапускаюсь:
Bash:
# more_set_headers 'Server: https://sysadmin.guru';
Bash:
nginx -t && systemctl restart nginx
Краши прекратились. Логично: фильтр headers-more без сконфигурированных команд не трогает заголовки, и до чтения по битым смещениям дело не доходит. Но сам несовместимый .so по-прежнему загружен в мастер-процесс — это костыль, а не решение. Забегая вперёд: в LP-баге, до которого мы дойдём ниже, есть отчёты о периодических крашах даже при загруженном, но не используемом модуле.
Теперь диагностика. В /var/crash/ уже лежит свежий дамп — вчерашний gdb-рецепт даёт тот же бэктрейс с ngx_strncasecmp() из headers-more. Только модуль теперь не апрельский из пакета, а мой, вчерашней сборки:
Bash:
ls -la /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so
# дата файла — вчерашняя: это мой билд под 1.24.0-2ubuntu7.10
Что изменилось за ночь, рассказывает dpkg.log:
Bash:
zgrep " upgrade nginx" /var/log/dpkg.log* | sort
Нюанс: zgrep по нескольким файлам выводит совпадения в порядке перебора файлов, а не по времени — без sort свежие строки легко проглядеть.
Bash:
...
/var/log/dpkg.log:2026-06-10 06:55:11 upgrade nginx:amd64 1.24.0-2ubuntu7.10 1.24.0-2ubuntu7.11
/var/log/dpkg.log:2026-06-10 06:55:11 upgrade nginx-common:all 1.24.0-2ubuntu7.10 1.24.0-2ubuntu7.11
В 06:55 утра, пока я спал, nginx обновился с .10 на .11. Смотрю, что внутри:
Bash:
apt changelog nginx | head -8
Bash:
nginx (1.24.0-2ubuntu7.11) noble-security; urgency=medium
* SECURITY REGRESSION: ABI change breaking external modules (LP: #2155992)
- debian/patches/CVE-2026-49975.patch: disable for now, pending further
investigation.
-- Marc Deslauriers <...> Tue, 09 Jun 2026 07:45:23 -0400
SECURITY REGRESSION. ABI change breaking external modules. Всё, что я вчера раскапывал через gdb, — здесь одной строкой.
Полная картина: баг LP #2155992
По ссылке из changelog — баг с важностью Critical и тегом regression-update:
https://bugs.launchpad.net/ubuntu/+source/nginx/+bug/2155992
Хронология по нему восстанавливается полностью.
Security-обновление 1.24.0-2ubuntu7.10, вышедшее утром 9 июня, содержало патч для CVE-2026-49975, который добавил поле в середину одной из внутренних структур nginx. Все поля после него сдвинулись — и каждый сторонний динамический модуль, собранный против старой раскладки, начал читать мусор по старым смещениям. Под раздачу попал весь зоопарк из universe: headers-more, uploadprogress, modsecurity, lua, passenger, xslt, nchan. У части пользователей воркеры падали, даже когда модуль был просто загружен через modules-enabled и ни одна его директива не использовалась. Обновление приехало всем через unattended-upgrades, так что в баге быстро собрались отчёты вида «упал весь парк серверов». Затронуты оказались все поддерживаемые релизы — от 22.04 до 26.04, у каждого свой набор сломанных версий.
Canonical отреагировала в тот же день: мейнтейнер security-команды выпустил 1.24.0-2ubuntu7.11, где проблемный патч просто отключён «до выяснения». ABI вернулся к старому, апрельскому. Пересобирать пакеты libnginx-mod-* сочли ненужным — после отката они снова совместимы.
А теперь сама ирония, по шагам:
— 9 июня, утро: unattended-upgrades ставит .10 → новый ABI → пакетный модуль несовместим → краши
— 9 июня, день: я пересобираю модуль под .10 → всё работает
— 9 июня, ~14:45 по серверному времени: Canonical публикует .11 с откатом ABI
— 10 июня, 06:55: unattended-upgrades ставит .11 → старый ABI → теперь несовместим уже мой модуль → краши
Я собрал модуль под новый ABI быстрее, чем Canonical успела этот ABI откатить. Фикс был правильным — он просто прожил ровно сутки. Если бы я 9-го числа ничего не делал, утренний апдейт 10-го починил бы всё сам. Но кто ж знал.
Решение второго дня: вернуть пакетный модуль
Раз ABI откатили к старому, совместим снова дистрибутивный модуль апреля 2024 года. Возвращаю самосборный .so на пакетный:
Bash:
apt install --reinstall libnginx-mod-http-headers-more-filter
Контроль: переустановленный файл должен побайтово совпасть с вчерашним бэкапом — это ведь тот же пакет той же версии 1:0.37-2build1:
Bash:
md5sum /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so \
/usr/share/nginx/modules/ngx_http_headers_more_filter_module.so.bak
Суммы совпадают — круг замкнулся. Раскомментирую more_set_headers и перезапускаю:
Bash:
nginx -t && systemctl restart nginx
Именно restart, не reload. Мастер-процесс держит уже загруженный .so в памяти; при reload dlopen по тому же пути вернёт старую копию, и подменённый на диске файл в работу не пойдёт. Новый бинарь модуля применяется только полным перезапуском.
Проверки:
Bash:
curl -sI https://sysadmin.guru | grep -i '^server'
# Server: https://sysadmin.guru
sleep 60 && grep "signal 11" /var/log/nginx/error.log | tail -3
# пусто
Прибираю за собой — бэкап и сборочные каталоги больше не нужны:
Bash:
rm /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so.bak
rm -rf /tmp/nginx-1.24.0* /tmp/nginx_crash
Откуда вообще ночные обновления
Я автообновления не настраивал. Но в Ubuntu Server их и не нужно настраивать: unattended-upgrades включён из коробки. Пакет входит в стандартную установку, при установке сам создаёт /etc/apt/apt.conf.d/20auto-upgrades и никакого явного согласия не спрашивает:
Bash:
cat /etc/apt/apt.conf.d/20auto-upgrades
Bash:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Запускают его systemd-таймеры: apt-daily.timer обновляет списки пакетов, apt-daily-upgrade.timer ставит обновления — по расписанию в 6:00 со случайной задержкой до часа:
Bash:
systemctl list-timers 'apt-daily*'
Сверяю с историей в dpkg.log: 28 апреля — 06:23, 16 мая — 06:56, 10 июня — 06:55. Почерк совпадает. По умолчанию ставятся только пакеты из кармана -security — оба обновления nginx (.10 и .11) шли именно через noble-security, поэтому и прилетели. Полный журнал работы — в /var/log/unattended-upgrades/unattended-upgrades.log: там видны оба прогона, 9 и 10 июня.
Кстати, те самые 14 неприменённых версий ядра, которые всплыли в первый день, — тоже его работа: ядра приезжали автоматически, а перезагрузка по умолчанию выключена (Automatic-Reboot "false").
Что делать, чтобы не повторилось
Главный источник сюрпризов в этой истории — не сам баг, а то, что обновления приезжают молча. Дальше три стратегии работы с автообновлениями, от мягкой к радикальной, и общие меры, нужные при любой из них.
Вариант 1. Оставить автообновления, включить почтовые отчёты
За двое суток unattended-upgrades успел и сломать прод (.10), и сам же его починить (.11), пока я спал, — счёт 1:1. Отключать механизм, который чинит сам себя, я не готов, но хочу знать о каждом его ходе. Настройка — локальным drop-in'ом, чтобы не трогать конфиг пакета (50unattended-upgrades — это conffile, при обновлениях пакета правки в нём будут конфликтовать):
Bash:
cat > /etc/apt/apt.conf.d/52unattended-upgrades-local << 'EOF'
Unattended-Upgrade::Mail "root";
Unattended-Upgrade::MailReport "on-change";
EOF
on-change — письмо только когда что-то реально установлено (есть ещё always и only-on-error). Нужен рабочий sendmail-совместимый MTA, и root должен куда-то резолвиться через /etc/aliases — либо пишите адрес явно. У меня на этой же машине живёт почтовый сервер, так что вопрос закрыт. Проверить без установки чего-либо:
Bash:
unattended-upgrade --dry-run --debug
С этой настройкой вся утренняя загадка второго дня — «я ничего не менял, почему всё сломалось?» — решалась бы одним письмом во входящих.
Вариант 2. Вывести nginx-стек из-под автообновлений
Компромисс: всё остальное обновляется само, а nginx и его модули — только руками, в окно обслуживания и со smoke-тестом. В тот же drop-in:
Bash:
Unattended-Upgrade::Package-Blacklist {
"nginx";
"libnginx-";
};
Записи в списке — регулярные выражения с привязкой к началу имени пакета: две строки накрывают nginx, nginx-common, nginx-extras и все libnginx-mod-*. Цену компромисса надо понимать: задержка security-патчей nginx становится вашей личной ответственностью, а этой весной noble патчил nginx почти ежемесячно — .7 в апреле, .8 в мае, .10 и .11 в июне.
Вариант 3. Полностью отключить автообновления
Мой выбор. Радикальный путь для тех, кто хочет, чтобы на сервере менялось ровно то, что меняет администратор:
Bash:
dpkg-reconfigure -plow unattended-upgrades
# ответить «Нет»
Или руками в /etc/apt/apt.conf.d/20auto-upgrades:
Bash:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "0";
Первую строку оставляю в «1»: обновление списков само ничего не ставит, зато apt list --upgradable всегда показывает актуальную картину. Таймеры apt-daily трогать не нужно — при «0» прогон завершается, ничего не делая.
Цену тоже считаем честно. В этой истории автообновления сначала сломали прод, а потом сами же его починили; отключая их, вы забираете себе обе роли. Это обязательный ритуал: регулярное окно (хотя бы раз в неделю), в нём — apt update, осмотр apt list --upgradable, осознанная установка, smoke-тест после. Плюс подписка на рассылку ubuntu-security-announce (USN), чтобы критичные патчи не ждали планового окна.
Общее: исправленный скрипт пересборки
Скрипт пересборки остаётся аварийным инструментом — на случай нового окна, когда репозиторный модуль несовместим с текущим nginx, как 9 июня. Но второй день вскрыл в моей первой версии два бага. Первый: apt source без пина тянет самую свежую версию из репозитория, а не установленную — запусти я его утром 10-го против ещё не обновлённого nginx, собрал бы модуль под .11 для работающего .10. Второй: reload не применяет подменённый .so — мастер держит старую копию в памяти, нужен restart. Исправленная версия:
Bash:
#!/bin/bash
# rebuild-nginx-modules.sh
set -e
NGINX_VER=$(dpkg-query -W -f '${Version}' nginx)
SRC_DIR="/tmp/nginx-rebuild"
rm -rf "$SRC_DIR"
mkdir -p "$SRC_DIR"
cd "$SRC_DIR"
# Исходники строго под установленную версию
apt source nginx=${NGINX_VER}
cd nginx-*/
./configure --with-compat --add-dynamic-module=/opt/headers-more-nginx-module
make modules
cp /usr/share/nginx/modules/ngx_http_headers_more_filter_module.so \
/usr/share/nginx/modules/ngx_http_headers_more_filter_module.so.bak
cp objs/ngx_http_headers_more_filter_module.so /usr/share/nginx/modules/
# Именно restart: reload не подменяет уже загруженный .so
nginx -t && systemctl restart nginx
echo "Done. Module rebuilt for nginx ${NGINX_VER}"
В deb-src источники стоит добавить и noble-security: security-версии копируются в noble-updates, но не мгновенно.
Bash:
echo "deb-src http://security.ubuntu.com/ubuntu noble-security main restricted universe multiverse" \
>> /etc/apt/sources.list.d/sources-src.list
apt update
И главное правило, которое я вывел за эти двое суток: самосборный .so — это не фикс, а обязательство. Он привязан к конкретному ABI и живёт до первого его изменения в любую сторону. После каждого обновления nginx выбор один из двух: пересобрать заново или вернуться на пакетный модуль, если ABI снова совпадает.
Общее: smoke-тест после любого обновления nginx
Три команды, тридцать секунд:
Bash:
nginx -t
curl -sI https://sysadmin.guru | grep -i '^server'
grep "signal 11" /var/log/nginx/error.log | tail -3
Пришло письмо от unattended-upgrades с nginx в списке (вариант 1) или обновились руками (варианты 2–3) — прогнали.
Общее: мониторинг воркеров
Если nginx падает, мониторинг должен сигналить раньше, чем это заметите вы. Через Netdata или простую проверку:
Bash:
grep "signal 11" /var/log/nginx/error.log
Наличие таких строк — немедленный алерт.
Общее: следить за судьбой CVE-2026-49975
Важно понимать: в .11 патч именно отключён, а не переписан — CVE-2026-49975 в noble прямо сейчас снова не закрыта. Canonical обещает перевыпустить исправление после разбирательства; в баге уже предлагают очевидный путь — добавлять поле в конец структуры, не ломая смещения. Гарантий нет, поэтому следующее security-обновление nginx я встречу подписанным на LP #2155992 и со smoke-тестом наготове.
Общее: перезагружаться в плановое окно
Из первого дня: сервер работал на ядре 6.8.0-110-generic при установленном 6.8.0-124-generic — 14 версий без перезагрузки, которые втихую привёз всё тот же unattended-upgrades. К крашам nginx это отношения не имело, но рассинхрон ядра и userspace — мина замедленного действия. Регулярный ребут в окно обслуживания закрывает вопрос.
Итог
Двое суток, одна строка конфига, две зеркальные поломки. Ключевые точки:
— core dump через apport + gdb за час указал виновника: динамический модуль читает битый указатель
— корень оказался не на моём сервере: security-патч Ubuntu (CVE-2026-49975) сломал ABI всех сторонних динамических модулей — баг LP #2155992 с важностью Critical
— проверка load_module не покрывает раскладку структур: несовместимый модуль грузится молча и падает в рантайме
— моя пересборка под .10 была корректной и прожила ровно сутки — до ночного отката ABI в .11, после которого несовместимым стал уже мой .so
— финал: nginx .11 + пакетный headers-more, заголовок Server на месте, unattended-upgrades теперь отчитывается на почту
Если nginx падает с SIGSEGV и у вас есть динамические модули — первым делом смотрите даты и версии .so и последние строки dpkg.log. Иногда честный ответ на вопрос «что я вчера сломал?» — «ничего: это приехало ночью». И иногда самый надёжный фикс — тот, который способен пережить следующее утро.