MAATRIX / Блог / Сертификат обновлялся, а nginx не перечитывал его 60 дней

Сертификат обновлялся, а nginx не перечитывал его 60 дней

MAATRIX

В конце августа коллега прислал скриншот: браузер ругается на просроченный сертификат, хотя certbot certificates на сервере показывает дату окончания через два с половиной месяца. Самое неприятное в таких инцидентах — это ощущение, что сервер врёт сам себе: файл на диске свежий, а клиенту прилетает что-то другое. Разбираем по шагам, как мы искали причину и почему она оказалась куда банальнее, чем казалось на первый взгляд.

Что мы увидели: сертификат "просрочен" при живом файле на диске

Первый сигнал пришёл не от пользователей, а от внешнего монитора, который раз в сутки дёргает сайт через openssl s_client и проверяет notAfter. Алерт был прямолинейным: до истечения сертификата остался один день. Дежурный инженер зашёл на сервер и первым делом проверил то, что проверяют все:

certbot certificates

Вывод показывал совершенно другую картину — сертификат для домена был выпущен несколько дней назад и действителен ещё почти три месяца:

Certificate Name: example.com
  Domains: example.com www.example.com
  Expiry Date: 2026-11-24 08:12:11+00:00 (VALID: 84 days)

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

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates

Результат совпал с тревогой: notAfter действительно указывал на завтрашний день. Сервер отдавал клиентам не тот сертификат, который лежал в /etc/letsencrypt/live/example.com/. Дальше пошёл обычный процесс разбора инцидента: набор гипотез, каждую из которых нужно было либо подтвердить логами, либо закрыть.

Гипотеза первая: сломалось автопродление certbot

Первое, что приходит в голову при расхождении дат — сертификат вообще не продлевался, а certbot certificates показывает дату из конфига, а не реальный файл. Проверили таймер:

systemctl status certbot.timer
journalctl -u certbot.timer --since "-30 days"

Таймер был активен, срабатывал регулярно, последний запуск certbot renew завершился без ошибок. В логе /var/log/letsencrypt/letsencrypt.log нашли строку о том, что сертификат был успешно перевыпущен примерно два месяца назад — то есть продление отработало штатно, задолго до текущей тревоги. Гипотезу закрыли: с выпуском сертификата всё было в порядке, файлы cert.pem, privkey.pem, fullchain.pem в каталоге archive/example.com/ действительно обновились. Значит, проблема не в Let's Encrypt и не в certbot как таковом — она где-то между диском и тем, что реально уходит клиенту.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотеза вторая: сломана цепочка сертификатов

Вторая мысль — может быть, fullchain.pem собран неправильно: например, в него попал только листовой сертификат без промежуточного, и клиент строит цепочку до какого-то устаревшего кэшированного корня, отсюда и путаница с датами. Проверили содержимое файла:

openssl crl2pkcs7 -nocrl -certfile /etc/letsencrypt/live/example.com/fullchain.pem | openssl pkcs7 -print_certs -noout

Цепочка была корректной: листовой сертификат ISRG Root X1 через промежуточный R11, обе даты в файле на диске совпадали с выводом certbot certificates — почти три месяца вперёд. Значит, сам файл на диске был правильным и актуальным. Проблема не в содержимом сертификата и не в сборке цепочки — она в том, что nginx вообще не читал этот файл при ответе клиенту.

Гипотеза третья: за балансировщиком несколько инстансов nginx

Третья гипотеза была самой правдоподобной с точки зрения "видели такое раньше": за HAProxy или облачным балансировщиком стоят два-три инстанса nginx, и продлившийся сертификат раскатился не на все ноды — часть отдаёт новый, часть старый, отсюда и нестабильность в проверках. Погнали проверку по каждому бэкенду напрямую, в обход балансировщика:

for ip in 10.0.0.11 10.0.0.12 10.0.0.13; do
  echo "== $ip =="
  echo | openssl s_client -connect $ip:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
done

Все три ноды показали одну и ту же дату — ту самую, просроченную. Инфраструктура была не в кластере, а на одном сервере с единственным процессом nginx. Гипотезу про рассинхронизацию между нодами закрыли за десять минут — балансировщика с несколькими бэкендами в этой схеме просто не было, это оказалась ложная параллель с другим похожим инцидентом, который дежурный помнил по прошлой работе.

Настоящая причина: nginx держит файл, открытый при старте, а не путь к нему

К этому моменту стало ясно, что дело не в сертификате и не в топологии, а в том, как именно nginx работает с файлами. Ключевой факт: nginx открывает файловые дескрипторы сертификата и приватного ключа один раз — при старте мастер-процесса или при штатном reload — и дальше воркеры используют уже открытый дескриптор, а не путь заново. Certbot, в свою очередь, не переписывает содержимое файла по тому же inode — он создаёт в archive/example.com/ новый файл (cert3.pem, fullchain3.pem и так далее) и переключает символическую ссылку в live/example.com/ на него.

Для процесса, который уже держит открытый старый файл, эта переброска симлинка не значит ничего: он продолжает читать байты по старому дескриптору, даже если файл, на который раньше указывала ссылка, давно удалён или заменён. Проверили это напрямую через lsof:

lsof -p $(cat /run/nginx.pid) | grep pem

Вывод показал открытые дескрипторы с пометкой (deleted) рядом с путём вроде archive/example.com/fullchain1.pem — то есть nginx держал файл первой версии сертификата, хотя на диске давно жили fullchain2.pem и уже fullchain3.pem. Дальше дело было за малым — понять, почему nginx ни разу не перечитал конфигурацию после успешных продлений. Ответ нашёлся в каталоге хуков:

ls -la /etc/letsencrypt/renewal-hooks/deploy/

Скрипт reload-nginx.sh там лежал, но без бита исполнения:

-rw-r--r-- 1 root root 87 Jun 12 10:03 reload-nginx.sh

Certbot по документации молча пропускает неисполняемые файлы в renewal-hooks/deploy/ — без ошибки, без предупреждения в обычном логе, просто идёт дальше. Хук положили при первоначальной настройке сервера через Ansible-роль, но задача копирования файла не проставляла права на выполнение, а строка chmod +x была в отдельной, отключённой на тот момент задаче плейбука. С самого первого продления сертификат обновлялся исправно, а команда на перечитывание конфигурации nginx никогда не выполнялась — просто это было незаметно, пока служебный (обновлённый) файл на диске оставался валидным и совпадал по датам с тем, что реально отдавал воркер. Разрыв стал видимым только тогда, когда именно та версия сертификата, которую воркер держал открытой с самого начала, подошла к концу своего 90-дневного срока — то есть почти 60 дней после первого "успешного", но не примененного продления.

Как это исправили: правильный hook и ручной reload

Первым делом вернули исполняемый бит и убедились, что certbot вообще видит хук:

chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
certbot renew --dry-run

Флаг --dry-run в связке с -v полезен именно для такой проверки: в подробном выводе видно, какие хуки certbot нашёл и намерен запустить, без реального перевыпуска сертификата. Дальше — немедленный ручной reload, чтобы не ждать следующего цикла продления через месяц:

nginx -t && systemctl reload nginx

Важный нюанс: reload, а не restart. restart кладёт текущие соединения и создаёт секундный даунтайм, тогда как reload поднимает новых воркеров с уже актуальными файлами, а старые воркеры донашивают текущие запросы и завершаются сами (graceful shutdown). После reload сразу перепроверили, что отдаётся клиенту:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
lsof -p $(cat /run/nginx.pid) | grep pem

Дата подтянулась к актуальной, а в lsof пропали пометки (deleted) — воркеры теперь держали текущие файлы из archive/example.com/fullchain3.pem. Содержимое самого хука тоже пересмотрели — вместо голого systemctl reload nginx сделали проверку конфигурации перед reload, чтобы битый конфиг не превратил продление сертификата в падение сайта:

#!/bin/bash
set -e
nginx -t
systemctl reload nginx

Как не наступить на те же грабли

Главный вывод из этого разбора: "certbot renew прошёл без ошибок" — это не то же самое, что "сайт отдаёт актуальный сертификат". Между ними стоит шаг перечитывания конфигурации, и если он молча не срабатывает, узнать об этом можно только тогда, когда становится поздно. Что мы поменяли в процессе после инцидента:

  • Добавили в мониторинг проверку не файла на диске, а именно того, что реально отдаёт openssl s_client на боевой порт — только так виден полный путь от сертификата до клиента.
  • Раз в неделю крон-джобой сверяем notAfter из certbot certificates и notAfter из живого TLS-хендшейка; расхождение больше суток — сразу алерт.
  • В Ansible-роль вернули задачу chmod +x для всех файлов в renewal-hooks/deploy/ и добавили тест конфигурации, который падает, если хук не исполняемый.
  • В certbot renew теперь всегда добавляем --deploy-hook явно в команде, а не полагаемся только на файлы в каталоге хуков — так права на файл видно сразу при первом ручном запуске.
  • Проверяем через lsof открытые дескрипторы сертификатов на новых серверах при вводе в эксплуатацию — это быстрый способ убедиться, что reload вообще случался хотя бы раз.

Таблица ниже — короткая шпаргалка по симптомам, которые встречались в этом разборе, и что за ними обычно стоит:

СимптомЧто показываетВероятная причина
certbot certificates показывает свежую дату, TLS-хендшейк — старуюРасхождение между диском и памятью процессаnginx не перечитал конфигурацию после продления
В lsof виден путь с пометкой (deleted)Процесс держит открытый удалённый/заменённый файлНе было reload с момента переключения симлинка certbot
Хук в renewal-hooks/deploy/ есть, но не срабатываетCertbot молча пропускает файлОтсутствует бит +x на скрипте хука
Все бэкенды за балансировщиком отдают одну и ту же старую датуНе проблема раскатки по нодамЕдиная точка отказа — на всех нодах не выполнялся reload

Если у вас похожая схема — сервер под управлением Ansible или другой конфигурацией, где certbot и nginx настраивались не руками, а автоматизацией — стоит один раз явно проверить права на файлы хуков и не полагаться на то, что "раз таймер зелёный, значит всё работает". О том, почему связка Let's Encrypt и nginx в принципе часто спотыкается именно на автопродлении, у нас есть отдельный разбор: Let's Encrypt сертификат не обновляется автоматически: причины и решение. А если nginx у вас в принципе "не видит" изменения — не только в сертификатах, но и в самом конфиге — механика там та же самая, разбор по шагам здесь: nginx не видит изменения конфига.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему certbot вообще не предупредил, что хук не сработал?

По документации certbot тихо игнорирует неисполняемые файлы в renewal-hooks/deploy/ — это осознанное поведение, а не баг, но оно рассчитано на то, что администратор сам проверяет права при настройке. В обычном выводе certbot renew без -v это никак не отражается.

Достаточно ли команды certbot renew без reload, если сертификат и так лежит в общем каталоге, который читает nginx?

Нет. nginx не перечитывает файл по пути при каждом TLS-хендшейке — он использует дескриптор, открытый при старте или последнем reload. Без явного nginx -s reload (или systemctl reload nginx) новый файл на диске никак не повлияет на то, что видит клиент.

В чём разница между reload и restart для nginx в контексте сертификатов?

reload запускает новых воркеров с обновлённой конфигурацией и файлами, старые воркеры доживают текущие соединения и завершаются — простоя для пользователей практически нет. restart останавливает и заново поднимает весь процесс, что означает кратковременный разрыв активных соединений.

Как быстро проверить на своём сервере, что hook действительно исполняемый?

Выполните ls -la /etc/letsencrypt/renewal-hooks/deploy/ и убедитесь, что у скрипта стоит x в правах, а затем прогоните certbot renew --dry-run -v — в подробном выводе будет видно, какие хуки certbot нашёл и планирует вызвать.

Можно ли обойтись без отдельного хука и просто перезапускать nginx по крону раз в сутки?

Технически можно, но это лечит симптом, а не причину, и добавляет лишний риск — если конфиг в момент cron-перезапуска окажется битым, вы получите падение сайта по расписанию вместо контролируемого reload сразу после успешного продления сертификата.

Что делать, если сервер вообще не под вашим управлением, а у хостера-панели вроде ISPmanager или cPanel?

Такие панели обычно сами перевыпускают сертификат и дёргают reload веб-сервера через собственный механизм — в этом случае стоит проверять логи именно панели, а не certbot напрямую, и убедиться, что встроенный крон-джоб панели не был отключён вручную кем-то из команды.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →