При обновлении приложения через Docker Compose старый контейнер останавливается, новый запускается. Между этими событиями может появиться окно, когда приложение недоступно.

Обычно предлагается два решения: добавить healthcheck или перейти на blue-green deployment. И тут мне стало интересно, насколько они действительно помогают, если запросы идут непрерывно.

Для проверки я собрал лабораторию с Caddy, двумя версиями небольшого HTTP-приложения и генератором нагрузки. Сравнил три способа обновления: обычное пересоздание контейнера, пересоздание с healthcheck и --wait, а также blue-green.

Всего получилось 36 основных прогонов и 43 200 клиентских запросов.

Blue-green показал лучший результат: ни одного HTTP 502 или 503 на 14 400 попыток. Но 11 запросов всё равно завершились транспортной ошибкой.

Что тестировал

Лаборатория состоит из Caddy и HTTP/1.1-приложения на Python. У приложения две версии: v1 и v2, каждая собрана в отдельный Docker-образ.

После запуска приложение три секунды находится в состоянии not_ready: порт уже слушается, но рабочие запросы получают HTTP 503.

При SIGTERM сервер перестаёт принимать новую работу и ожидает завершения ранее принятых запросов до 12 секунд. Docker Compose предоставляет ему 15 секунд на остановку.

В ответах возвращаются версия приложения, путь и request ID. Генератор проверяет не только HTTP-статус, но и полноту тела, согласованность версии в заголовке и JSON, а также идентификатор запроса.

Это важно: если клиент получил заголовок 200 OK, а затем соединение оборвалось, считать такой запрос успешным нельзя.

Все контейнеры работают во внутренней Docker-сети. У каждого прогона отдельный Compose.

Три способа обновления

1.Обычный recreate

Есть один backend app. Его образ меняется с v1 на v2 через docker compose up -d --no-deps.

Caddy продолжает работать, но во время пересоздания контейнера у него нет резервного приложения.

2. Healthcheck + --wait

Схема такая же, только у контейнера есть Docker healthcheck, а обновление выполняется с --wait.

Healthcheck проверяет /health раз в секунду. Compose ожидает перехода контейнера в состояние running или healthy.

При этом Caddy не читает состояние Docker healthcheck. Для него upstream по-прежнему один. Даже если Compose ещё ожидает готовности, запросы через proxy уже могут поступать на новый контейнер.

3. Blue-green

Здесь одновременно существуют два backend.

Пока Caddy отправляет запросы на blue/v1, запускается green/v2. Контроллер проверяет готовность новой версии, меняет upstream в конфигурации Caddy и выполняет caddy reload.

Затем проверяет ответ v2, дожидается завершения активной работы на blue и только после этого останавливает старый контейнер.

Контейнер Caddy не пересоздаётся. Это дополнительно проверялось по его ID, времени запуска и счётчику перезапусков.

Новые запросы переключаются на другую версию, а уже выполняющиеся запросы к старому backend не обязательно обрываются.

Как проводил измерения

Параметр

Значение

Частота запросов

20 в секунду

Продолжительность

60 секунд

Попыток за прогон

1 200

Максимум одновременно выполняющихся

20

Начало обновления

Около 20-й секунды

Повторений каждого сочетания

3

Было два профиля нагрузки:

  • fast - только быстрые запросы;

  • mixed - 90% быстрых запросов и 10% запросов с трёхсекундной задержкой.

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

Получилось 3 способа обновления × 2 профиля × 2 режима соединений × 3 повторения × 1 200 запросов = 43 200 попыток.

Тесты проходили на общем хосте: Ubuntu 24.04., arch64, 3 CPU, 3,81 GiB RAM. Использовались Docker 29.9.2, Compose 5.6.1 и Caddy 2.11.9.

Для backend установлен лимит 0,35 CPU и 128 MiB RAM. Приложение, задержки и нагрузка синтетические - это проверка механики обновления, а не производительности реального сервиса.

Результаты

Способ

Попытки

Корректные 200

HTTP 502

HTTP 503

Транспорт

Recreate

14 400

12 426

1 246

728

0

Healthcheck + --wait

14 400

12 837

826

736

1

Blue-green

14 400

14 389

0

0

11

Неуспешных запросов получилось:

  • Recreate - 1 974 (13,71%).

  • Healthcheck + --wait — 1 563 (10,85%).

  • Blue-green - 11 (0,076%).

Здесь учитываются только полностью полученные корректные HTTP 200. Частично полученный ответ относится к транспортной ошибке.

График замеров

График замеров

Почему healthcheck не спас

При обычном пересоздании встречались два характерных случая.

Первый - Caddy пытался соединиться с backend, которого в этот момент уже не было:

dial tcp 172.19.0.2:8080: connect: connection refused

Второй - новый контейнер уже принимал соединения, но ещё не был готов:

{
  "event": "request_rejected",
  "version": "v2",
  "request_id": "load-419",
  "path": "/",
  "reason": "not_ready"
}

Вариант с healthcheck и --wait дал меньше ошибок, но полностью их не устранил.

Причина понятна из архитектуры: --wait влияет на то, когда завершится команда Compose. Он не создаёт резервный backend и не запрещает Caddy отправлять запросы на ещё не готовое приложение.

В своей лаборатории я отдельно проверил healthcheck без --wait и --wait без healthcheck. Но эти контрольные сочетания выполнялись только по одному разу. Поэтому утверждать, что наблюдаемое уменьшение ошибок вызвано исключительно healthcheck, было бы неправильно.

Вывод: в протестированной конфигурации healthcheck и --wait не обеспечили непрерывную доступность единственного backend.

Blue-green: откуда взялись 11 ошибок

При blue-green не было ни одного полностью полученного ответа HTTP 502 или 503.

Режим

Корректных 200

Транспортные ошибки

Fresh

7 200 / 7 200

0

Keep-alive

7 189 / 7 200

11

Все 11 ошибок имели одинаковый тип:

RemoteDisconnected:
Remote end closed connection without response

В данных этих запросов также присутствовал признак:

connection_reused: true

То есть клиент пытался отправить очередной запрос через уже существовавшее соединение.

Например, в прогоне 02-blue-green-fast-keepalive-r1 две такие ошибки возникли приблизительно через 0,206 и 0,305 секунды после начала команды caddy reload.

При этом сам контейнер Caddy не перезапускался.

Что происходит с соединениями при reload

В отдельной функциональной проверке воспроизвелась следующая последовательность:

  1. Клиент отправил запрос и получил корректный ответ от v1.

  2. Соединение осталось открытым.

  3. Контроллер выполнил caddy reload.

  4. Следующий запрос через прежнее простаивающее соединение завершился RemoteDisconnected.

  5. Новый запрос через новое соединение получил корректный ответ от v2.

То есть сохранение процесса Caddy ещё не означает сохранения каждого клиентского соединения.

А что с запросами, которые уже выполнялись?

Отдельно я проверялся шестисекундный /slow.

17:37:36.092 request_start
version=v1
request_id=diagnostic-slow
attempt_id=528

17:37:42.093 request_end
version=v1
request_id=diagnostic-slow
attempt_id=528
delivered=true

Между этими событиями прошёл reload Caddy. Клиент получил полный HTTP 200 от старой версии.

Аналогичная проверка при SIGTERM показала, что уже принятый шестисекундный запрос завершился корректно, несмотря на начавшуюся остановку backend.

Способ

Корректные ответы /slow

Recreate

586 / 720

Healthcheck + --wait

609 / 720

Blue-green

719 / 720

Единственная неуспешная попытка /slow при blue-green тоже была транспортной ошибкой на переиспользуемом клиентском соединении.

Здесь нужно различать две ситуации: сохранение активного запроса и сохранение простаивающего соединения для следующего запроса.

Одна ошибка, которую легко посчитать неправильно

В серии healthcheck обнаружился ещё один любопытный случай.

Запрос load-403 получил заголовки HTTP 503 от v1, после чего соединение оборвалось:

IncompleteRead: IncompleteRead(0 bytes read, 72 more expected)

То есть сервер объявил длину тела 72 байта, но клиент не получил ни одного.

В логах backend есть request_rejected с причиной draining, а у Caddy — сообщение aborting with incomplete response.

Запрос поступил уже после начала SIGTERM. Вероятно, процесс завершился во время отправки отказа.

Что в итоге

Обычное пересоздание контейнера в этом тесте приводило к заметному количеству ошибок. Добавление healthcheck и --wait улучшило наблюдаемый результат, но не решило проблему отсутствия резервного backend.

Blue-green позволил подготовить новую версию до переключения и сохранить уже выполнявшиеся запросы старой версии.

При этом 11 клиентских попыток завершились транспортными ошибками - несмотря на отсутствие HTTP 502 и 503.

Для меня главный результат эксперимента не столько в преимуществе blue-green, сколько в различии между двумя формулировками:

«Proxy не вернул ни одного 502» и «все клиентские запросы завершились успешно».