Сервер не отвечает, но работает: беззнаковое вычитание в ZFS и systemd CrashAction=freeze
Короткий ответ: если сервер перестал отвечать по всем портам, а после ресета в журнале обнаружилась лавина segfault’ов в несвязанных друг с другом процессах и строка systemd[1]: Freezing execution — виновата не память, а арифметика в ядре. В OpenZFS до 2.4.4 и 2.3.9 функция zfs_fillpage() считала длину чтения как io_len = i_size - io_off в беззнаковых типах: если файл усечь ровно в тот момент, когда подкачивается mmap-страница, разность уходит в минус, превращается в «почти 2⁶⁴», и dmu_read() забивает нулями физическую память далеко за пределами страницы. Машина при этом остаётся жива — до конца её добивает systemd, у которого CrashAction по умолчанию равен freeze.
Отказ выглядел как выключенный сервер: ни SSH, ни HTTP, ни веб-морда гипервизора. Помог жёсткий ресет через панель хостера. Дальше остался вопрос, который никто не любит: а что это, собственно, было, и повторится ли оно завтра.
Час расследования дал два ответа вместо одного. Баг в ZFS — это спусковой крючок. Настоящую цену отказу назначил дефолт systemd.
Коротко (TL;DR)
- Причина: беззнаковое вычитание в
zfs_fillpage()при гонке mmap-чтения с усечением файла на месте. Ядро затирает нулями чужие страницы физической памяти, жертвами становятся случайные процессы. - Где починено: OpenZFS 2.4.4 и 2.3.9, оба от 21 августа 2026. В Proxmox — пакет
zfsutils-linuxверсии 2.4.4-pve1. В 2.2.11, вышедшей в тот же день, фикса нет. - Почему отказ длится часами, а не секундами:
CrashAction=freeze— дефолт systemd. Упавший PID 1 замирает навсегда, службы никто не перезапускает,systemctlвиснет, sshd со временем умирает следом. - Как за минуту отличить от битой памяти: счётчики ECC в
/sys/devices/system/edac/mc/mc0/нулевые и в журнале нет ни одногоMachine Check— значит планки ни при чём. - Признак в дампе: регистр длины
memset— не мусор, а аккуратное отрицательное число, ровно минус другой регистр. Это переполнение при вычислении размера, а не сбойное железо. - Смягчение на одну строку:
CrashAction=rebootв drop-in — и повторение аварии стоит десяти секунд вместо целой ночи. - Побочные грабли:
restart: on-failureу Docker переживает жёсткий ресет, но по документации не переживает перезапуск демона.
Как выглядит отказ снаружи
Снаружи это неотличимо от выключенной машины, и именно поэтому первая версия всегда неверная. Сервер молчит по всем портам, панель хостера показывает питание в норме, техподдержка разводит руками. Логичная гипотеза — «выключился», а дальше ищешь причину выключения: ACPI-событие, watchdog, провал по питанию.
Гипотеза разваливается о журнал. journalctl --list-boots показывает предыдущую загрузку, и записи в ней идут вплоть до момента, отстоящего от новой загрузки на полторы-две минуты — ровно на длительность POST серверной платы. Последняя строка перед ресетом была такой:
nginx[…]: [alert] …#…: worker process … exited on signal 11
Машина не выключалась. Она работала всё это время и исправно писала в журнал, что ей плохо. Просто снаружи её не было слышно.
Отсюда же вылезает вторая неприятность: настоящий момент сбоя лежит сильно раньше, чем «время падения» из last. last -x покажет crash, но не покажет, когда именно всё началось. Начало ищется по первому segfault’у в лавине — и оно может отстоять от ресета на всю ночь.
Почему это не битая память, хотя выглядит именно так
Массовые одновременные segfault’ы в несвязанных процессах — классический почерк неисправной RAM, и первый рефлекс здесь неправильный. Планировать даунтайм под memtest86+ не надо, сначала стоит потратить минуту на два счётчика:
| |
Нули во всех трёх местах при ECC-памяти означают, что память как подсистема исправна. Одиночный перевёрнутый бит контроллер поймал бы и записал. А заодно проверьте, что ECC действительно работает, а не просто планки такие:
| |
Total Width на 8 бит больше Data Width — вот это и есть работающий ECC, дополнительные биты под коррекцию физически присутствуют. Если ширины равны, ECC у вас нет, кто бы что ни писал в спецификации.
Эта минута экономит несколько часов даунтайма на диагностике не той подсистемы. ECC защищает от перевёрнутых битов, а не от кода, который сам, штатным путём исполнения, пишет нули не туда.
Четыре ложных следа, и все четыре через одну функцию
Все четыре гипотезы, которые дал поиск по симптому, оказались мимо — и я потратил на них больше времени, чем на разгадку. Выписываю их с причинами отказа: каждая закрывает целое направление, и если у вас похожий стек — эти ветки можно не проходить заново.
След 1: гонка Direct IO. Первый же осмысленный поиск по стеку вывел на обсуждение реальной гонки в OpenZFS: конкурентная запись с O_DIRECT может выставить db->db_data = NULL, и тогда dmu_read_impl() падает на разыменовании. Имена функций совпадали с нашим стеком буквально. Проверка тюнабла подлила масла в огонь — Direct IO включён:
# cat /sys/module/zfs/parameters/zfs_dio_enabled
1
Я успел назвать это главной гипотезой вслух. Развалилось об регистры: у нас не разыменование нуля, а memset с осмысленным адресом назначения и абсурдной длиной. Механизм принципиально другой.
След 2: повреждение SLUB на шифрованном ZFS. Нашёлся свежий issue #18427 с симптомом слово в слово нашим — массовые segfault’ы в userspace, воспроизводится на версиях с 2.2.3 по 2.4.1. Отверг одной командой:
# zfs get -r encryption rpool
NAME PROPERTY VALUE SOURCE
rpool encryption off default
След 3: уже исправленная регрессия в dmu_read_impl. Issue #17886 — Oops в той же функции и тоже с затиранием памяти по кривой длине, закрыт через PR #17915. Выглядело как «баг известен, фикс есть, обновляйтесь». Прочитал сам PR — он чинит потерянные скобки в макросе BRT_RANGESIZE_TO_NBLOCKS(), деление вместо умножения. Автор пишет прямо: «this could cause small memory corruptions for vdevs bigger than 64TB+». Порог отсекает сразу: до 64 ТБ на vdev типовой пул под веб-нагрузку не дотягивает на порядок. Да и стек в том баге идёт через brt_load() при импорте пула, а не через mmap под нагрузкой. Общее место падения, разные баги.
След 4: «сервер был выключен несколько часов». Про него выше — разбился о непрерывный журнал предыдущей загрузки.
Общий вывод неприятный: четыре следа из четырёх шли через dmu_read_impl. Функция популярная, «повреждение памяти» — слишком общий симптом, и поиск по симптому в такой ситуации выдаёт бесконечную ленту чужих несчастий. Развязку дал не поиск, а арифметика.
Развязка: длина memset — это ровно минус другой регистр
Разгадка целиком лежала в дампе Oops, надо было просто посчитать. Вот он, сокращённо до значимого:
BUG: unable to handle page fault for address: ffff8983e8400000
#PF: supervisor write access in kernel mode
#PF: error_code(0x0003) - permissions violation
Comm: php-fpm
RIP: 0010:memset+0xb/0x20
RAX: 0000000000020000 RSI: 0000000000000000 RDI: ffff8983e8400000
RDX: ffffffffff7a4000 R15: 000000000085c000
Call Trace:
? dmu_read_impl+0x21e/0x230 [zfs]
dmu_read [zfs]
zfs_fillpage [zfs]
zfs_getpage [zfs]
zpl_read_folio [zfs]
filemap_read_folio
filemap_fault
__do_fault
handle_mm_fault
Смотрим на аргументы memset. В x86-64 SysV это RDI — адрес, RSI — чем заполнять, RDX — сколько байт.
RSI = 0, заполняем нулями. RDX = 0xffffffffff7a4000 — на первый взгляд мусор. А теперь R15 = 0x85c000 = 8 765 440. И вот здесь надо остановиться: 0xffffffffff7a4000 — это ровно −8 765 440 в дополнительном коде. Не случайное значение, не затёртый регистр. Аккуратное отрицательное число.
Отрицательная длина не берётся из воздуха. Она означает, что где-то вычли большее из меньшего в беззнаковой арифметике. Виноват не тот, кто испортил память, а тот, кто неправильно посчитал размер.
Заодно RAX = 0x20000 — это 128 КиБ, дефолтный recordsize ZFS. Всё сходится: обычное чтение записи ZFS, у которого поехала длина.
С этой гипотезой поиск перестал быть поиском по симптому и стал поиском по имени функции вместе с точной версией. Нужный issue нашёлся первым же результатом.
Что сломано в ZFS: инвариант, который не доживает до релизной сборки
Виновата пара строк в zfs_fillpage(), а прикрывал их ASSERT, которого в релизных сборках нет вовсе. Вот код из ветки 2.4.3:
| |
Инвариант записан прямым текстом: смещение страницы обязано быть меньше размера файла. Ниже строчкой на этом инварианте построено вычитание. Всё честно — ровно до того момента, пока не заглянешь в определение ASSERT3U для сборки без отладки, в include/os/linux/spl/sys/debug.h:
| |
Макрос разворачивается в два sizeof. Он ничего не вычисляет и ничего не проверяет — он существует, чтобы аргументы не считались неиспользуемыми. А дистрибутивы, включая Proxmox, собирают ZFS именно без отладки. То есть единственная защита от переполнения выключена ровно в тех сборках, которые крутятся на проде.
Дальше механика простая. Файл усекают на месте (O_TRUNC при перезаписи), i_size падает. Страница, которая уже стоит в очереди на подкачку, оказывается за новым концом файла: io_off > i_size. Условие io_off + io_len > i_size истинно, io_len = i_size - io_off — и size_t уезжает в «почти 2⁶⁴». dmu_read() получает эту длину, видит за концом файла дыру и добросовестно заполняет нулями всё, до чего дотянется.
Причину назвал участник обсуждения в issue #18787, пользователь @Zaczero, — и сформулировал её так:
in
zfs_fillpage(), if a page is faulted in just after the file is truncated,io_len = i_size - io_offunderflows (unsigned) to ~2⁶⁴, anddmu_read()then writes zeros forward across physical memory from that one page.
Там же назван и типичный триггер: «an mmap read (PHP readfile()) racing an in-place truncate of the same file». В нашем дампе Comm: php-fpm — попадание точное.
Второе наблюдение из того же комментария объясняет, почему симптом так сбивает с толку:
Kernel slab + two unrelated userspace heaps (redis, php-fpm) + journald’s mmap’d file all corrupted in the same second → a physical-page sweep, not a single-list bug. Explains why ZFS isn’t in the faulting stack (it’s a sweep on another CPU; the crashing threads are just victims).
Затирание идёт по физическим страницам, поэтому ZFS в стеке падающего процесса чаще всего вообще не появляется. Падают жертвы, а не виновник. Именно из-за этого поиск по симптому уводит куда угодно, только не к файловой системе.
Отдельная деталь для тех, кто держит контейнеры: в дампе стоял UID 100082. Расшифровывается механически — непривилегированный LXC по умолчанию маппит хостовые UID со смещением 100000, значит внутри контейнера это uid 82, а 82 в Alpine — это www-data. Рядом в трейсах мелькал ld-musl-x86_64.so.1, тоже Alpine. Так по одному числу локализуется, чей именно PHP дёргал файл: не хостовый, а тот, что в контейнере.
В каких версиях баг живёт и куда обновляться
Уязвимы все версии до 2.4.4 и 2.3.9, а вдобавок вся ветка 2.2 целиком, включая свежайшую 2.2.11. Фикс — PR #18715 «linux: handle mmap read beyond file size», коммит 223b8bc446, влит в master 30 июня 2026. Он добавляет ранний выход, когда страница целиком лежит за концом файла:
| |
Симметричная правка сделана в zfs_getpage(), чтобы range lock брался на корректный диапазон. Следом влит регрессионный тест — PR #18824, 20 июля 2026.
Релиз 2.4.3 вышел 12 июня 2026, то есть фикс опоздал к нему на 18 дней. Вот как обстоят дела с релизами сейчас:
| Ветка | Последний релиз | Дата | Фикс zfs_fillpage() |
|---|---|---|---|
| 2.4 | 2.4.4 | 21.08.2026 | есть |
| 2.3 | 2.3.9 | 21.08.2026 | есть |
| 2.2 | 2.2.11 | 21.08.2026 | нет, уязвимый код на месте |
| Proxmox | zfsutils-linux 2.4.4-pve1 | в pve-no-subscription | есть |
Таблицу я собрал не по анонсам, а сравнением zfs_fillpage() в тегах репозитория: в zfs-2.2.11 строки ASSERT3U(io_off, <, i_size); и io_len = i_size - io_off; стоят ровно там же, где в 2.4.3. Ветка 2.2 на сегодня уязвима, хотя релиз у неё того же числа, что у исправленных. В списке изменений 2.4.4 и 2.3.9 фикс упомянут явной строкой linux: handle mmap read beyond file size #18715, в описании 2.2.11 такой строки нет.
Проверить, что стоит у вас, можно одной командой — она же покажет, дошёл ли пакет до вашего репозитория:
| |
Ещё одна деталь, отрезвляющая насчёт «просто откатимся на прошлое ядро»: уязвимый код одинаков и в 2.4.2, и в 2.3.x, и в 2.1.15. Это не свежая регрессия, а давняя мина. Откат на запасное ядро с ZFS постарше не лечит ничего.
Отдельно любопытна хронология. Фикс появился раньше issue и совсем не по нему: коммит влит 30 июня, автор патча — Brian Behlendorf, руководитель проекта OpenZFS, а в теле коммита стоит Reported-by: Iliya Polihronov (@vnsavage) (Automattic). То есть на баг независимо наткнулись в компании, которая держит WordPress.com. Issue же заведён 12 июля, и связали одно с другим только 16-го — через две с половиной недели после того, как патч уже лежал в master.
Автор issue — @stgraber, мейнтейнер Incus. У него это ловилось на хостах с сотней LAMP-контейнеров, причём тоже на ECC-памяти:
All systems use ECC memory and I’ve confirmed that the system where I’ve captured the crash data here also has a completely clean SEL so physical memory should be fine.
11 августа он подтвердил, что после выкатки cherry-pick’а коммита падения прекратились: «haven’t heard about any more crashes since this got rolled out, so seems like that did the trick». Сам issue при этом до сих пор открыт — ориентироваться надо на номер PR, а не на статус тикета.
Почему отказ длится часами, а не секундами
Отказ длится часами, потому что systemd по умолчанию замораживает упавший PID 1 навсегда вместо перезагрузки. Это вторая половина разгадки, и она дороже первой. Через считаные миллисекунды после начала затирания памяти в журнале появляется:
systemd[1]: Caught <ABRT>, from our own process.
systemd[1]: Caught <ABRT>, dumped core as pid …
systemd[1]: Freezing execution.
У systemd есть параметр CrashAction, и по умолчанию он равен freeze. Документация systemd(1) описывает это без всякой двусмысленности:
Takes one of
freeze,rebootorpoweroff. Defaults tofreeze. If set tofreeze, the system will hang indefinitely when the system manager (PID 1) crashes. If set toreboot, the system manager (PID 1) will reboot the machine automatically when it crashes, after a 10s delay.
«Hang indefinitely» — это ровно то, что мы видели. Логика дефолта понятна: замереть, чтобы администратор подошёл и изучил живую систему. На арендованном железе в чужом дата-центре она означает «зависни навсегда и жди, пока кто-нибудь заметит».
Замороженный PID 1 отнимает у системы способность себя чинить. Упавшие службы никто не перезапустит, systemctl виснет, штатное выключение невозможно. Дальше картина становится издевательской: мастер-процесс nginx жив, послушно форкает воркеров, воркеры наследуют испорченную память и умирают мгновенно. При частоте порядка двух падений в секунду за ночь набегают сотни тысяч строк в журнале. Обычный systemctl restart nginx вылечил бы это за секунду — но выполнять его было некому.
Через полчаса умирает и sshd:
sshd[…]: free(): invalid pointer
sshd[…]: recv_rexec_state: ssh_msg_recv failed
После этой строки в журнале нет ни одного упоминания sshd — ни попыток входа, ни отказов. Порт 22 просто никто не слушал. Отсюда и полное молчание снаружи при живом ядре внутри.
Смягчение — одна строка, и ставить её лучше drop-in’ом, а не правкой основного файла:
| |
| |
Теперь повторение аварии стоит десяти секунд до автоперезагрузки. Прикиньте разницу сами: если PID 1 замрёт вечером, а первый человек посмотрит на сервер утром — это часы простоя вместо десяти секунд, и вся разница в одной строке конфига.
Параметр CrashAction= появился в systemd 256, вместе с ним завели и systemd.crash_action= в командной строке ядра. В Debian 13 и Proxmox 9 приезжает systemd 257, так что опция там точно есть.
Побочная находка: restart-политика, которая переживает аварию, но не переживает ребут
Политика restart: on-failure поднимает контейнеры после жёсткого ресета, но не поднимает их после штатной перезагрузки — и это даёт ложную уверенность в отказоустойчивости. У контейнеров стояло именно on-failure. После жёсткого ресета они поднялись как миленькие: процессы убиты SIGKILL, код выхода ненулевой, политика сработала.
А теперь документация Docker про эту же политику:
The
on-failurepolicy only prompts a restart if the container exits with a failure. It doesn’t restart the container if the daemon restarts.
При штатной перезагрузке dockerd шлёт контейнерам SIGTERM, и тот, кто завершается с кодом 0 — nginx именно так и делает, — под on-failure не поднимется. Получается парадокс: конфигурация переживает аварию, но может не пережить плановый ребут. А люди тестируют отказоустойчивость как раз выдёргиванием питания и делают из этого неверный вывод.
Лечится без простоя и без пересоздания контейнеров:
| |
Рядом нашлась и вторая мина: у контейнера с мониторингом директивы restart: не было вообще, а это значит no — не поднимать никогда. После каждой перезагрузки он не стартовал, а зависящий от него прокси уходил в цикл перезапусков с host not found in upstream — десятки рестартов за четверть часа. depends_on тут не спасает: он задаёт порядок только внутри docker compose up, а при автозапуске контейнеров демоном никакого порядка нет, есть только restart-политика у каждого контейнера по отдельности.
Что сделать сейчас, по шагам
Минимум — обновить ZFS и переключить CrashAction; первое убирает причину, второе ограничивает цену следующего краха PID 1 десятью секундами.
- Проверьте версию ZFS:
modinfo zfs | grep ^version. Всё, что ниже 2.4.4 или 2.3.9, а также вся ветка 2.2 включая 2.2.11 — уязвимо. - Обновитесь. На Proxmox —
apt update && apt install zfsutils-linux zfs-initramfsдо 2.4.4-pve1, дальше перезагрузка: модуль ядра на живой системе не подменить. - Если обновиться нельзя, снизьте вероятность триггера. Он — перезапись файла на месте под mmap-чтением. Вынесите кэш-каталоги, куда приложение пишет через
O_TRUNC, на tmpfs: где нет ZFS, там нет и бага. В приложении правильный путь — писать во временный файл иrename(), но на легаси-кодовой базе полного покрытия не будет. - Поставьте
CrashAction=rebootdrop-in’ом. Это независимо от ZFS и полезно при любом крахе PID 1. - Пройдитесь по restart-политикам контейнеров:
docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -aq). Всё, чтоnoилиon-failure, переведите наunless-stopped. - Заведите внешнюю проверку доступности. Хоть что-нибудь снаружи, что дёргает порт раз в минуту и пишет вам, когда он молчит.
Пункт 6 я поставил последним, а по важности он первый.
Итог
В этой аварии три слоя вины, и починить самому можно два из них. Баг в ZFS — спусковой крючок, он от вас не зависел и уже исправлен апстримом: обновляйтесь до 2.4.4 или 2.3.9 и живите дальше. Дефолт CrashAction=freeze превратил единичный сбой в многочасовой отказ — это чинится одной строкой и стоит того, чтобы прописать её на всех арендованных машинах заранее, до первого случая. А отсутствие внешнего мониторинга растянуло отказ до утра, и это, честно говоря, дыра крупнее самого бага в файловой системе.
Практический вывод, который переживёт конкретно этот баг: когда ядро падает на memset или memcpy — посмотрите на регистр длины и проверьте, не аккуратное ли это отрицательное число. Если да, искать надо не того, кто испортил память, а того, кто неправильно посчитал размер. Это сразу отсекает всю ветку с диагностикой железа, а она самая дорогая по времени.
И отдельно — про ASSERT в чужом коде. Инвариант, записанный ассертом, в релизной сборке не существует. Он документирует намерение автора, а не гарантирует поведение системы. Читая исходники в поисках причины падения, каждый ASSERT стоит мысленно читать как комментарий — и проверять, что будет, если он неверен.
Первоисточники: issue openzfs/zfs#18787, PR #18715 и коммит 223b8bc446, регрессионный тест #18824, релиз zfs-2.4.4, zfs_vnops_os.c в теге zfs-2.4.3, определение ASSERT3U в debug.h, man systemd(1) про systemd.crash_action=, документация Docker про restart-политики.