インフラ・DevOps
Docker Composeでゼロダウンタイムデプロイメントを実現 – Kubernetesは不要
Zero-Downtime Deployments with Docker Compose – No Kubernetes Required (statusdude.com)
要約
この記事は、Kubernetesが本格的なプロダクションサービスに必ずしも必要ではないと主張し、StatusDudeがDocker ComposeとHAProxyでゼロダウンタイムデプロイメントを実現した方法を紹介しています。彼らは当初Traefikで「サービスが複数回定義されています」や「スケールダウン競争」といった問題に直面し、特に別のバックエンドで失敗したリクエストを再試行できない点が致命的でした。その後HAProxyを採用し、その`option redispatch`機能と多層的なヘルスチェックにより、これらの課題を克服し、Kubernetesに代わる堅牢でシンプルなサービス管理方法を提供しています。
全文翻訳
←ブログに戻る Docker Composeでゼロダウンタイムデプロイメントを実現 — Kubernetesは不要 2026年6月24日 業界には、本格的なプロダクションサービスを実行するにはKubernetesが必要だという集団的な妄想があります。そんなことはありません。StatusDudeでは、毎分数千のモニタリングチェックを処理し、マルチリージョンワーカーを稼働させ、1日に複数回デプロイしていますが、これらすべてをDocker ComposeとHAProxyで行っています。リクエストのドロップはゼロ。ダウンタイムもゼロ。午前3時にetcdの面倒を見る必要もありません。しかし、私たちはHAProxyから始めたわけではありません。Traefikから始めました。それは約4時間で終わりました。
まずTraefikを試した TraefikはDockerベースのセットアップで人気のある選択肢です。Dockerラベルを介してサービスを自動的に検出し、洗練されたダッシュボードを備え、ドキュメントからは手間がかからないように見えます。私たちはTraefikラベルで2つのバックエンドレプリカを設定し、ローリングデプロイを実行しましたが、すべてが崩壊するのを見ることになりました。
「サービスが複数回定義されています」 私たちの最初のデプロイ戦略は、移行中に既存のバックエンドと並行して`backend_new`サービスを実行することでした。両方とも同じTraefikルーティングラベル(同じHostルール、同じサービス定義)を持っていました。これは理にかなっていますよね?切り替え中に古いものと新しいものの両方にトラフィックを処理させたいのです。Traefikはこれに同意しませんでした。そのDockerプロバイダーは、各Composeサービスを個別の構成ソースとして扱います。同じラベルを持つ2つのサービス?「サービスが複数回定義されています」。すべてのリクエストで404。フォールバックもマージもなく、ルーティングを完全に拒否しました。私たちは別々のサービスではなく、`docker compose --scale backend=4`を使用するようにアプローチを再構築しました。これによりラベルの競合は回避されましたが、次の問題が明らかになりました。
スケールダウン競争 ローリングデプロイ戦略は、レプリカを4つにスケールアップ(古いもの2つ+新しいもの2つ)し、その後2つにスケールダウン(新しいものだけを残す)するというものです。十分にシンプルです。しかし、Traefikの内部ルーティングテーブルの更新が十分速くありませんでした。4つから2つにスケールダウンすると、Traefikはシャットダウン中のコンテナにルーティングし続けました。他のすべてのリクエストで502エラーが発生しました。ルーティング状態はDockerの実際の状態から数秒遅れており、かなりの量のトラフィックが失われるのに十分な時間でした。遅延を追加したり、コンテナを停止する前にネットワークから切断したり(これにより、削除前にヘルスチェックがクリーンに失敗する)、パッシブヘルスチェックを試したりしましたが、これらは積極的すぎると偽陽性を引き起こすためすぐに元に戻しました。どれもうまくいきませんでした。しかし、本当に致命的だったのは全く別のことでした。
致命的な問題:別のバックエンドでの再試行がない これは開発者がしばらく無視している既知の問題です... https://github.com/traefik/traefik/issues/2723 シナリオは次のとおりです。ローリングデプロイ中に、古いコンテナを停止します。`docker stop`はSIGTERMを送信します。Uvicornはグレースフルシャットダウンを開始しますが、その間に窓口があります。すでに処理中のリクエスト、または停止シグナルとTraefikがルーティングテーブルを更新する間に到着するリクエストです。そのリクエストが終了中のバックエンドに到達すると、接続は途中で切断されます。クライアントは生の障害(空の応答、接続リセット、部分的な本文)を受け取ります。これは許されません。サービスとハートビートモニターが稼働していると報告された場合、それを認識する必要があります!では、Traefikはその失敗したリクエストに対して何をするかというと、何もしません。Traefikの再試行ミドルウェアは存在しますが、それは同じバックエンドで再試行します。まさに終了中のバックエンドで。再び失敗するバックエンドで。正常なバックエンドに再ディスパッチすることはありません。リクエストはただ...失われます。私たちはあらゆる組み合わせを試しました:パッシブヘルスチェック、停止前の切断、異なる試行回数での再試行ミドルウェア。しかし、根本的な問題は残りました — Traefikは失敗したリクエストを別のサーバーに送信できなかったのです。その日の午後、私たちはTraefikを取り除き、HAProxyに乗り換えました。
実際に必要なもの それでは、本質を掘り下げてみましょう。ゼロダウンタイムデプロイメントには実際に何が必要なのでしょうか?複数のバックエンドインスタンス — 他がトラフィックを処理している間に1つを置き換えられるようにする 失敗したリクエストを別のバックエンドで再試行するロードバランサー — 終了中のコンテナがリクエストをドロップしないようにする インスタンスを一度に1つずつ置き換えるデプロイスクリプト — ローリングアップデート それだけです。3つのこと。それぞれをどのように実現するか説明しましょう。
ステップ1:Docker Composeによる複数のレプリカ Docker Composeには、`deploy.replicas`という組み込み設定があります。
```yaml
# docker-compose.yml
services:
backend:
build: ./backend
deploy:
replicas: 2
image: myapp-backend
expose:
- "8000"
env_file: .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 5s
timeout: 5s
retries: 3
start_period: 5s
restart: unless-stopped
```
これは、共有のDocker DNS名`backend`の背後で実行される2つのバックエンドコンテナです。Dockerネットワーク内で`backend`を解決すると、両方のコンテナIPが得られます。1つのDockerfile、1つのイメージ、2つのコンテナ。Podの仕様も、デプロイメントも、レプリカセットもありません。
ステップ2:ロードバランサーとしてのHAProxy HAProxyは実績があり、高速で、設定も読みやすいです。しかし、私たちがこれを選んだ本当の理由は、`option redispatch`です。
```
global
log stdout format raw local0 info
maxconn 4096
defaults
mode http
timeout connect 3s
timeout client 30s
timeout server 30s
# THE key feature: retry failed requests on a DIFFERENT backend
retries 3
option redispatch 1
retry-on conn-failure empty-response response-timeout 502 503 504
resolvers docker_dns
nameserver dns1 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
# Re-resolve DNS every 2 seconds
hold valid 2s
frontend http_in
bind *:80
default_backend backends
backend backends
balance roundrobin
option httpchk http-check send meth GET uri /health http-check expect status 200
default-server inter 1s fall 1 rise 1 check resolvers docker_dns \
resolve-prefer ipv4 init-addr none \
observe layer7 error-limit 3 on-error mark-down
server-template backend 1-10 backend:8000 check
```
これを機能させる3つのことについて説明しましょう。
別のバックエンドでの再試行 これがTraefikでは提供できなかった機能です。`retries 3 option redispatch 1 retry-on conn-failure empty-response response-timeout 502 503 504` リクエストが失敗した場合(接続拒否、空の応答、502、503、504)、HAProxyはそれを再試行します。そして`option redispatch 1`は、すべての再試行が別のバックエンドに向かうことを意味します。同じ終了中のサーバーではなく、別の正常なサーバーへ。理にかなっていますよね?!ローリングデプロイ中に、リクエストがシャットダウン中のコンテナに到達し、空の応答を受け取った場合、HAProxyはサイレントに他のレプリカで再試行します。クライアントはエラーを目にすることはありません。リクエストのドロップもありません。この単一の機能により、Traefikで抱えていたすべての問題が解消されました。
3層のヘルス検出 私たちは単一のヘルスチェックメカニズムに頼っていません。それぞれ異なる障害モードを捉える3つの独立した層があります。レイヤー1 — リクエストごとの再試行(ミリ秒単位):単一のリクエストが失敗した場合、すぐに別のバックエンドで再試行します。デプロイ中の過渡的な障害を捉えます。レイヤー2 — パッシブ監視(`observe layer7`):HAProxyは実際のトラフィックからの実際のHTTP応答を監視します。バックエンドが3回連続で5xxエラーを返した場合(`error-limit 3`)、即座にローテーションから除外されます(`on-error mark-down`)。プローブサイクルを待つ必要はありません。レイヤー3 — アクティブヘルスチェック(`inter 1s fall 1 rise 1`):毎秒`/health`エンドポイントをプローブします。トラフィックを受け取らない完全に停止したバックエンドを捉えます。1回の失敗で即座にDOWN。1回の成功でローテーションに戻ります。各層は、他の層の死角をカバーします。リクエストごとの再試行は、終了中のバックエンドに到達する単一のリクエストを処理します。パッシブチェックは、負荷がかかるとエラーを返し始めるバックエンドを処理します。アクティブチェックは、トラフィックが流れていない状態でサイレントにクラッシュするバックエンドを処理します。
DNSベースのディスカバリ(Dockerソケット不要) `server-template backend 1-10 backend:8000 check`という行は、HAProxyがバックエンドを検出する方法です。これは、Dockerの組み込みDNSリゾルバー(127.0.0.11:53)を使用してDocker DNS名`backend`を解決し、見つかった各IPに対してサーバーエントリを作成します。`hold valid 2s`は、HAProxyが2秒ごとに再解決することを意味します。コンテナが停止した場合、そのIPはDNSから消えます。新しいコンテナが起動した場合、そのIPは表示されます。HAProxyはこれを自動的に認識します。Dockerソケットのマウントも、ラベルの解析も、動的な設定生成も不要です。静的な設定で...