100人が同時に使うPlatform Cityの裏側
Platform City チームの川村です。
今回は、Platform Cityの裏側の、様々な制約やインフラ構成のポイントをご紹介します。
制約は3つありました。参加者は100人規模で1人あたり数アプリ、当日は建物が数百棟になること。一発勝負で、当日はやり直せないこと。そして予算が有限で、AWS の請求書は運営の財布から出ることです。以下の決定は、ほぼ全部この3つから出ています。
1. config は Git、テナントは API
最初に決めたのが、管理対象を2つのレイヤーに分けることでした。
| プラットフォーム基盤 | テナント実体 | |
|---|---|---|
| 何 | ArgoCD、共有 Gateway、監視、Crossplane 本体 | 参加者ごとの namespace・権限・quota・GitLab |
| 投入経路 | git push → ArgoCD が自動同期 | API から投入 |
| 正とするデータの置き場 | GitHub のリポジトリ | クラスタの etcd |
| 変更頻度 | 低い(PR レビューあり) | 高い(オンボーディングのたび) |
基盤側は教科書どおりの GitOps です。
問題はテナント側でした。テナント一覧は、ユーザーの操作のたびに増減する運用データで、静的なインフラ構成とは性質が違います。オンボーディングのたびに PR を作ってマージして同期を待つ、を100人分やると、人間のゲートを含まないオンボーディングという約束と正面衝突します。かといって API から kubectl apply 相当を叩くだけにすると、冪等性や自動修復を全部自分で実装することになります。
そこで、今回はCrossplaneを活用することにしました。Crossplaneはk8sで動くIaCツールとして知られていますが、汎用的な抽象化ツールとしても活用することができるのです。今回の設計では、1ユーザー = 1つの XTenant というカスタムリソースにして、control-plane の API から投入します。正しいデータはクラスタの etcd にあり、kubectl get xtenants がテナント一覧です。宣言的リソースなので、子リソースは Crossplane が継続的に reconcile してくれます。「git が単一のSSoT」という原則に、意図的な例外を1つ作った形です。副作用としてオフボーディングも綺麗になり、XTenant を消せば namespace も権限も GitLab のプロジェクトも消えます。
onboard の API は「存在しなければ作成、存在すれば 409」で、既存の XTenant には書き込みません。冪等性のために server-side apply を使いたくなるところですが、それをやると CI が更新した apps[].image を placeholder で巻き戻す事故が起こり得るので、書き込み経路そのものを閉じました。
2. XTenant が1人分に払い出すもの
XTenant に書くのは、オーナーと上限とアプリ一覧だけです。
apiVersion: platform.cnia.jp/v1alpha1
kind: XTenant
metadata:
name: taro-yamada
spec:
owner: [email protected]
cpuQuota: "2" # 既定値
memoryQuota: "4Gi" # 既定値
podsQuota: "6" # 既定値
apps:
- name: self-intro
theme: self-intro
trustTier: A
district: intro
image: registry.gitlab.com/.../self-intro-taro-yamada:latest
port: 8080
resources:
cpu: "250m"
memory: "512Mi"
これを投入すると、Crossplane の Composition が次のものを作ります。
Kubernetes 側には、テナント単位で Namespace、専用の Role と RoleBinding、NetworkPolicy 4本(default-deny、同一 namespace 内、DNS、公開 HTTPS)、ResourceQuota、HTTP → HTTPS リダイレクトの HTTPRoute。アプリ単位では Deployment(replicas: 1、/healthz の readiness と liveness、requests = limits)、Service、ALB のターゲットグループ設定、HTTPS の HTTPRoute です。GitLab 側には、参加者のサブグループとプロジェクト(テンプレートから import)、招待、main ブランチの保護設定を作ります。
XTenant の名前は、そのままテナントの住所になります。
| 払い出されるもの | 名前 |
|---|---|
| Kubernetes namespace | <xtenant> |
| 公開ホスト名 | <xtenant>.<街のドメイン> |
| GitLab サブグループ | .../citizen/<xtenant> |
1か所の命名が3つの識別子になるので、一度決めたら変えられません。逆に言えば、この1つを決めるだけで全部が揃います。
アプリの replicas は 1 固定で、PodDisruptionBudget も作っていません。100人分を全部2レプリカにすると Pod 数も CPU もメモリも倍になるので、ノードドレイン時の一瞬の停止は許容すると決めました。
3. Gatewayを共有する
Gateway API で素直にアプリごとに Gateway を作ると、ALB が Gateway 1つにつき1台立ちます。100人分作ったら ALB が100台です。請求書を見る前に気づけてよかったと思っています。
なのでクラスタ全体で Gateway は1つだけにして、全員がそこに集約します。TLS は共有の ALB で終端し、ACM の証明書を SNI で束ねます。
アプリを公開するのに必要なのは、ターゲットグループ設定(targetType: ip)と HTTPRoute です。HTTPRoute は本来、HTTPS 本体と HTTP から 301 リダイレクトするものの2本がアプリごとに要ります。ただ ALB のリスナールールの既定クォータが100なので、100人 × 2本 = 200 で上限の2倍、3アプリなら6倍になってしまいます。そこでアプリに依存しないリダイレクトのほうはテナント単位の1本に集約し、あわせてクォータの引き上げも申請しました。コストではなくクォータが形を決めた例です。
| リソース | テナントの権限 | なぜ |
|---|---|---|
| HTTPRoute | フル | 自分のアプリを公開するのに必要 |
| ターゲットグループ設定 | フル | ClusterIP を ALB のバックエンドにするのに必須 |
| Gateway / GatewayClass | なし | 作られると ALB が1台増えて課金される |
| LoadBalancerConfiguration | なし | 証明書と scheme は基盤側の管理物 |
ドメインも1テナント1ホスト名にして、複数アプリはパスで分けます(/ と /my-game/)。
4. EKS Auto Mode の癖
クラスタは EKS Auto Mode にしました。ノードのプロビジョニング、スケール、パッチを AWS に寄せられるので、少人数の運営チームには合っています。ただ癖もあります。
Auto Mode の内蔵ロードバランシングは Gateway API に対応していないので、AWS Load Balancer Controller を別途入れて2つのコントローラを共存させています。
組み込みの NodePool は AWS 管理で、有効/無効しか切り替えられません。そのままだと Karpenter が最安の large(2vCPU / 4GB)を選び、100人分のアプリでノードの requests が埋まります。なのでインスタンスサイズの下限を xlarge(4vCPU / 8GB)にした独自の NodePool を作り、weight を高くして優先させています。ノードの統合は WhenEmpty(空になったときだけ)です。WhenEmptyOrUnderutilized だと台数が減って requests 比率が再び逼迫します。NodePool 全体の vCPU 合計にも上限を置いていますが、そこに当たると組み込み NodePool にフォールバックして同じ事故が再発するので、最悪ケース(1テナント 1.75 vCPU × 100)から逆算して一般公開前に引き上げました。
5. テナント隔離と、正直な限界
参加者に namespace を渡して edit ロールを付けるで済ませたくなるところですが、やめました。edit ロールには NetworkPolicy の書き込みが含まれているので、これを渡すと参加者が自分で隔離を解除できてしまいます。
なので専用の Role を作り、NetworkPolicy は読み取りだけにしました。Pod の直接作成も許可していません。Deployment や Job 経由では作れますが、素の Pod は作れないようにしています。EKS の NetworkPolicy は controller 配下の Pod を対象にする仕様なので、直接 Pod を作られるとポリシーの適用から漏れる可能性があるためです。ServiceAccount、RBAC、namespace、Gateway 関連も触れませんし、ClusterRoleBinding は一切付けていないので、テナントは kubectl get namespaces すら通りません。
egress の FQDN 許可リストは作りませんでした。100人が何の API を叩きたくなるか予測できないからです。代わりに「プライベートアドレス帯・ループバック・リンクローカルを除外した、任意の公開 IPv4 への TCP 443 を許可する」という形にして、これを契約として明示しました。DNS は kube-system の CoreDNS 向けだけ開けています。
限界も書いておきます。Pod の起動から NetworkPolicy が適用されるまでには時間的な隙間がありますし、ホスト自身との通信など CNI 由来の制約も残ります。継続的なホスト侵害を想定するような強い隔離は保証しません。1日のイベントで、参加者の身元が GitLab 側で分かっている前提での割り切りです。こういうのは「完璧です」と言わないほうが後で楽だと思っています。
6. デプロイの順番待ちを Kubernetes に載せた
前回までに同時にロールアウトするデプロイ数に上限を置いていると書きました。その実装の話です。100人が同じ時間帯にデプロイすると、Crossplane の reconcile と Kubernetes のロールアウトが同時に殺到するので、受付と実行を分けてキューを挟んでいます。
キューの置き場所は Kubernetes のカスタムリソース XDeployment です。書き込み頻度は最大500棟 × 状態遷移3〜4回で高々2000回程度なので、etcd で十分持てます(当初は外部ストアに置く設計で、見積もりを誤っていました。経緯は次回書きます)。CR にしたことで、kubectl get xdeploy で App / Phase / Image / Age が表で見えるようになり、当日トラブったときに状況が一目で分かります。街の BFF も普通の informer でキュー状態を拾えるので、「デプロイ待ちの建物」のための専用の連携が要らなくなりました。
ワーカー側で気をつけたのはこのあたりです。
- 順序は FIFO です。作成時刻順で、同時刻なら名前順。テナント横断で公平にします
- 同時実行数に上限(既定15)を置き、同じアプリへの連続デプロイは1件ずつ直列にします
- 「枠を取った」という記録を先に保存してから宣言を更新し、成功したらフラグを立てます。途中で落ちても、再起動後に未適用の予約だけを安全に再適用できます
- 観測不能を成功として扱いません。対象のイメージが Deployment に入り、その世代が観測済みで、Pod が揃っている、を全部確認してから成功にします。通信エラーや競合では枠を保持して再試行します
- 失敗理由は機械可読なコードにして、
next_stepsをセットで返します
ビルドの並列数は control-plane では管理しません。ビルドは deploy API を叩く前に CI Runner が終わらせているので、このキューはビルド中の件数を表していないからです。実態を表さない架空のビルド枠は作らず、並列数の制御は Runner 側の設定に寄せました。
7. 街への配信をどう安定させたか
街のリアルタイム更新は SSE(Server-Sent Events)です。接続確立時に必ずスナップショットを先頭に送り、15秒ごとにキープアライブを流しています。共有 ALB のアイドルタイムアウトは既定60秒なので、これを忘れると当日1分ごとに街が固まるという現象が起きます。
3D 側は素の ES Modules + Three.js です。モデルの容量は gzip 転送量 5MB と非圧縮 20MB の2本立てで予算を置いていて、実測は非圧縮 約3.8MB / gzip 約0.5MB なのでまだ余裕があります。
外形監視は1棟あたり60秒周期、同時4件までに絞っていて、100棟規模でも合計 1.7 req/s です。User-Agent を citymap-bff-probe/1 にして、参加者が自分のアクセスログで見分けられるようにしました。
8. 壊れる前提で段を用意する
一発勝負なので、壊れ方を先に決めておきました。
| 段 | 発動条件 | 挙動 |
|---|---|---|
| 1 | ネットワークの瞬断、BFF の再起動 | 自動再接続。画面に「再接続中」バッジ。街は最後の状態で止まる |
| 2 | BFF が全損 | 定期的に書き出してあるスナップショットを Web 側が静的配信。「オフライン表示(HH:MM 時点)」バッジ |
| 3 | クラスタや ALB が全損 | 埋め込みデータで動くデモモードに URL を差し替える |
| 4 | 会場ネットワークが全損 | ローカル実行版をスクリーン用 PC で起動する |
設定値(成長の閾値、締切時刻、お知らせの文面、区画の定義)は全部 ConfigMap に出して、コード変更なしで当日変えられるようにしています。壊れた値が入っていたら既定値にフォールバックして起動する、というのも入れました。厳密に検証して起動拒否するほうが正しそうに見えますが、当日 config を書き間違えてサービスが上がらないほうが事故として重いと判断しました。
9. 認証は2系統
参加者には kubectl も渡します。人数分の IAM ユーザーを発行するのは現実的ではないので、Auth0 を OIDC プロバイダとして EKS に登録し、kubelogin でブラウザからログインしてもらいます。ID トークンの email クレームがそのまま Kubernetes のユーザー名になります。運営側は AWS SSO からロールを AssumeRole する形にして、cluster-admin / editor / viewer の3段にしました。
ArgoCD の SSO も Auth0 に直結させましたが、組み込みの admin は残してあります。Auth0 が落ちたときと、RBAC の設定を自分で壊したときの復旧経路が必要だからです。
10. Terraform / GitOps / 手動の線引き
何をどこで管理するか、最初に線を引きました。
| 管理手段 | 対象 |
|---|---|
| Terraform | EKS クラスタ、VPC、Route53、ACM、IAM、Pod Identity、ECR、DynamoDB(いいねの元帳) |
| GitOps | Kubernetes 内のすべて(RBAC、Gateway、監視、Crossplane、アプリ) |
| 一度きりの手動 | GitLab のトップグループ、SAML 設定、Secret の投入 |
気に入っている設計をひとつ挙げると、いいねの元帳を append-only にするために IAM でそれを強制していることです。アプリケーションに与える DynamoDB の権限から UpdateItem と DeleteItem を外し、CI 用のロールにもデータプレーンの権限を与えていません。コードは書き換えられますが、権限がなければ書き換えても動きません。
AI 前提で変わった部分と、変わらなかった部分
AI 前提で変わったのは、主にインターフェースとフィードバックループの設計でした。磨く対象が UI からツールの入出力に移り、エラーレスポンスがエージェントが次の行動を決める構造化データになり、人間の承認ゲートの代わりに quota や RBAC や CI の強制といった構造で統制するようになりました。
一方で、変わらなかったことのほうが多いです。宣言的な基盤運用、冪等性、最小権限、隔離を利用者が解除できない作り、壊れ方を先に設計しておくこと。エージェントという新しい利用者に従来の原則をそのまま適用しただけで、むしろエージェントが相手だと手抜きがバレやすいぶん、原則に忠実にならざるを得なかったと思っています。
次回は、Platform Cityの構築の過程での判断と失敗の話です。
Platform City の続報や当日の様子は、公式 X でも発信していきます。ぜひフォローして最新情報をチェックしてください! https://x.com/cnia_pfem(ハッシュタグ: #PEK2026)
Platform Engineering Kaigi 2026
- 開催日:2026年9月26日(土)
- 場所:中野セントラルパークカンファレンス
- 申し込み:FindyConference (一般申し込み、オンラインともにリンク先からの申し込みが必要となります)
- 問い合わせ:問い合わせフォーム