12 сентября я зашёл на собственный сайт с телефона и получил полноэкранное предупреждение о небезопасном соединении.

Сертификат parkout.ru истёк 22 августа. То есть 21 день каждый посетитель видел эту страницу вместо карты.

Первая мысль была про certbot: не продлил, кончилось место, сломался таймер. Полез проверять.

Certificate Name: parkout.ru
  Expiry Date: 2026-10-21 (VALID: 39 days)

Certbot был в полном порядке. Новый сертификат он выпустил ещё 23 июля, лежит на диске, действует до 21 октября.

Браузер при этом показывал истёкший.

Где расходятся две правды

Обе стороны говорили правду, просто о разных вещах.

Certbot знает про файл на диске. Он его выпустил, положил в /etc/letsencrypt/live/ и честно отчитался.

Браузер получает то, что держит в памяти процесс nginx. А nginx читает сертификат один раз, при старте, и дальше работает с тем, что загрузил. Обновление файла для него не событие.

Контейнер nginx-lb не перезапускался с 21 июля. Сертификат он загрузил тогда же, старый, с датой истечения 22 августа. Дальше certbot мог обновлять файл сколько угодно: процесс продолжал отдавать то, что прочитал в июле.

Связать одно с другим должен был deploy-hook — скрипт, который certbot запускает после успешного обновления. Заглянул туда:

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

Пусто. Хуков не было вообще, ни одного, с самого начала.

Схема «certbot на хосте, nginx в контейнере» разваливается ровно здесь. Если certbot стоит с плагином --nginx, он перезагружает nginx сам, и про хуки можно не знать годами. Как только nginx уезжает в docker, этот плагин применять не к чему: снаружи контейнера ему нечего перезагружать, и связь рвётся молча.

Починка на минуту:

docker exec nginx-lb nginx -s reload

И скрипт, чтобы это происходило само:

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx-lb.sh
set -e
LOG=/var/log/parkout-certbot-hook.log

docker inspect -f '{{.State.Running}}' nginx-lb 2>/dev/null | grep -q true || {
    echo "$(date -Is) deploy-hook: контейнер nginx-lb не запущен" >> "$LOG"
    exit 0
}
docker exec nginx-lb nginx -t 2>>"$LOG" || {
    echo "$(date -Is) deploy-hook: конфиг невалиден, reload отменён" >> "$LOG"
    exit 1
}
docker exec nginx-lb nginx -s reload
echo "$(date -Is) deploy-hook: nginx-lb reloaded" >> "$LOG"

Про nginx -t в этом скрипте я написал ерунду, и меня справедливо поправили в комментариях. Я думал, что reload на битом конфиге уронит живой nginx. Это не так: мастер-процесс сначала проверяет синтаксис и при ошибке откатывается, продолжая работать на старом конфиге.

Проверил на одноразовом контейнере — сломал конфиг и дёрнул reload:

$ nginx -s reload
nginx: [emerg] unknown directive "this" in /etc/nginx/conf.d/default.conf:46
$ curl -s -o /dev/null -w '%{http_code}' localhost
200

Сайт стоит, мастер жив. Смысл в nginx -t остаётся, но куда скромнее: он кладёт понятное сообщение в лог самого хука в момент продления, вместо строчки [emerg] в логе nginx, куда никто не смотрит. Для статьи, половина которой про алерты в непрочитанный ящик, это не лишнее.

Мониторинг всё видел

Дальше начинается вторая часть, которая мне нравится меньше.

У нас стоит Uptime Kuma. Она эту аварию зафиксировала. Down Count 2319, повторные уведомления раз в час, три недели подряд.

Я не увидел ни одного письма.

Причина оказалась не в SMTP и не в Kuma. Алерты уходили на dymatrosov@parkout.ru, а этот ящик никто не открывает. Еженедельные письма про бэкапы приходили на личный gmail и прекрасно читались — поэтому ощущение «мониторинг работает» было, а мониторинга по факту не было.

Дальше я потратил час на неверную гипотезу, и она поучительная.

В настройках Kuma поле smtpFrom было заполнено как "Uptime Kuma ParkOut", без адреса в угловых скобках. Невалидный From. Я решил, что почтовый сервер такие письма отбрасывает, и пошёл чинить.

Проверил живой отправкой, прежде чем править. Gmail такое письмо принимает без единой жалобы. Гипотеза оказалась неверной, и хорошо, что я её проверил, а не «исправил».

Заодно выяснилось, почему мои первые попытки проверить SMTP давали 535 BadCredentials. Пароль в .env лежит в кавычках:

EMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"

Bash при source кавычки снимает, и приложение получает правильные 19 символов. А мой скрипт вытаскивал значение через grep -oP и захватывал кавычки вместе с паролем: 21 символ вместо 19. Пароль был валиден всё это время, врал мой способ его прочитать.

Исправление в итоге свелось к одной строке в базе Kuma: получатель на читаемый адрес, нечитаемый в копию.

Чем это закончилось

Обе правки я сделал 12 сентября. И вот здесь важный момент, из-за которого я вообще решил это описать.

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

Обновление пришло 22 сентября. В логе:

2026-09-22T06:12:25+03:00 deploy-hook: nginx-lb reloaded

Сегодня, 30 сентября, сертификат в браузере:

notBefore=Sep 22 02:13:51 2026 GMT
notAfter=Dec 21 02:13:50 2026 GMT

Выпущен 22 сентября, тот самый новый. Контейнер при этом не перезапускался с 12 сентября — nginx перечитал сертификат по сигналу, не теряя соединений.

Отдельно стоит сказать, почему это вообще доказательство. Старый сертификат действовал до 21 октября. Если бы хук не сработал, сегодня сайт всё равно открывался бы нормально, просто со старым сертификатом, и поломка всплыла бы только через три недели. Различить два состояния можно единственным способом: посмотреть дату выпуска того сертификата, который реально отдаётся наружу.

Что забрать с собой

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

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

Эти две команды могут расходиться неделями, и расхождение ничем себя не проявляет.

Переезд процесса в контейнер рвёт связи, о существовании которых вы не знали. Плагин --nginx годами перезагружал nginx сам, и про deploy-hooks можно было не подозревать. После переезда в docker перезагружать ему стало нечего. Ошибки нет, лога нет, просто одна из сторон больше не разговаривает с другой.

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

Проверяйте гипотезу до того, как чинить по ней. Я был уверен, что невалидный smtpFrom блокирует доставку. Одно тестовое письмо сняло вопрос за минуту. Если бы я сразу «починил» From, я бы приписал себе исправление проблемы, которой не было, и настоящая причина осталась бы на месте.

Починка без проверки в бою — гипотеза. Между «написал хук» и «хук работает» прошло десять дней и одно настоящее обновление сертификата. До 22 сентября у меня был не исправленный баг, а правдоподобно выглядящий скрипт.


Это пятая статья по следам одного проекта — карты загруженности парковок parkout.ru. Предыдущие: про чёрную карту без единой ошибки в консоли, про −1000% занятости в собственном датасете, про сам датасет и про метрику, которая стала хуже и от этого правдивее.

Комментарии (4)


  1. orki
    01.10.2026 10:12

    Хороший честный разбор своего факапа.

    Не знаю ваш уровень и как вы относитесь к критике, но рискну. Для админа - ок. Для девопса - слишком сложная конфигурация для простой задачи. В 2020 году - это было бы отличное решение для большого и тяжелого проекта. Для малого… Traefik или Caddy вместо связки Nginx + Certbot. В 2026 году для динозавров вроде меня есть Angie - форк Nginx, который уже пару лет как умеет из коробки работать с сертификатами.

    И все это тогда будет намного проще автоматизировать через Ansible - не несколько тасок меньше, быстрее тестировать, быстрее деплоить - один конфиг, никаких дополнительных скриптов и хуков.


    1. cmtn Автор
      01.10.2026 10:12

      Спасибо!

      Проверил Angie - да, умеет сам с версии 1.5.0. И главное, он сам же подхватывает новый сертификат. У меня сломалось именно это: certbot положил новый файл на диск, а nginx продолжал отдавать старый, потому что его никто не перезапустил.

      Посмотрел свой конфиг после вашего комментария - там один домен и три прокси. Так что да, certbot с хуками для такого жирновато, на Caddy вышло бы строк десять.


  1. hyperwolf
    01.10.2026 10:12

    Текст отдает нейрослопом.

    Проверка nginx -t перед reload тут не формальность. Если конфиг успел сломаться между запусками, reload на битом конфиге уронит живой nginx, и вместо истёкшего сертификата получится отсутствующий сайт.

    -s reload не применит битый конфиг и ругнется на его битость.


    1. cmtn Автор
      01.10.2026 10:12

      Поправка принята, спасибо. Проверил: сломал конфиг на тестовом контейнере, дёрнул reload - ругнулся и не применил, сайт работает. Статью исправил. Про слоп - виноват мой ревьюер Клодий Гптивич :). Однако в реальности ситуации можете не сомневаться