Запустить приложение в Compose легко, а вот обновить его так, чтобы посетитель не увидел ошибку 502, — задача посложнее. Разберём полный стек: Nginx на внешних портах, приложение во внутренней сети, сертификаты Let’s Encrypt и деплой, при котором старая версия продолжает обслуживать запросы, пока новая не прошла healthcheck. Все конфигурации живут на хосте, контейнеры остаются одноразовыми.
Структура стека
Определим четыре сервиса: nginx, два варианта приложения — синий и зелёный, — и certbot. Два приложения нужны именно для переключения: пока новая версия прогревается, старая ещё отдаёт трафик.
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/backend.conf:/etc/nginx/backend.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
app_blue:
build: .
expose: ["8000"]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 5s
timeout: 3s
retries: 12
app_green:
build: .
expose: ["8000"]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 5s
timeout: 3s
retries: 12
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
Приложение не публикует порты наружу — его видит только Nginx внутри общей сети. Здоровье контролирует healthcheck: если приложение не отвечает на /healthz, Docker пометит контейнер unhealthy, и вы увидите это в docker compose ps ещё до переключения трафика.
Секреты и данные, которые переживают перезапуск
Контейнеры должны быть одноразовыми, а состояние — на хосте. Базу, загруженные файлы и логи приложения выносите в именованные тома или bind-пути, иначе команда docker compose down -v заодно удалит пользовательские данные. Секреты не хардкодьте в docker-compose.yml: приложение читает переменные из файла .env рядом с ним, Compose подставит их автоматически, а сам .env добавьте в .gitignore. Пароль базы держите в отдельном файле с правами 600 и монтируйте его в контейнер, чем передавайте через аргументы сборки — аргументы остаются в истории образа и видны любому, кто сможет выполнить docker image inspect.
Конфигурация Nginx и подмена бэкенда
Ключевой приём — переменная в proxy_pass вместе с локальным DNS-резолвером Docker. Без этого Nginx резолвит имя контейнера один раз при старте и падает, если бэкенд ещё не запущен.
server {
listen 80;
server_name example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
resolver 127.0.0.11 valid=10s;
include /etc/nginx/backend.conf;
proxy_next_upstream error timeout http_502;
proxy_connect_timeout 2s;
}
}
Файл /etc/nginx/backend.conf на хосте содержит ровно одну строку: set $backend http://app_blue:8000;. Именно её мы будем менять при деплое.
Получение и продление сертификатов
Первый сертификат получаем webroot-методом: Nginx отдаёт файлы из каталога вызова, certbot кладёт токен и подтверждает владение доменом.
docker compose run --rm certbot certonly --webroot \
-w /var/www/certbot -d example.com -d www.example.com \
--agree-tos -m admin@example.com --non-interactive
openssl x509 -enddate -noout -in ./certbot/conf/live/example.com/fullchain.pem
Продление оформляем скриптом на хосте: он запускает renew и сразу перечитывает конфигурацию Nginx, иначе новые сертификаты подхватятся лишь после ручного перезапуска.
#!/bin/sh
set -e
cd /opt/site
docker compose run --rm certbot renew
docker compose exec -T nginx nginx -t
docker compose exec -T nginx nginx -s reload
Скрипт кладём в /etc/cron.d или планируем таймером на 03:17 ежедневно. Периодически проверяйте выпуск: openssl x509 -enddate читает дату окончания, а docker compose run —rm certbot renew —dry-run показывает, что продление сработает.
Деплой без простоя
Сценарий обновления: собрали образ, подняли контейнер второй версии, дождались здоровья, переключили Nginx, остановили старый контейнер. Пока не выполнен шаг переключения, трафик идёт на старую версию.
#!/bin/sh
set -e
cd /opt/site
NEW=$1
if [ "$NEW" = blue ]; then OLD=green; else OLD=blue; fi
docker compose build app_$NEW
docker compose up -d app_$NEW
CID=$(docker compose ps -q app_$NEW)
until [ "$(docker inspect -f '{{.State.Health.Status}}' "$CID")" = healthy ]; do sleep 2; done
printf 'set $backend http://app_%s:8000;\n' "$NEW" > nginx/backend.conf
docker compose exec -T nginx nginx -t
docker compose exec -T nginx nginx -s reload
sleep 30
docker compose stop app_$OLD
Почему это работает без обрыва соединений: nginx -s reload выполняется мягко, воркеры дорабатывают открытые запросы, а новые сразу идут на новый бэкенд. Старый контейнер останавливается только через паузу, которая перекрывает время жизни медленных запросов. Если новая версия оказалась битой, откат — это повторный запуск скрипта с указанием старого цвета, пока тот ещё не остановлен.
Диагностика и типичные проблемы
- docker compose ps показывает состояние и healthcheck, логи конкретной версии — docker compose logs -f —since 10m app_green;
- всегда проверяйте файл перед применением: docker compose config -q выдаст ошибку синтаксиса без перезапуска сервисов;
- healthcheck вечно unhealthy почти всегда означает, что в образе нет curl или нет эндпоинта /healthz — проверьте wget или смените команду;
- первая установка: docker compose up -d —wait поднимет стек и дождётся здоровых контейнеров;
- ошибка host not found in upstream появляется при старте без resolver и переменной — вернитесь к блоку с 127.0.0.11;
- не используйте docker compose restart для Nginx: это рвёт соединения, в отличие от nginx -s reload;
- docker compose stats показывает в реальном времени, кто из сервисов съедает память, а docker compose top показывает, какие процессы живут внутри контейнера; это помогает отличить утечку приложения от проблем с диском хоста.
Итог
Нулевой простой в Compose достигается не магией оркестратора, а простой последовательностью: healthcheck новой версии, переключение бэкенда через переменную и мягкая перезагрузка Nginx, и лишь потом остановка старого контейнера. Тот же принцип работает с сертификатами — renew и reload в одном скрипте по таймеру. Такой стек из четырёх контейнеров остаётся полностью прозрачным: каждый шаг виден в docker compose ps и журнале, а откат укладывается в одну команду.