Implement zero-downtime deployment with graceful shutdown - #75
Open
granstel wants to merge 26 commits into
Open
Conversation
Эндпоинт /health и состояние готовности экземпляра. При получении сигнала остановки экземпляр сначала объявляет себя неготовым и держится поднятым ещё DrainDelaySeconds — за это время балансировщик успевает увести с него трафик. Без этой паузы порядок обратный: приложение закрывает порт, и только потом балансировщик замечает проблему, теряя попавшие в промежуток запросы. ShutdownTimeout хоста берётся из той же секции конфигурации: после паузы хост дожидается текущих запросов и разбора очереди фоновых работ. Это основа бесшовного обновления, сама раскатка — следующим коммитом.
…о умолчанию Две поломки, найденные при первой реальной сборке и запуске образа: - с переходом на централизованное управление версиями пакетов restore внутри образа перестал проходить: Directory.Packages.props не копировался в контекст сборки - шаблонный appsettings.json содержал "Port": "" в секции Tracing, и типизированная привязка конфигурации падала на старте. Значение заменено на 0 — оно и раньше означало "не настроено" Добавлен HEALTHCHECK: по нему скрипт раскатки понимает, что новый экземпляр готов принимать трафик. Ради него в образ доставлен curl. Проверено запуском контейнера: healthcheck зеленеет, /health отдаёт Healthy.
Docker Hub на каждый merge в master, теги — версия из csproj, хеш коммита и latest. Шаг раскатки идёт по SSH и включается переменной DEPLOY_ENABLED, поэтому до настройки доступа workflow только публикует образ. deploy/docker-compose.yml — Traefik с автовыпуском и автопродлением сертификата Let's Encrypt, приложение и Redis. Traefik следит за контейнерами через сокет Docker и проверяет /health раз в две секунды. deploy/rollout.sh — раскатка без простоя: новый экземпляр поднимается рядом со старым, старый гасится только после того, как стал здоровым новый. Если новый не поднялся за две минуты, скрипт откатывается на старый. Порядок остановки проверен на живом контейнере: через три секунды после сигнала /health уже отдаёт 503, а порт ещё принимает запросы; порт закрывается только после паузы вывода из ротации. deploy/README.md — что задать на сервере и в GitHub.
# Conflicts: # src/FillInTheTextBot.Api/Dockerfile # src/FillInTheTextBot.Api/Startup.cs # src/FillInTheTextBot.Api/appsettings.json
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
added 21 commits
August 30, 2026 14:04
Версия и тег образа берутся из имени тега релиза — как было принято в проекте раньше. Пуш в master сборку больше не запускает.
checkout v4 -> v7, setup-dotnet v4 -> v6, login-action v3 -> v4, build-push-action v6 -> v7. Мажорные версии переводят действия на рантайм Node 24; входные параметры, которыми мы пользуемся, не менялись.
Microsoft.* переведён на Warning: на Debug каждый HTTP-запрос давал около десятка строк внутренностей Kestrel и маршрутизации. На боевом сервере это вытесняло полезную историю из ограниченного по объёму лога.
Тег образа задаётся релизом, версия в проекте влияет только на сборку — приводим её в соответствие с выпущенной версией.
Скрипт гасил старый экземпляр сразу после того, как новый отвечал на /health напрямую по IP. Но Traefik узнаёт о готовности только своей активной проверкой раз в 3 секунды, и в этот зазор в пуле не оставалось живых серверов: старый уже неготов, новый ещё не добавлен — клиент получал 503 от самого прокси. Теперь rollout.sh спрашивает у Traefik через его API, появился ли новый экземпляр в serverStatus со статусом UP, и только тогда уводит старый. API поднят на служебной точке входа :8080, наружу не публикуется. Замер во время раскатки: было 12 ошибок на 338 запросов, стало 0 из 911 и 0 из 1342 в двух прогонах.
Каталог deploy/ на сервере был копией, которую обновляли руками, — она молча расходилась с репозиторием. Исправление простоя, например, живёт в rollout.sh, и на неподновлённом сервере раскатка снова роняла бы запросы, ничем это не показывая. Обёртка fitb-deploy, на которую замкнут ключ раскатки, теперь перед запуском подтягивает deploy/ с тега выпускаемой версии: частичный клон (--filter=blob:none --sparse) разворачивает только этот каталог и весит около мегабайта. Скрипты раскатки всегда соответствуют версии. Локальная конфигурация с секретами и сама обёртка не синхронизируются: обёртка — граница доверия, иначе любой коммит получал бы на сервере права группы docker.
Общая сеть требовала ручного docker network create на новом сервере — лишний шаг, о котором легко забыть и который ничем себя не напоминает, кроме отказа при запуске. Теперь rollout.sh создаёт её, если её нет. В compose сеть остаётся external. Проверено, что compose умеет принимать уже существующую сеть и что при docker compose down с чужими контейнерами Docker удалить её не даёт. Но владение отдавать прокси всё равно не стоит: потребителей трое, и ни один из них не владелец.
…ервера redis-compose.yml существовал только на сервере: параметры кэша сессий нигде не были записаны и восстановить их после потери машины было бы неоткуда. Теперь он в deploy/ и синхронизируется по тегу, пароль берётся из .env, так что в git секрета нет. Комментарии сокращены до сути. Пути каталогов раскатки, ключей и логов из репозитория убраны: KEYS_DIR и LOGS_DIR теперь обязательны в .env без значений по умолчанию, каталог раскатки обёртка читает из /etc/default/fitb-deploy. Остались только пути внутри контейнеров и стандартные системные.
Порт публикуется только на loopback сервера. Адрес в публикации указан явно: без него docker открыл бы 6379 на все интерфейсы в обход ufw, и redis оказался бы доступен из интернета — пароля для этого мало. Подключение снаружи: ssh -L 6379:127.0.0.1:6379 <сервер>
Убраны шаги раската, команды запуска, копирование конфигов и список секретов — всё это читается из rollout.sh, шапок compose-файлов, env-примеров и workflow. Осталась подготовка сервера, которой в репозитории нет, и решения, которые из кода не выводятся.
В README остались только схема и решения по раскатке. Описание установки обёртки, ограничений ключа CI и создания клона переехало в репозиторий инфраструктуры: это про конкретную машину, а не про сервис.
После переноса подготовки сервера в README осталось четыре пункта, из которых два дублировались: требование к A-записи уже есть в env.example, а расхождение готовности приложения и прокси описано в комментарии rollout.sh и в документации GracefulShutdownService и ShutdownConfiguration. Заметка про порты 25/465 к сервису отношения не имеет. Уникальное переехало туда, где пригодится: замеры простоя и признак, по которому 503 прокси отличается от 503 экземпляра, — в комментарий rollout.sh рядом с ожиданием ротации, чтобы это ожидание не сократили как лишнее; размен синхронизации по тегу — в шапку fitb-deploy. Заодно убрано упоминание Prometheus в env.example: он не развёрнут.
…атки Сеть создавалась в rollout.sh, потому что изначально казалось опасным отдавать её compose: потребителей трое. Проверка показала, что опасности нет — Docker не даёт удалить сеть, пока в ней живут чужие контейнеры, чужой compose down её не тронет. Владельцем сделан стек прокси: он поднимается первым и работает постоянно. Это выпрямляет первое развёртывание — раньше первый запуск раскатки создавал сеть, тянул образ, поднимал контейнер, ждал его готовности и только потом обнаруживал, что прокси нет. Проверка прокси перенесена в начало rollout.sh: она же теперь косвенно проверяет и сеть, а отказ происходит до образа и контейнера.
docker run с двумя десятками флагов заменён на service-compose.yml. Метки Traefik, тома и переменные стали читаемой конфигурацией. Собственный механизм обновления compose не годится: он гасит старый контейнер и поднимает новый, а --scale не даёт держать две разные версии одного сервиса. Поэтому два проекта из одного файла, blue и green: новая версия поднимается в свободном цвете, прежний гасится после подтверждения ротации от прокси. Ручной цикл опроса /health убран — его заменяет compose up --wait, который ждёт HEALTHCHECK самого образа. До этого healthcheck образа не использовался ничем, кроме вывода docker ps. Слив трафика задаётся через stop_grace_period вместо docker stop -t.
Готовность нового экземпляра rollout.sh снова проверяет сам, стучась с хоста в IP контейнера. Compose --wait без HEALTHCHECK ждал только состояния running и возвращался за секунду, то есть шаг готовности вместе с выводом логов при отказе пропал бы. Логи теперь печатаются и при таймауте ротации: причина отказа там может быть той же, а сообщение уводило разбираться в прокси.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR implements a complete zero-downtime deployment system for the service, including graceful shutdown handling, health checks for load balancer integration, and deployment automation scripts.
Key Changes
Deployment Infrastructure
deploy/rollout.sh: Bash script that orchestrates zero-downtime updates by:/healthendpointdeploy/docker-compose.yml: Traefik edge proxy configuration that:/healthendpointdeploy/env.exampleanddeploy/app-env.example: Configuration templates for deployment parameters and application secretsdeploy/README.md: Comprehensive deployment documentation covering architecture, setup, and operational proceduresApplication Health & Graceful Shutdown
ReadinessState: Tracks whether the instance is ready to accept trafficReadinessHealthCheck: Health check implementation that reports unhealthy during shutdownGracefulShutdownService: Hosted service that:DrainDelaySecondsto allow load balancer to remove instance from rotationShutdownConfiguration: Configuration class for drain delay and shutdown timeout valuesCI/CD Pipeline
.github/workflows/docker-publish.yml: Updated to:master.csprojfilelatestrollout.shon the server (whenDEPLOY_ENABLEDis true)Configuration & Testing
appsettings.json: Added shutdown configuration section with default drain delay (10s) and timeout (30s)AppConfiguration: AddedShutdownConfigurationpropertyHealthTests: Integration tests verifying:/healthreturns 200 OK when running normally/healthreturns 503 Service Unavailable during shutdownDockerfile: Addedcurlto base image for health checks.gitignore&.gitattributes: Updated to track deployment scripts with LF line endings while excluding secrets and certificatesImplementation Details
The deployment strategy ensures zero downtime by:
The health check endpoint (
/health) is critical to this design—it allows the load balancer to detect when an instance is shutting down and remove it from rotation before the port closes, preventing request loss.https://claude.ai/code/session_019QThhRUApAXHFb3t4Amaij