Сертификат обновлялся, а nginx не перечитывал его 60 дней
В конце августа коллега прислал скриншот: браузер ругается на просроченный сертификат, хотя certbot certificates на сервере показывает дату окончания через два с половиной месяца. Самое неприятное в таких инцидентах — это ощущение, что сервер врёт сам себе: файл на диске свежий, а клиенту прилетает что-то другое. Разбираем по шагам, как мы искали причину и почему она оказалась куда банальнее, чем казалось на первый взгляд.
Содержание
- Что мы увидели: сертификат "просрочен" при живом файле на диске
- Гипотеза первая: сломалось автопродление certbot
- Гипотеза вторая: сломана цепочка сертификатов
- Гипотеза третья: за балансировщиком несколько инстансов nginx
- Настоящая причина: nginx держит файл, открытый при старте, а не путь к нему
- Как это исправили: правильный hook и ручной reload
- Как не наступить на те же грабли
Что мы увидели: сертификат "просрочен" при живом файле на диске
Первый сигнал пришёл не от пользователей, а от внешнего монитора, который раз в сутки дёргает сайт через 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →