/var/log/letsencrypt was the only thing this image wrote outside its volumes, and it is what stopped `read_only: true` from working. --logs-dir moves it to /etc/letsencrypt/logs, beside the account key and the renewal config it already keeps there. The second gain is the one that matters more here. This is the service whose breakage does not announce itself: a renewal that stops working is an expired certificate sixty days later, and the log is what says why. On the root filesystem it died with every container the registry replaced; in the volume it outlives them. Nothing else about the container needs to change to be confined. It never changes user, the files it touches are its own, the ACME challenge is served on 380 rather than a privileged port, and the HAProxy runtime socket it writes to is group-owned by root -- so it reaches it by permission rather than by CAP_DAC_OVERRIDE, and `cap_drop: ALL` takes nothing away. Measured on the live relay: no file under /etc/letsencrypt or /etc/certificates is owned by anyone but root, and the socket is srw-rw---- 1001:0. Both READMEs say what a caller should set now, stop_grace_period included -- the entrypoint has asked for that one since it was written.
16 KiB
Общие сведения
English version: README.md
Certbot — клиент Let's Encrypt от EFF: получает и перевыпускает бесплатные TLS-сертификаты.
Certbot, работающий в docker-контейнере циклом перевыпуска, в паре с контейнером haproxy, который он снабжает сертификатом.
Репозиторий описывает только развёртывание в docker.
Использование
Контейнер — не разовая команда. Его команда по умолчанию представляет собой цикл: выполнить проход, поспать 12 часов и повторить, — поэтому сертификат он получает при первом старте и дальше поддерживает свежим.
Каждый проход делает три вещи, по скрипту на каждую:
| Скрипт | Что делает |
|---|---|
entrypoint.sh |
Сам цикл и PID 1 контейнера. Выполняет проход, спит 12 часов, повторяет; неудачный проход не обрывает цикл, а сообщается и повторяется |
1-ensure-cert.sh |
Сам проход. При первом запуске пишет самоподписанную заглушку, чтобы HAProxy смог занять 443; затем дожидается HAProxy и просит у certbot сертификат ровно на имена из CERTBOT_DOMAIN — выпуская его, перевыпуская при изменившемся списке имён либо не трогая вовсе |
2-concatenate-cert.sh |
Склеивает fullchain.pem и privkey.pem в единый site.pem, которого ждёт HAProxy |
3-update-haproxy-cert.sh |
Устанавливает site.pem в работающий HAProxy через его runtime API — без перезапуска и без разрыва соединений |
Проверка владения — HTTP-01 на порту 380. Внутри контейнера на нём слушает
собственный standalone-сервер certbot, а HAProxy передаёт туда
/.well-known/acme-challenge/ с публичного порта 80. Больше до 380 никто
доступа иметь не должен.
Let's Encrypt требует, чтобы публичные записи A/AAAA домена указывали на эту машину, а порт 80 был доступен из интернета.
docker-compose
services:
certbot:
build:
context: https://git.bitdeals.org/private/certbot.git
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/certbot
restart: unless-stopped
environment:
- CERTBOT_DOMAIN=example.org,www.example.org
- CERTBOT_EMAIL=admin@example.org # необязательно, для уведомлений об истечении
volumes:
- certificates:/etc/certificates # общий с haproxy
- letsencrypt:/etc/letsencrypt # ключ учётной записи, сертификаты, настройки перевыпуска
- letsencrypt_work:/var/lib/letsencrypt
volumes:
certificates:
letsencrypt:
letsencrypt_work:
certificates — тот же том, который HAProxy подключает только на чтение; пишет
в него именно этот контейнер.
docker cli
docker run -d \
-e CERTBOT_DOMAIN=example.org,www.example.org \
-v certificates:/etc/certificates \
-v letsencrypt:/etc/letsencrypt \
-v letsencrypt_work:/var/lib/letsencrypt \
registry.bitdeals.org/certbot
Цикл перевыпуска — это CMD образа, а точка входа базового образа (certbot)
сброшена, поэтому всё, что указано после имени образа, заменяет цикл целиком:
разовая команда над тем же состоянием не требует --entrypoint:
docker run --rm \
-v letsencrypt:/etc/letsencrypt \
-v letsencrypt_work:/var/lib/letsencrypt \
registry.bitdeals.org/certbot certbot certificates
Сборка и публикация
Push в main собирает и публикует образ (.gitea/workflows/build.yaml) с тремя
тегами: <версия>.<sha7> — для развёртывания, <версия> — для чтения и
latest — для compose и Watchtower. Еженедельный cron пересобирает образ из тех
же исходников. Вручную, если под рукой учётные данные реестра:
docker build . --file docker/Dockerfile --tag registry.bitdeals.org/certbot
docker push registry.bitdeals.org/certbot
Контекст сборки — корень репозитория, а не docker/: Dockerfile копирует
./docker/scripts/, поэтому при контексте ./docker этот каталог не виден и
сборка падает на COPY.
Параметры
Образы контейнера настраиваются параметрами, передаваемыми при запуске.
| Параметр | Назначение |
|---|---|
| -e CERTBOT_DOMAIN | Домен, на который выпускается сертификат, либо несколько через запятую (a.org,b.org) — это собственный синтаксис -d у certbot: получается один сертификат, несущий все имена в Subject Alternative Names. По первому домену назван каталог сертификата под /etc/letsencrypt/live. По умолчанию: пусто — сертификат не запрашивается и сайт молча остаётся с самоподписанной заглушкой |
| -e CERTBOT_EMAIL | Адрес для уведомлений Let's Encrypt об истечении срока. По умолчанию: пусто, регистрация идёт с --register-unsafely-without-email, и предупреждений не будет — см. «Замечания» |
| -v /etc/certificates | Общий с HAProxy. Содержит site.pem — склеенные сертификат и приватный ключ, которые загружает HAProxy |
| -v /etc/letsencrypt | Каталог конфигурации certbot: ключ учётной записи ACME, выпущенные сертификаты и настройки перевыпуска. Его потеря означает повторную регистрацию и повторный выпуск |
| -v /var/lib/letsencrypt | Рабочий каталог certbot. Базовый образ объявляет его как VOLUME, поэтому без именованного тома при каждом создании контейнера возникает новый анонимный |
| -p 380 | Порт ACME-проверки HTTP-01. Внутренний: на него проксирует HAProxy. Публиковать его на хост не нужно и не следует |
Замечания
- Без адреса электронной почты предупреждений об истечении не будет. Пустой
CERTBOT_EMAILозначает регистрацию с--register-unsafely-without-email: если перевыпуск начнёт падать, вы узнаете об этом только когда сертификат истечёт. Следите за сертификатом внешними средствами или задайте переменную. - Первый сертификат самоподписанный, и браузеры об этом скажут. HAProxy не
стартует без
site.pem, поэтому1-ensure-cert.shпрежде всего пишет заглушку. Она заменяется, как только выпущен настоящий сертификат, — но если выпуск не удался, сайт продолжает отдавать именно заглушку, и сообщение об этом есть только в журнале контейнера. - Приватный ключ попадает к HAProxy через unix-сокет, а не по сети.
3-update-haproxy-cert.shпередаёт весьsite.pemв runtime API HAProxy — канал уровняadminбез аутентификации, — поэтому том сadmin.sockдолжен быть разделён с haproxy и больше ни с кем. TCP-порт был бы доступен любому контейнеру в общей сети, а правил по портам у docker-сетей нет. См. замечание о runtime API в README haproxy. - Отказ в установке сообщается, а не проглатывается. Runtime API сообщает об
отказе текстом ответа и всё равно закрывает соединение штатно, поэтому код
возврата socat ни о чём не говорит;
3-update-haproxy-cert.shвместо этого разбирает ответы наset ssl certиcommit ssl certи останавливается на первом, который не является подтверждением. Исправить он ничего не может:site.pemна томе в любом случае верен, поэтому отказ означает, что работающий HAProxy остаётся на прежнем сертификате до перезапуска. Об этом он и пишет. - Установка выполняется на каждом проходе, а не при перевыпуске. Скрипт
склеивает и переустанавливает сертификат независимо от того, сделал ли
certbot certonlyхоть что-нибудь, — дважды в сутки. Вреда нет, но это значит, что путь «сертификат обновлён» задействуется постоянно и настоящий перевыпуск ничем не отличается от любого другого прохода. Для этого у certbot есть--deploy-hook. site.pemвсегда заменяется атомарным переименованием — и на пути заглушки, и на пути перевыпуска. HAProxy читает этот файл при старте, а обычное перенаправление вывода в него оставляет промежуток, в котором на томе лежит обрезанный PEM, — то есть сертификат, с которым HAProxy откажется стартовать.- Остановка во время выпуска может не уложиться в отведённое docker время.
POSIX-оболочка выполняет обработчик сигнала только после возврата команды
переднего плана, поэтому SIGTERM, пришедший, пока
certbot certonlyобщается с Let's Encrypt, придерживается до конца этого вызова — дольше, чем отведённыеdocker stopпо умолчанию 10 секунд, после которых контейнер убивают посреди выпуска. Ничего при этом не портится (состояние в/etc/letsencryptпереживает, следующий проход доделает), но если чистая остановка важна — задайте сервисуstop_grace_period: 60s. Сон между проходами прерываемый и реагирует за миллисекунды. - Скрипты подключаются через
., а не запускаются отдельным процессом, поэтому текущий каталог,set -eи всякая оставленная переменная наследуются следующим скриптом. Именно поэтому они обращаются к файлам по абсолютным путям, аcdиumaskдержат внутри подоболочек; новые пишите по тому же правилу. - Контейнер работает от root, как и базовый образ, — ему нужно писать в
/etc/letsencrypt. Прав здесь никто потом не понижает. - Базовый образ не зафиксирован.
FROM certbot/certbot:latestвместе с еженедельной пересборкой по cron означает, что новый выпуск certbot попадает в реестр, а через Watchtower и в продуктив, без того чтобы кто-либо запускал сборку. Для воспроизводимых сборок фиксируйте версию тегом. CERTBOT_DOMAINперечитывается на каждом проходе, и сертификат следует за ним. Проход выполняетcertbot certonly --cert-name <первое-имя> --keep-until-expiring -d "$CERTBOT_DOMAIN", поэтому добавленное имя попадает в сертификат в течение 12 часов и выпускать его вручную не нужно. В обратную сторону это работает так же, и вот это — острый край: убранное имя приводит к перевыпуску без него, и на следующем проходе сайт под этим именем остаётся без TLS. Смена первого имени — третий случай: по нему назван каталог под/etc/letsencrypt/live, поэтому рядом со старым сертификатом заводится новый, а старый перестаёт продлеваться.- У Let's Encrypt есть ограничения частоты. Повторяющиеся неудачные попытки
выпуска на один домен в них засчитываются; проверяйте изменения на
--server https://acme-staging-v02.api.letsencrypt.org/directory, прежде чем оставлять цикл повторять их каждые 12 часов. - Ни одна capability здесь не нужна, а после
--logs-dirне остаётся и записи за пределы томов. Контейнер не меняет пользователя, трогает только свои файлы, а ACME-челлендж отдаёт на порту 380, а не на привилегированном, поэтомуcap_drop: ALLему ничего не стоит. Исключением был журнал:/var/log/letsencryptна корневой файловой системе. Теперь он пишется в томletsencrypt, рядом с остальным состоянием, и именно это делает возможнымread_only: trueи сохраняет журнал неудавшегося продления после замены контейнера. Задайте сервису иstop_grace_period: сигнал, пришедший во время выпуска, откладывается до конца прохода, а выпуск умеет тянуться дольше десяти секунд, которые даёт docker.