Skip to content

Горячая перезагрузка ​

Горячая перезагрузка позволяет обновить конфигурацию без остановки сервера и прерывания обработки запросов. По сигналу SIGUSR1 сервер перечитывает config.json и заново инициализирует компоненты: маршруты, домены и редиректы, подключения к базам данных, хранилища, сессии, ограничители частоты, TLS и планировщик задач.

Что перезагрузка обновляет, а что нет ​

Политика main.timeouts и виртуальных хостов также перечитывается. Новый объект проверяется до остановки рабочего поколения: неверный диапазон или недопустимое сочетание параметров оставляет прежний сервер активным. Новые соединения используют новое поколение; активные запросы и WebSocket-сессии сохраняют прежнюю политику до завершения. Это не отменяет правила soft/hard reload: hard может закрыть старое соединение, даже если его таймаут ещё не истёк.

Перезагрузка перечитывает конфигурацию. Код она подхватывает не весь:

Подхватывается по SIGUSR1
Любое значение в config.jsonда
Маршрут, привязанный к другому .so или другой функциида
.so по пути, который ещё не загружалсяда
Пересобранный на месте .so обработчикада
Пересобранный на месте модуль приложения из main.modulesда
Бинарник cwfr, то есть само ядронет

Пересобранный обработчик ​

Пересоберите обработчик поверх прежнего файла, отправьте SIGUSR1 — новое поколение конфигурации возьмёт новый код. Никаких действий, кроме сигнала, не требуется, путь в config.json менять не нужно.

Само по себе это не работало бы. dlopen() считает библиотеку уже загруженной сначала по строке пути — то есть не открывая файл, — а затем по паре «устройство и inode». Поэтому перезапись файла по тому же пути, симлинк и жёсткая ссылка одинаково бесполезны: нужны одновременно новое имя и новый inode.

Сервер это и делает. Заметив, что файл на диске изменился, а прежний путь всё ещё загружен, он кладёт теневую копию рядом с оригиналом и загружает её:

handlers/models/lib_modeluser.so
handlers/models/.cwfr-shadow-4211-1-lib_modeluser.so   ← копия, из неё и работает

Что об этом стоит знать:

  • Копия лежит рядом с оригиналом, а не в tmp, и это важно: так $ORIGIN в RPATH библиотеки по-прежнему указывает на её собственный каталог, и приватные зависимости, лежащие рядом с обработчиком, находятся. Системные библиотеки ищутся через ldconfig и не затрагиваются вовсе.
  • Каталог с .so должен быть доступен на запись. Если нет (типичный случай — /opt), копия уходит в main.tmp, а в лог пишется, что $ORIGIN в этой загрузке не действует.
  • Копия переживает выгрузку библиотеки и удаляется только при завершении процесса. Так сделано намеренно: пока сервер жив, на диске лежат файлы всех поколений, и отчёт AddressSanitizer или core dump символизируется целиком — с именами функций, файлами и строками, включая уже выгруженный код.
  • Отображённая память при этом не растёт: копия снимается вместе с поколением, переживает выгрузку только файл на диске.
  • На диске копии накапливаются в пределах жизни процесса — не более одной на пересборку. Штатное завершение удаляет их все; остатки после kill -9 или падения подчищает следующий старт, пропуская файлы живых процессов.

Core dump разбирайте до перезапуска

Старт нового процесса удаляет теневые копии прежнего. Если нужно разобрать core упавшего сервера — сделайте это до перезапуска либо сохраните каталог с .cwfr-shadow-* заранее.

Модуль приложения ​

С модулем из main.modules это тоже работает: пересоберите его на месте, отправьте SIGUSR1 — обработчики нового поколения будут звать новый код, а старый модуль выгрузится вместе со старым поколением.

Одной теневой копии здесь мало, и причина отдельная. Обработчик слинкован с модулем и записал в себя не путь к нему, а его SONAME — имя, которое библиотека объявляет сама про себя внутри ELF. При загрузке обработчика системный загрузчик ищет это имя среди уже загруженных объектов, находит совпадение ещё до открытия файла и на inode не смотрит вовсе. Копия модуля несёт тот же SONAME, а старый модуль в списке загруженных стоит раньше — значит отвечал бы всегда он.

Поэтому каждое поколение получает своё имя: в копии модуля правится DT_SONAME, а в копиях зависящих от него обработчиков — DT_NEEDED. Строка переписывается на месте и той же длины, поэтому в файле ничего не сдвигается: libapp.so становится, например, libapp.0f.

Что из этого следует:

  • Копируются и обработчики, зависящие от модуля, даже если сами они не менялись — их DT_NEEDED должен указывать на модуль этого поколения. Обработчиков, которые модуль не зовут, это не касается: компоновщик с --as-needed не оставляет им такой записи, и они не копируются.
  • ldd и отладчик покажут имя с тегом. Это косметика: путь файла и build-id не меняются, поэтому символизация, отдельные debug-файлы и отчёты AddressSanitizer работают как обычно.
  • Состояние в статических переменных модуля между поколениями не переезжает. Каждое поколение исполняет app_init() своего экземпляра, и статика у него своя. Всё, что должно пережить перезагрузку, храните в базе, кэше или сессии.
  • Деструкторы контекстов теперь принадлежат поколению. Зарегистрированные через httpctx_set_user_data_free()/wsctx_set_user_data_free(), они хранятся в конфигурации, а не глобально: у пересобранного модуля адрес другой, и запрос, доживающий на старом поколении, освобождается его же деструктором.

Когда модуль всё-таки не подхватится

Тег вписывается в SONAME, поэтому модуль без SONAME (собранный без -Wl,-soname, NO_SONAME в CMake) переименовать нельзя. Не получится и переписать имя, которое компоновщик склеил с другим — так бывает, если обработчик зависит сразу от libapp.so и от чего-то, чьё имя оканчивается на libapp.so.

В обоих случаях перезагрузка не отменяется: сервер оставляет работать прежний модуль, пишет в лог причину и применяет всё остальное. Новый модуль подхватит рестарт — или прежний приём с версионированным именем:

json
"main": {
    "modules": ["libapp.2.so"]
}

Сигнал — после сборки, а не во время ​

Компоновщик пишет .so не атомарно: он удаляет прежний файл и создаёт новый, поэтому между началом и концом записи по пути лежит неполный ELF. SIGUSR1, пришедший в этот момент, снимет копию с обрезанного файла — dlopen() её отвергнет, перезагрузка будет отклонена на этапе проверки, и сервер продолжит работать на старой конфигурации.

Ничего не ломается и следующая перезагрузка сработает сама собой, но лишнего сигнала лучше избежать: посылайте SIGUSR1 после завершения сборки либо собирайте во временный каталог и публикуйте через mv — переименование атомарно.

Запуск перезагрузки ​

Событие перезагрузки привязано к сигналу SIGUSR1. Отправьте его процессу сервера (cwfr) одним из способов:

bash
pkill -USR1 cwfr        # по имени процесса
kill  -USR1 <pid>       # по PID процесса

Перед перезагрузкой сервер сбрасывает буферы логирования на диск, поэтому последние записи не теряются.

Проверка конфигурации ​

Новый config.json сначала парсится и валидируется. Если файл содержит ошибку или невалидную конфигурацию — перезагрузка отменяется, а сервер продолжает работу со старой конфигурацией. Это позволяет править конфигурацию «вживую» без риска остановить работающий сервер.

Проверка — это полный черновой прогон на выброшенной конфигурации: модули из main.modules открываются, их app_init() вызывается, обработчики загружаются, имена middleware разрешаются. Опечатка в пути, отсутствующий app_init, битый .so, неизвестное имя middleware — всё это отклоняет перезагрузку до того, как старое поколение будет погашено. Без такой проверки при reload: hard сокеты закрывались бы раньше, и отказ загрузки оставлял бы живой процесс, не обслуживающий ничего.

Именно поэтому одной перезагрузкой можно добавить и новое middleware в app_init(), и маршрут, который на него ссылается: проверочный проход исполняет app_init() новой сборки, а не сверяется с реестром работающей. Реестр работающего поколения на это время откладывается в сторону и возвращается на место — регистрации чернового прогона указывают внутрь модулей, которые он тут же выгружает.

Плата — app_init() вызывается дважды за перезагрузку. Требование идемпотентности было всегда, и оба вызова начинаются с пустого реестра, так что дублей не бывает; но побочные действия, если они там есть, выполнятся дважды.

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

Режимы перезагрузки ​

Поведение активных соединений при перезагрузке задаётся свойством reload в секции main (см. Конфигурация → reload). Значение по умолчанию — soft.

json
{
    "main": {
        "reload": "hard",
        ...
    },
    ...
}

soft по умолчанию ​

Сервер перестаёт принимать новые соединения, но продолжает обслуживать уже активные — они завершаются естественным путём: до конца запроса, по истечении таймаута keep-alive или при закрытии соединения клиентом. Перезагрузка выполняется, как только закрыто последнее активное соединение. Ни один находящийся в обработке запрос не обрывается.

hard ​

Сервер принудительно закрывает все сетевые соединения (shutdown сокетов) сразу после получения сигнала, обрывая активные запросы. Перезагрузка выполняется максимально быстро. Режим удобен, когда изменения нужно применить срочно, а потеря текущих запросов допустима.

Выпущено под лицензией MIT.