Jenkins + Kubernetesでサービス単位の選択デプロイをどう設計したか
はじめに
この記事は 単一だったOmniludeバックエンドをAIと一緒にMSAへ移行した過程 の続きです。前回の記事でサービス境界をなぜ分けたのかを書いたなら、今回はその境界が実際のデプロイ単位にどうつながったのかを整理します。
MSAの話は、サービス名をいくつに分けたかで終わりがちです。しかし運用の手触りが本当に変わる瞬間は、コードのフォルダが分かれたときではなく、デプロイ単位が変わったときです。
この記事では、現在の構成がどんな問題を減らしたのか、どのように動いているのか、そして何がまだ足りないのかをまとめておきます。
コード分離よりも先にデプロイ分離を実感した理由
一つの大きなバックエンドだった頃に最も不便だったのは、機能が混ざっていること自体よりも、小さな修正一つがいつも全体デプロイの圧力に広がってしまうことでした。
ブログ機能を少し触っただけでも、大きなアプリ全体を再ビルドし、イメージも作り直し、同じ大きな塊をまた載せ直さなければなりませんでした。こういう構造では、変更頻度の高い領域から先に実験速度が落ちます。さらに大きかったのは、長い接続を維持する必要があるサービスまで同じデプロイ影響圏に入ってしまうことでした。
たとえば backbone-service のように WebSocket、SSE、イベントストリームに近い領域は、通常の機能修正と同じ圧力で揺らされると運用の体感がすぐ悪くなります。だから私にとってサービス分離は、どのAPIを誰が持つかという問題よりも、何を別々にデプロイできるかという問題に近いものでした。
Jenkinsがデプロイ対象を決める方法
サービス運用は Kubernetes の上で行い、CI/CD は Jenkins で扱っています。
この記事では実装の細部よりも、CI/CDの観点からデプロイ単位をどう切ったのかに焦点を当てて説明します。
現在のパイプラインでは、デプロイの出発点は BUILD_MODE とサービス別の boolean パラメータです。all なら全体をデプロイでき、auto ではチェックしたサービスだけをデプロイすることも、何も選ばなければ Git の変更から対象を自動検知することもできます。つまり手動では全体と部分の両方を扱えますし、自動トリガーでは変更ファイルを基準に対象を絞り込めます。
選択できるサービスは auth-service、backbone-service、storage-service、ai-service、game-service、blog-service、legacy-service の7つです。リポジトリは一つのままでも、デプロイ対象は最初からサービス単位で選べるようにしたわけです。
自動検知ロジックも比較的単純です。git diff で変わったファイルを見て、modules/{service} 配下の変更からどのサービスが修正されたのかを拾います。ただし、ここには重要な例外が一つあります。共通モジュールである common(jspring) が変わった場合は、共通基盤が揺れたと見なして全サービスを再ビルドします。このルールがないと、common の変更が部分デプロイの陰に隠れてしまいます。
私はこの点がかなり重要だと思っています。選択デプロイは「必要なものだけを素早く」という利点がありますが、共通規約の変更を軽く見ると、むしろ危険になるからです。
アーティファクトもサービス単位に分ける
実際のビルドフローも同じ考え方に従います。まず common をローカル Maven に publish し、その後で選択されたサービスだけを順番に bootJar までビルドします。並列ビルドではなく順次ビルドを選んだ理由も現実的です。Qクラス生成や Kotlin daemon の衝突を減らすほうが、今の段階では安定していたからです。
イメージビルドは kaniko コンテナの中で行います。この段階も並列ではなく順次実行です。速度だけを見れば並列のほうが格好よく見えるかもしれませんが、今の Omnilude にはリソース衝突を減らし、失敗箇所を読みやすくするほうが合っていました。
イメージ名もサービスごとに別々に対応づけています。たとえば auth-service は jongmin-auth、ai-service は jongmin-ai、blog-service は jongmin-blog という形です。こうしておくとレジストリでもデプロイ単位をそのまま読めますし、後から特定サービスだけを追いかけるのも楽になります。
フォルダ構成も運用単位に合わせる
Jenkins がビルド対象を決めるなら、Kubernetes はその結果を実際の運用単位として固定します。現在の構成は kubernetes/services/{service}/{dev|prd} です。各サービスは自分の Deployment と Service を持ち、ingress は ingress/{env} の下で環境別に管理しています。
この構造の利点は説明しやすいことです。あるサービスがどうデプロイされるのかを見たければ、そのサービスのフォルダを見ればよいからです。コード境界とマニフェスト境界が大きくずれていないので、運用の観点でも責任の所在がぼやけにくくなります。
ルーティングも同じ考え方です。dev-api.omnilude.com と api.omnilude.com の下で、path prefix 基準で各サービスをつないでいます。つまりサービス分離はリポジトリ内の概念にとどまらず、実際のAPI所有権や ingress ルーティングまで伸びています。
基本的な安定化装置もここで入ります。各サービスの Deployment には readinessProbe と livenessProbe が入り、一部の本番サービスには HPA、PDB、topology spread constraint も付いています。まだ全サービスが同じ水準の運用ガードレールを持っているわけではありませんが、少なくともどのサービスにどこまで安定化装置を入れたかを分けて管理できる状態にはなりました。
デプロイフローはシンプルに保つ
現在のデプロイ段階は、見た目よりずっと直線的です。
- Jenkins が今回のデプロイ対象を決めます。
- 選ばれたサービスだけをビルドしてイメージにします。
kubectl applyでサービス別マニフェストを反映します。kubectl rollout restartで新しいバージョンを上げます。kubectl rollout statusで各サービスが立ち上がるまで待ちます。- 開始、成功、失敗の状態を Slack に送ります。
ここで大事なのは派手さより読みやすさです。今の段階では、何を上げたのか、どこで失敗したのか、何を待っているのかが誰にでも分かる流れのほうがよいと判断しました。もっと複雑なデプロイ戦略は後から足せますが、現在の構造を説明しにくい状態だと、むしろ運用が不安定になりかねません。
適用後に変わったこと
この選択デプロイ構造で最も大きく変わったのは、小さな変更が全体再デプロイを強制しなくなったことです。実験速度が重要な ai-service、プロダクト機能を素早く磨きたい blog-service、比較的独立したゲーム側の作業を、それぞれ別のテンポで扱えるようになりました。
もう一つは説明可能性です。誰かに Omnilude の現在の運用構造を聞かれたら、サービス一覧、イメージ名、Kubernetes のフォルダ、ingress 経路、Slack 通知フローまでを一続きで話せます。個人プロジェクトでも、私はこうした説明可能性がかなり重要だと思っています。運用構造は動くだけで終わってはいけません。なぜこの分け方なのかを説明できてこそ、次の改善にもつながるからです。
ただし現在の構造は完成形ではありません。自動ロールバック、デプロイ後の smoke test、メトリクスベースのデプロイゲート はまだ入っていません。今の中心は rollout 確認と Slack 要約です。
まとめ
今の構造は巨大なCI/CDプラットフォームと呼べる段階ではありませんが、少なくとも「リポジトリは一つでも、デプロイはサービス単位で切る」という基準は、Jenkins パラメータ、イメージ名、Kubernetes フォルダ、ingress 経路、Slack 通知まで一貫してつながっています。
次の段階は明確です。この構造の上に、自動ロールバック、smoke test、より良い運用通知のような実際のガードレールを載せていくことです。この部分はまだ進行中の課題なので、実装が終わったら次の記事で続けて整理するつもりです。