Docker Swarm 上級編:Rolling Update、Placement Constraints、ダウンタイムゼロのデプロイ

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

実際のプロジェクトでDocker Composeを初めて使ったとき、今思えば笑えるような基本的なミスを多々犯した——コンテナを再起動する前にトラフィックをdrainするのを忘れて、deployが終わったらサイトが1時間もダウンしてしまった。チームに「なんでダウンしてるの?」と聞かれても説明できなかった。それがきっかけで、Docker Swarm上級編を深く掘り下げるようになった:制御されたRolling Update、Placement Constraints、そしてDocker Configだ。

この記事は、Swarmの基礎をすでに知っていて、本当の本番環境設定をしたい人向けだ——デモのラボではなく、実際のユーザーが使う24時間365日稼働するシステムのために。

背景:なぜこの設定レイヤーが必要なのか?

Swarmのデフォルト設定はそこそこ問題ないが、本番環境に移行すると、よく遭遇する問題にすぐぶつかる:

  • デフォルトのRolling updateがダウンタイムを引き起こす:Swarmは新しいreplicaを起動する前に古いreplicaを停止するため、サービスするインスタンスが存在しない時間的な空白が生まれ——ユーザーには502が表示される。
  • Containerの配置が制御できない:重いデータベースがRAMの少ないノードにスケジュールされたり、APIのreplicaがすべて1つのノードに集中してそのノードがダウンしたら終わり、ということになりかねない。
  • ConfigとSecretが環境変数に入っているdocker inspectログの集約、またはホスト上のps auxを通じて漏洩しやすい。
  • 自動ロールバックがない:deployが終わってからエラーに気づき、ユーザーが実際のエラーを見ている間に手動で対応しなければならない。

3つの機能——Placement Constraints、Docker Config/Secret、そしてorder: start-firstを使ったRolling Update——が、まさにこの3つの問題を解決する。

準備:ノードへのラベル付け

ラベルはPlacement Constraintsの基盤だ。stackファイルを書く前に、役割とハードウェアの特性に応じて各ノードにラベルを付ける必要がある。多くのチュートリアルがこのステップを省略しているため、読者はconstraintがなぜ機能しないのか分からず途方に暮れることになる:

# workerノードにroleラベルを付与する
docker node update --label-add role=worker node-1
docker node update --label-add role=worker node-2

# ストレージタイプを付与——データベースにとって重要
docker node update --label-add storage=ssd node-1
docker node update --label-add storage=hdd node-2

# マルチリージョンクラスターの場合はアベイラビリティゾーンを付与
docker node update --label-add zone=az-1 node-1
docker node update --label-add zone=az-2 node-2

# ラベルが正しく付与されたか確認する
docker node inspect node-1 --format '{{json .Spec.Labels}}'
docker node ls --format 'table {{.Hostname}}\t{{.Status}}\t{{.ManagerStatus}}'

ラベルを付与したら、docker node ls -q | xargs docker node inspect --format '{{.Description.Hostname}}: {{.Spec.Labels}}'で確認し、stackをdeployする前にすべてのノードにラベルがあることを確かめよう。

詳細設定

Docker ConfigとSecret——正しい設定管理

環境変数でconfigを渡す代わりに、Docker Configは静的な設定ファイル(nginx.conf、app.yamlなど)を保存し、Docker Secretは機密情報(パスワード、APIキー)を保存する。どちらも保存時に暗号化され、実行中のコンテナのRAM内でのみ復号される:

# ファイルからconfigを作成する
docker config create nginx_conf ./nginx.conf
docker config create app_settings ./app.yaml

# ファイルからsecretを作成する(shellのhistoryへの保存を避けるためstdinより推奨)
docker secret create db_password ./db_password.txt
docker secret create jwt_secret ./jwt_secret.txt

# 確認する
docker config ls
docker secret ls

stackファイルで宣言してコンテナにマウントする:

configs:
  nginx_conf:
    external: true
  app_settings:
    external: true

secrets:
  db_password:
    external: true

services:
  nginx:
    image: nginx:1.25-alpine
    configs:
      - source: nginx_conf
        target: /etc/nginx/nginx.conf
        mode: 0440         # オーナーとグループに読み取り専用、othersはアクセス不可

  api:
    image: myapp/api:latest
    configs:
      - source: app_settings
        target: /app/config/settings.yaml
    secrets:
      - source: db_password
        target: db_password
        mode: 0400         # オーナーのみ読み取り可——secretはconfigより厳格にすべき

Secretはコンテナ内の/run/secrets/<secret_name>にマウントされる。アプリは環境変数の代わりにこのファイルを読み取る——この方法はsecretがプロセスのenvironmentに現れず、docker inspectで漏洩しないため、はるかに安全だ。

Placement Constraints——ワークロードを適切なノードへ

上記で付与したラベルを使えば、どのコンテナがどのノードで動くかを正確に制御できる。constraintsはハードルール(必ず満たすべき)で、preferencesはソフトルール(可能な限り満たすよう努力)だ:

services:
  api:
    deploy:
      replicas: 4
      placement:
        constraints:
          - node.role == worker          # APIはmanagerノードで動かさない
          - node.labels.role == worker   # カスタムラベルでダブルチェック
        preferences:
          - spread: node.labels.zone     # AZ全体に均等に分散させ、1箇所に集中させない

  database:
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.labels.storage == ssd   # DBはSSDを持つノードでのみ動かす
          - node.role == worker          # managerでは動かさない

  redis:
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.labels.zone == az-1     # 必要に応じてRedisを特定のzoneに固定する

ダウンタイムなしのRolling Update——詳細設定

ここが最も設定ミスが多い部分だ。重要なパラメータはorder: start-firstで——Swarmは新しいreplicaを起動し、healthcheckが通過するのを待ってから、古いreplicaを停止する。ダウンタイムを引き起こすデフォルトのstop-firstとは逆だ。

しかし、order: start-first適切に設定されたhealthcheckと組み合わせた場合にのみ正しく機能する:

services:
  api:
    image: myapp/api:${VERSION:-latest}
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s    # アプリにhealth評価を開始する前に30秒の起動時間を与える
    deploy:
      replicas: 4
      update_config:
        parallelism: 1            # 一度に1つのreplicaを更新——保守的だが安全
        delay: 15s                # 各バッチ更新の間に15秒待つ
        order: start-first        # 新しいreplicaを先にSTART、古いreplicaはその後にSTOP
        failure_action: rollback  # update失敗時は全体を自動的にロールバックする
        monitor: 30s              # 各更新後30秒間監視して遅延エラーを検出する
        max_failure_ratio: 0.3    # ロールバックをトリガーする前に最大30%のreplica失敗を許容する
      rollback_config:
        parallelism: 0            # 0 = すべてのreplicaを同時にロールバック
        delay: 0s                 # ロールバック時は遅延なし——迅速さが必要
        failure_action: continue  # エラーが発生してもロールバックを続ける
        order: stop-first         # ロールバック時:まず新バージョンを停止し、次に旧バージョンを復元

healthcheckのstart_periodは、正しくチューニングするのに最も時間がかかった部分だ。アプリがデータベースへの接続、configのロード、キャッシュのウォームアップに20秒かかる場合は——バッファのためにstart_period: 25sに設定しよう。このパラメータがないと、Swarmは起動直後にコンテナが失敗したと判断する——コンテナは再起動を繰り返し、rolling updateが完了することはない。

検証とモニタリング

stackのdeployとrolling updateの監視

# stackを初めてdeployする
docker stack deploy -c docker-stack.yml myapp

# stack内のすべてのserviceを確認する
docker stack services myapp

# APIを新バージョンに更新——rolling updateが自動的に実行される
VERSION=v2.1.0 docker stack deploy -c docker-stack.yml myapp

# rolling updateの進行をリアルタイムで監視する
# 観察点:古いreplicaがShutdownになる前に新しいreplicaがRunningになっていること
watch -n2 'docker service ps myapp_api --format "table {{.Name}}\t{{.Node}}\t{{.CurrentState}}\t{{.DesiredState}}"'

更新が正しく進んでいる場合、replicas+1個のタスクが同時に存在する瞬間が見られる:新しいreplicaがRunning状態にある一方で、古いreplicaはまだShutdownに移行していない。これがゼロダウンタイムが機能している証拠だ。

ロールバックと配置の確認

# serviceを前バージョンにロールバックする(手動介入が必要な場合)
docker service rollback myapp_api

# データベースがSSDノード上で正しく動いているか確認する
docker service ps myapp_database --format 'table {{.Name}}\t{{.Node}}\t{{.CurrentState}}'

# ノード全体のAPIreplicaの分布を確認する
docker service ps myapp_api --filter 'desired-state=running'

# serviceのすべてのreplicaからログを集約する
docker service logs -f --tail 100 myapp_api

リソース使用量の監視

# service内のすべてのコンテナのリソース使用量を確認する
docker stats $(docker ps --filter 'name=myapp_api' -q)

# 設定済みのリソース制限を確認する
docker service inspect myapp_api --pretty | grep -A 8 Resources

# すべてのタスクのhealth状態を確認する
docker service ps myapp_api --format 'table {{.Name}}\t{{.Node}}\t{{.CurrentState}}\t{{.Error}}'

設定が完了したら、よくやるのが「ドライ」ローリングアップデートの実行だ:同じコードを新しいバージョン番号でイメージに再タグ付けし、更新をトリガーして、watch docker service psを眺める。古いreplicaがShutdownに移行する前に新しいreplicaがRunningになれば——ゼロダウンタイムdeploymentが正しく動いている。

この3つ——ワークロードを適切な場所に配置するPlacement Constraints、設定を保護するDocker Config/Secret、中断なしでdeployするためのorder: start-firstを使ったRolling Update——は、SwarmをProductionに持ち込む初日から必要なものだ。複雑ではないが、3つのうち1つでも欠けると確実にインシデントが発生する。筆者はこれを難しい方法で学んだ。

Share: