すべてのバックエンドサービスが同じ方法でデプロイされるわけではない
あるチームが新機能をデプロイしようとしている。APIサービスの更新はスムーズに進んだ——数秒のローリングリプレイスメントで、ゼロダウンタイム。次にワーカーの更新に移る。誰かがデプロイを実行すると、突然、キューにあった画像処理ジョブがすべて消えてしまった。チームはログを凝視し、困惑する。「APIではうまくいったのに。なぜワーカーで壊れたんだ?」
このシナリオは、エンジニアリングチームで毎週のように発生している。根本原因は、ほとんどの場合、デプロイツールのバグではない。自分たちが扱っているバックエンドサービスの種類についての誤解である。
バックエンドサービスは表面上は似ている。すべてサーバー上で動作し、すべてコードを持ち、すべて更新が必要だ。しかし、どのように処理を受け取り、どのように状態を保持し、どのように中断を処理するかは、根本的に異なる。あるタイプに完璧に機能するCI/CDパイプラインが、別のタイプでは静かにデータを破壊する可能性がある。
APIサービス:常に待機、常に利用可能
最も馴染み深いバックエンドタイプはAPIサービスである。ポートをリッスンし、モバイルアプリ、Webサイト、または他のサービスからの受信リクエストを待ち、応答を返す。APIサービスがダウンすると、ユーザーはすぐにそれを感じる——アプリが読み込めなくなり、ページにエラーが表示され、トランザクションが失敗する。
次のフローチャートは、バックエンドサービスを処理の受け取り方で分類し、各タイプに推奨されるデプロイ戦略をマッピングしたものである。
APIサービスは常に準備ができている必要がある。インスタンスをスケールアップ/ダウンすることでトラフィックの急増に対処できなければならない。古い接続がまだ処理されている間も、新しい接続を受け入れなければならない。そして決定的に重要なのは、処理中のリクエストをドロップせずに更新されなければならないことである。
CI/CDにとって、これはデプロイ戦略が重要であることを意味する。単純な停止→起動のデプロイでは、アクティブなユーザーを遮断してしまう。ローリングアップデート、ブルーグリーンデプロイ、またはカナリアリリースが必要になる。パイプラインは、新しいインスタンスがヘルスチェックに合格してから古いインスタンスが削除されることを検証する必要がある。
ワーカー:リクエストではなくジョブを処理する
ワーカーはユーザーと直接通信しない。キューからタスクを取得し、一つずつ処理し、結果を保存する。画像リサイズ、メール送信、レポート生成——これらはワーカーのジョブである。ユーザーは写真をアップロードするとすぐに応答を受け取るが、リサイズはバックグラウンドで行われる。
ワーカーはライブリクエストを処理しないため、より穏やかに更新できる。重要な懸念事項は、アクティブなジョブをドロップしないことである。優れたワーカーパイプラインは、古いプロセスをシャットダウンする前に、現在のジョブが完了するのを待つ。キューシステムがサポートしていれば、ワーカーは停止中であることを通知し、現在のタスクを完了してから終了できる。新しいインスタンスが起動し、キューから次のジョブを取得し、システムは処理を失うことなく継続する。
チームが犯す間違いは、ワーカーをAPIサービスのように扱うことである。デプロイ中にプロセスを即座に強制終了し、進行中のジョブがすべて失われる。キューはジョブが途中で失敗したことを知らない——完了シグナルを受け取らないだけである。ジョブはブラックホールに消えてしまう。
Kubernetes Deploymentは永続的に実行されることを前提としており、トラフィックを管理するために readiness probe を使用する。対照的に、Jobは完了まで実行され、restartPolicy: Never を使用する:
# APIサービス - 永続的に実行され、readiness probeが必要
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: myapp/api:latest
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
---
# ワーカー - 単一のジョブを実行し、終了する
apiVersion: batch/v1
kind: Job
metadata:
name: image-resize-job
spec:
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: myapp/worker:latest
command: ["process-queue"]
スケジューラ:タイミングがすべて
スケジューラはスケジュールに従ってタスクを実行する。真夜中に古いデータをクリーンアップする。毎時、外部システムと同期する。毎朝6時に日次レポートを生成する。
スケジューラの課題は、重複実行を回避することである。スケジューラがクリーンアップジョブを実行していて、デプロイがちょうどジョブの開始時にスケジューラを再起動した場合、新しいインスタンスも同じジョブをトリガーするリスクがある。するとクリーンアップが2回実行され、競合やデータ問題を引き起こす可能性がある。
優れたスケジューラパイプラインは、古いインスタンスがスケジュールされたタスクを実行している間、新しいインスタンスが起動しないようにする。また、重複実行を検出してスキップするメカニズムも必要である。一部のチームは、二重実行を防ぐために分散ロックやデータベースマーカーを使用する。
コンシューマ:ストリームに追従する
コンシューマはワーカーに似ているが、連続的なデータストリームを処理する。KafkaやRabbitMQなどのメッセージブローカーから読み取り、メッセージをリアルタイムで処理し、ストリーム内の自分の位置を維持する必要があることが多い。
決定的な違いは、速度とオフセット追跡である。コンシューマが遅れると、システムを圧倒せずに追いつく必要がある。コンシューマがクラッシュした場合、最初からではなく、中断した場所から再開しなければならない。
CI/CDにとって、これはデプロイがオフセットコミットを慎重に処理しなければならないことを意味する。現在のオフセットをコミットせずにコンシューマをシャットダウンすると、既に処理済みのメッセージが再処理される可能性がある。間違ったオフセットから再開すると、メッセージをスキップしたり、二重に処理したりする可能性がある。パイプラインは、グレースフルシャットダウンと再開を確実にするために、メッセージブローカーと連携する必要がある。
内部サービス:隠れた依存関係
内部サービスはフロントエンドアプリケーションからアクセスされない。認証、設定、ログ、フィーチャーフラグなど、システム内の他のサービスにサービスを提供する。複数のサービスが、しばしば同期的に、それらに依存している。
内部サービスの危険性は、カスケード障害である。認証サービスの小さな変更が、それを呼び出すすべてのサービスを壊す可能性がある。設定サービスの応答が遅いと、システム全体でタイムアウトが発生する可能性がある。
これらのサービスは、より厳格なテストと、より注意深いデプロイを必要とする。パイプラインは、実際の呼び出し元の動作をシミュレートする統合テストを実行する必要がある。デプロイは段階的に行い、依存するサービスを監視する必要がある。ロールバックは迅速かつ信頼性が高くなければならない。なぜなら、爆発半径が広いからである。
バックエンドサービスのパイプラインのための実践的なチェックリスト
任意のバックエンドサービスのパイプラインを設計する前に、以下の質問を自問すること:
- このサービスはどのように処理を受け取るか?(リクエスト、キュー、スケジュール、ストリーム)
- タスクの途中で安全に停止できるか?できる場合、タスクはどうなるか?
- 回復できないインメモリ状態を保持しているか?
- 2つのインスタンスが同じタスクを同時に実行するとどうなるか?
- このサービスがダウンした場合、他にどのサービスが壊れるか?
- ユーザーやシステムが気付く前に、このサービスはどのくらいの期間利用できなくてもよいか?
これらの答えによって、ローリングアップデート、グレースフルシャットダウンフック、キュー連携、または依存関係を認識したデプロイ順序付けが必要かどうかがわかる。
まとめ
CI/CDパイプラインは、万能なスクリプトではない。それは、サービスが実際にどのように動作するかを反映したものである。APIサービスには接続認識が必要である。ワーカーにはジョブ完了の保証が必要である。スケジューラには重複防止が必要である。コンシューマにはオフセット管理が必要である。内部サービスには依存関係の連携が必要である。
自分が扱っているバックエンドサービスの種類を理解すれば、ブログ記事からパイプラインテンプレートをコピーするのをやめ、データ、ユーザー、そしてチームの睡眠を守るパイプラインを構築し始めることができる。