はじめに
CyberAgent group Infrastructure Unit(CIU)の川原です。本プロジェクト全体のリーダーを担当しました。本記事では、ユーザーへのサービス提供を担う API / コントローラについて紹介します。
API / コントローラで満たしたいこと
本連載の第1回で、後藤さんが新リージョンの要件を紹介してくれました。
簡単に言えば、単なる VM 基盤ではなく、NIST で定義されているようなクラウドとしての VM サービスを提供することを目指しました。ここで重視したのは、基盤側の保守や構成変更をユーザーに意識させず、継続して VM を利用できることです。パブリッククラウドでも、基盤側の事情によって VM の再起動や AZ の移動を依頼されることはありますが、ユーザー自身に VM の再構築やデータ移行を求められることはほとんどありません。私たちも、基盤を移行する際にユーザーへ同様の負担をかけないサービスを目指しました。
この利用体験を維持するため、ユーザーには OpenStack を直接公開せず、Cycloud の API とコントローラによって抽象化する構成を採用しました。これにより、ユーザー向けの API やリソースモデルを維持したまま、サービスの裏側にある実装を継続的に拡張・改善できます。
一方、あとから変更しやすい内部実装については、開発速度を優先して過度に作り込みませんでした。OpenStack は API とコントローラの背後に隠蔽できるため、将来的に別の VM 基盤へ切り替えられます。これに対して、VPC のリソースモデルやユーザー向け API は、公開後に変更するとユーザーの設定や運用に直接影響します。そのため、安定した運用実績を持つ OpenStack を継続して採用しつつ、VPC のようにあとから変更しにくい、ユーザーとの契約になる部分の設計に注力しました。
以上の前提のもと、VM サービスを提供するための API / コントローラの設計について紹介します。
Cycloud のデザインパターン
まず、Cycloud のサービスがどのような形で提供されているかを紹介しつつ、 本プロジェクトにおける全体像を紹介します。
gRPC API を中心としたユーザーインターフェイス
Cycloud では、原則としてすべてのインフラリソースを gRPC API から操作できます。gRPC Gateway も併用し、CLI や Web Console などのクライアントを提供しています。gRPC API は、多くの部分で Google API Improvement Proposals を参考にしたリソース志向の API です。それぞれのリソースは基本的に CRUD 操作に対応しており、ユーザーはリソースを宣言的に管理できます。
コンポーネントの関係を図で表すと以下のような構成になっています。

gRPC API には、それぞれのインフラリソースを管理するバックエンドが存在します。Cycloud では、必要とされるデータ整合性と、実際のインフラをプロビジョニングする方法に応じて、RDBMS と Kubernetes API という 2 種類のバックエンドを使い分けています。今回の構成では、リソース割り当てに強い一貫性が必要な Networking API に RDBMS を、外部システムとの状態差異を継続的に解消する必要がある Compute API に Kubernetes API を採用しました。
RDBMS によるリソース割り当て
RDBMS をバックエンドとした API では、トランザクションを利用することで、インフラリソースの割り当てに強い一貫性を持たせることができます。Networking API では、同じ IP アドレスを複数のリソースへ割り当てないことなど、割り当て処理の一貫性が特に重要です。そのため、Networking API のバックエンドには RDBMS を採用しました。Kubernetes API の利用も検討しましたが、IP アドレスなどの排他的な割り当てを扱ううえでは、RDBMS のトランザクションを利用するほうが適していると判断しました。
NetBox をバックエンドとして利用する方法も検討しました。しかし、VPC や EVPN といった今回の仮想ネットワーク構成を十分に表現することが難しい点が課題になりました。また、将来 EVPN 以外の構成へ移行する場合にも、Route Target のような既存構成のパラメータと、新しい構成に必要なパラメータを同じリソースモデルで管理できるようにしたいと考えました。そのため、Networking API のデータモデルとバックエンドは自前で実装しています。
一方、RDBMS が提供するのは、主にデータの永続化とトランザクションです。RDBMS へユーザーの要求を保存するだけでは、OpenStack やネットワーク機器へ設定を反映したり、処理に失敗した際に再試行したり、保存された状態と実際のインフラの状態との差異を修復したりすることはできません。これらのプロビジョニング機構は別途実装する必要があります。
Kubernetes API とコントローラによるプロビジョニング
例えば OpenStack では、MQ を通じてリソースの状態変化やユーザーのリクエストを各コンポーネントへ通知し、それぞれのコンポーネントがメッセージを処理します。このような構成では、メッセージの配送や再試行、重複処理、処理途中の障害などを考慮する必要があります。OpenStack は長い運用実績を持ち、これらの障害に対する運用手順も確立されていますが、同等の仕組みを自前で実装すると、実装と運用の両面で複雑性が高まります。
そのため、もう一つのデザインパターンが、Kubernetes API をバックエンドとする構成です。Kubernetes API を利用すると、ユーザーが要求した状態を Custom Resource の Spec として保存し、Kubernetes コントローラのリコンサイルループによって実際の状態との差異を継続的に解消できます。外部 API の一時的な失敗や、処理途中の障害によって状態に差異が生じた場合でも、次回以降のリコンサイルで処理を再実行できるため、不確実性を伴うインフラリソースの管理に適しています。
Compute API では、OpenStack を Kubernetes コントローラでラップし、OpenStack との連携で生じる一時的な失敗や状態の不整合をリコンサイルループで吸収しています。また、OpenStack 固有の実装をコントローラの背後に閉じ込めることで、ユーザー向けのリソースモデルを維持したまま、将来的に OpenStack 自体を入れ替えたり、別の VM 管理基盤へ移行したりできます。
VM の計算資源は別の基盤上に再作成できますが、ユーザーのワークロードを継続するためには、同じネットワークへ接続できることと、永続ボリュームのデータを引き継げることが必要です。そのため、ネットワークの到達性とボリュームデータの整合性を維持できれば、VM 基盤の内部実装を切り替えられる構成になっています。
今回の全体構成
以上のデザインパターンを適用し、今回のアーキテクチャの全体像は以下のような構成になっています。

Networking API は、IP アドレスや CIDR のようにユーザーから見えるネットワークリソースに加えて、VLAN ID や Route Target など、基盤の管理に必要なリソースの割り当てを担当します。Networking API 自体はネットワーク機器を直接操作せず、主にリソースの管理と割り当てを行うため、オンプレミス上で稼働させる必要はありません。そのため、GCP 上で MySQL や Redis などのマネージドサービスを活用して提供しています。
一方、実際の機器に接続できるオンプレミス環境では Kubernetes コントローラを稼働させています。このコントローラは、ユーザーのリクエストと Networking API で割り当てられた情報を入力として、OpenStack と Apstra へ必要なリソースをプロビジョニングします。機器や VM 基盤の具体的な操作には OpenStack や Apstra などの既存資産を利用し、新たに実装する範囲を抑えています。
コントローラ設計の考え方
ここからは、コントローラを開発するうえで気をつけている点を交えつつ、Compute API における Kubernetes コントローラについて紹介します。
エンドユーザーのリクエストはすべて Spec として保存する
Compute コントローラでは、ユーザーが要求した状態と、特定のインフラ基盤上で実現する状態を別々の Custom Resource として表現しています。本記事では、ユーザーの要求を保持するリソースを「Interface CR」、OpenStack など特定の基盤上で実現すべき状態を保持するリソースを「Provider CR」と呼びます。具体的な関係は以下のとおりです。

gRPC API では、ユーザーから受け取った gRPC リクエストを、Protobuf の定義と 1 対 1 に対応する Interface Custom Resource(Interface CR)へ変換し、Kubernetes 上に保存します。Interface CR には、ユーザーが指定した Spec に加えて、作成者情報など、インフラ構成には直接影響しないメタデータも保存します。
認証・認可には Cycloud の共通基盤を利用しています。入力値については、まず protovalidate を使って宣言的なルールに基づくバリデーションを行い、その後、重複アタッチの禁止など、複数のリソースを参照する必要がある検証をアプリケーションコードで行います。gRPC API の主な責務は、これらの検証と Interface CR への変換です。Protobuf メッセージから Custom Resource への変換には定型的なコードが多いため、実装時には生成 AI も活用し、開発を効率化しています。
Interface CR は、ユーザーが要求した状態をそのまま保持するため、以降の処理における Source of Truth になります。例えば、必要な CPU やメモリ、接続するネットワーク、利用するボリュームといったユーザーの要求は、実際に VM を提供する基盤が OpenStack であるか、別のシステムであるかには依存しません。Interface CR でこの要求を保持しておけば、同じ仕様を満たす限り、ユーザーに移行作業を求めることなく内部実装を変更できます。これにより、OpenStack を直接ユーザーへ提供する構成では実現しにくかった拡張性を確保しています。
Spec を一方通行に伝搬する
Interface CR と Provider CR を分けることで、ユーザーの要求からインフラ構成を決定する責務と、決定された構成を実際の基盤へ反映する責務を分離できます。Provider CR のコントローラは、OpenStack など、controller-runtime から直接管理できない外部リソースを扱います。この部分は処理が複雑になりやすいため、インスタンスタイプの選択やネットワーク構成の決定といった Cycloud 固有の判断は持たせません。与えられた Spec を OpenStack へ反映し、実際の状態を status へ記録することに責務を限定します。
このように責務を限定することで、Provider CR を処理する実装を別のコントローラへ置き換えやすくなります。例えば、今回は当時まだ開発中だったため採用を見送りましたが、将来的には Crossplane のようなコントローラエコシステムを利用して、OpenStack への反映部分を自前で実装しない構成も考えられます。また、別の VM 基盤を導入する場合も、Interface CR を変更せず、Provider CR とそのコントローラを切り替えることで対応できます。
Interface CR のコントローラは、Interface CR の Spec や Networking API から取得した情報をもとに、Provider CR を含むインフラ側の Spec を決定します。インスタンスタイプから利用する VM 基盤を選択することや、ユーザーが指定したネットワークを OpenStack 上の Port としてどのように表現するかといった、Cycloud 固有の設計判断はこの層で行います。入力から出力する Spec を決定する処理に責務を寄せることで、情報の流れを基本的に一方向にできます。また、大部分の変換処理を「Interface CR と外部 API の情報を入力し、Provider CR の Spec を出力する」純粋関数として記述できるため、ユニットテストで検証しやすくなります。
ただし、一方向にできない例外もあります。例えば、ある Provider CR の Spec を決定する際に、先に作成した別の Provider CR に対して OpenStack が払い出した UUID が必要になる場合です。この場合は、関連する Provider CR の status に UUID が記録されるまで処理を進めず、記録された後に次の Provider CR を生成します。こうした複数リソース間の依存関係については、controller-runtime の envtest を使ったシナリオテストで、リソースの作成順序や status の変化を含めて検証しています。例外的な逆方向の参照を可能な限り減らし、テストしやすい構造を保つことを重視しています。
実際の Compute コントローラにおけるリソースの関係を、以下の図に示します。図は左から右へ Spec が伝搬する構成になっています。実線は、親リソースが子リソースの Spec を生成し、ownerReference によって所有する関係を表します。点線は、リソースを所有せず、その情報だけを参照する関係を表します。
ユーザーは、Kubernetes 上に Instance や Volume などの Interface CR を作成し、Networking API 上に VPC や NetworkInterface などのネットワークリソースを作成します。Instance コントローラはそれらを入力として、OpenStackInstance や OpenStackNetworkInterface などの Provider CR と、FQDN を名前解決するための DNSEndpoint を生成します。各 Provider CR のコントローラは、OpenStack 上に Server、Port、Volume などの実リソースを作成します。
一部では、OpenStack が払い出した UUID を後続リソースの Spec へ設定するため、status から情報を読み取る逆方向の参照が発生します。この例外を除けば、情報は基本的に図の左から右へ一方向に流れます。

VPC や SecurityGroup は複数の Instance から共有されるリソースであるため、特定の Instance の owner ツリーには含めていません。これらは Networking API 側で管理され、専用の Runner が OpenStackProject や OpenStackSecurityGroup へ同期します。Instance コントローラは、同期済みのリソースを参照しますが、自身では作成しません。
一方、OpenStackNetworkInterface は Instance が所有します。ただし、その Spec は Instance の情報だけでなく、Networking API 上の NetworkInterface を参照して生成します。このように、「親リソースが所有して作成するもの」と「別のリソースが管理しており、情報だけを参照するもの」を区別することで、Instance から Provider CR への Spec 伝搬を可能な限り一方向に保っています。
ボリュームの扱いは、ほかのリソースとは少し異なります。ボリュームデータの実体はストレージ上に保存され、OpenStack の Volume は、そのデータを各 OpenStack クラスタから利用するための管理情報として扱われます。Cycloud 上の Volume は、VolumeBinding を介して、各クラスタの OpenStackVolume に関連付けられます。
今回は、複数の OpenStack クラスタ間で VM を移行しても、同じボリュームデータを引き続き利用できることを要件としています。そのため、OpenStackVolume を削除するときには、ストレージ上のデータ自体を削除せず、OpenStack の管理対象から外す unmanage を実行します。
別の OpenStack クラスタで同じデータを利用する場合は、そのクラスタ上で manage を実行し、再び OpenStack の管理対象として登録します。VolumeBinding は、ボリュームデータを再登録するために必要な識別子と、現在どの OpenStack クラスタへ接続されているかを保持する中間リソースです。
Instance type の変更などによって利用する OpenStack クラスタが切り替わった場合も、VolumeBinding を利用して移行先のクラスタへボリュームを再登録できます。これにより、ユーザーが移行処理を意識することなく、データを維持したまま VM 基盤全体を切り替えられるようにしています。
Spec の伝搬方向と ownerReference の親子関係をそろえることで、owner ツリーも自然な木構造になります。例えば Instance を削除すると、その配下にある OpenStackInstance、OpenStackNetworkInterface、DNSEndpoint は Kubernetes の Garbage Collection によって削除されます。一方、VPC や SecurityGroup のように複数のリソースから共有されるものは owner ツリーの外に置き、個々の Instance とは独立したライフサイクルで管理します。
リコンサイラは状態を持たずに順番に処理する
リコンサイラをべき等に実装するためには、処理の進行状況を内部状態として保持するのではなく、毎回観測できる現在の状態をもとに、必要な処理を順番に実行するのがよいと考えています。
説明のために単純化した例として、VM を作成した後に SSH 疎通を検証するコントローラを考えます。このコントローラでは、VM が存在しなければ作成し、存在していれば SSH 接続を確認します。
一つの実装方法として、VM を作成したことを status へ記録し、次回のリコンサイルではその記録を見て SSH 接続の確認から処理を再開する方法があります。しかし、VM が手動で削除された場合、status には「作成済み」と記録されている一方で、実際には VM が存在しないという不整合が発生します。
そこで、リコンサイルのたびに実際の VM が存在するかを確認します。VM が存在しなければ作成し、存在すれば SSH 接続の確認へ進みます。また、SSH 接続の試行回数を status で管理する代わりに、VM の作成時刻を参照し、作成から 30 分経過しても接続できなければ VM を削除します。次回のリコンサイルでは VM が存在しないことを検知するため、再び作成処理が行われます。このように、同じ Spec と同じ観測結果に対して同じ処理を選択することで、べき等性を保ちます。
ここで「リコンサイラが状態を持たない」とは、リコンサイラ自身の進行状況を表すフラグを status に保存し、その値を変数として利用しない、という意味です。status は、ユーザーや運用者が現在の状態を確認するための可観測性として利用します。
一方、Provider CR の status に記録された OpenStack の UUID や Ready 状態は、外部リソースを実際に観測した結果です。このような情報は、現実世界の状態として後続の処理判断に利用します。リコンサイラは、リソースの Spec と観測した現在の状態から必要な処理を決定的に選択し、毎回同じ順序で実行することでべき等性を保ちます。
あとから振り返ると、この構成は Google の SRE 本の第 7 章「不整合のべき等な解消」で紹介されている ProdTest の考え方に近いものでした。ProdTest では、リソースが期待する状態になっているかを順番に検査し、検査に失敗した場合は、その状態を修復する処理を実行します。今回の例であれば、「VM が存在するかを確認し、存在しなければ作成する」「SSH 接続できるかを確認し、一定時間接続できなければ VM を削除する」という検査と修復の連続として捉えられます。
この構成では、status.conditions を処理制御のための状態ではなく、リコンサイラが現在どの段階まで到達しているかを外部へ示す情報として定義できます。例えば、メインのリコンサイラに対応する Condition の type を “Ready” とし、reason を “NotCreatedVM”、”NotReachableSSH”、”Ready” のように設定します。これにより、ユーザーや運用者は、リソースが準備できていない理由を status から確認できます。
今回の Compute コントローラでも Instance リコンサイラは、簡略化して書くと以下のような処理を行っています。
- Networking API で NetworkInterface のアタッチ・デタッチを行う
- OpenStackSecurityGroup に OpenStack の UUID が入っていることを確認する
- OpenStackSecurityGroup の UUID を入れつつ OpenStackNetworkInterface を CreateOrUpdate
- OpenStackNetworkInterface に OpenStack の UUID が入っていることを確認する
- OpenStackVolume に OpenStack の UUID が入っていることを確認する
- OpenStackNetworkInterface と OpenStackVolume の UUID を入れつつ OpenStackInstance を CreateOrUpdate
- FQDN の DNSEndpoint を CreateOrUpdate
リコンサイルのたびに OpenStackNetworkInterface や OpenStackVolume の状態を確認するため、コードだけを見ると少し冗長に感じるかもしれません。一方で、実際に観測できる状態を基準に各処理を実行することで、途中で障害が発生した場合も、次回のリコンサイルで安全に処理を再開できます。
不要なリコンサイルを減らす
前述のように、各リコンサイルで複数の外部リソースの状態を順番に確認すると、1 回あたりの処理時間は長くなります。そのため、必要のないリコンサイルを減らし、キューを効率的に処理することが重要です。
Kubernetes 内のリソース変更は、controller-runtime の Watch によってリコンサイルを起動できます。また、Predicate を使うことで、Spec に影響しない更新などを除外できます。一方、controller-runtime から OpenStack や Networking API の変更を直接 Watch することはできないため、外部リソースの変更を検知し、対象のリコンサイルを起動する仕組みを別途用意する必要があります。
当初採用したのは、一定間隔で外部リソースをポーリングし、関連するすべての Custom Resource をリコンサイルする方法です。Networking API と OpenStack をそれぞれ数十秒間隔でポーリングし、リコンサイルや Runner によって状態を同期していました。しかし、リリース前のベンチマークでは、不要なリコンサイルが大量に発生し、必要な処理がキューで待たされる問題が確認されました。
特に Networking API のポーリングでは、NetworkInterface や SecurityGroupRule など多数のリソースを同期するため、全体が収束するまでに時間がかかりました。また、Subnet や SecurityGroup を作成した直後でも、次回のポーリングまで同期が始まりません。そのため、VM を作成できるまでの時間がポーリング間隔に左右され、AKE や Terraform のように、ネットワークや VM など複数の依存リソースを連続して作成するシステムのユーザビリティに影響しました。
現在は、外部リソースの変更通知を利用して、必要なリソースだけをリコンサイルしています。OpenStack については、Nova や Cinder が配信する notification メッセージを RabbitMQ 経由で受信します。VM のパワーステート変更やボリュームのアタッチ・デタッチなどのイベントから対象の Provider CR を特定し、そのリソースだけをリコンサイルキューへ追加します。
Networking API では、Redis の pub/sub を利用してリソースの変更を配信し、Server Streaming RPC の Watch エンドポイントを通じてコントローラへ通知します。ただし、Redis の pub/sub はメッセージの到達を保証しないため、通知だけではイベントを取りこぼす可能性があります。そのため、比較的長い間隔のポーリングも併用し、失われたイベントを補完しています。ポーリング時には ETag を利用し、前回から変更があったリソースだけをリコンサイルします。
これらの変更により、不要なリコンサイルを減らし、ユーザーの操作を短時間でインフラへ反映できるようになりました。性能分析にはトレーシングを活用していたため、Compute コントローラだけでなく、関連コンポーネントのレイテンシに関する改善点も発見できました。結果として Cycloud 全体の最適化にもつながり、改めて計測に基づいて改善することの重要性を実感しました。
おわりに
本記事では、設計の背景や開発中の試行錯誤を交えながら、Compute コントローラで重視している考え方を紹介しました。一方で、リコンサイルを起動する仕組みを改善した後も、複数の外部リソースから取得した状態を集約してユーザー向けの status へ反映する処理にはコストがかかります。どの状態を、どの粒度でユーザーへ提示すると分かりやすいかについても、引き続き検討しています。まだ課題は残っていますが、ユーザーからのフィードバックを継続的に反映できるようになり、要望された機能を提供するまでの速度も改善しました。この点で、API とコントローラによって基盤を抽象化した効果を実感しています。
最後になりますが、このプロジェクトを通じて様々な試行錯誤を重ね、無事サービスとして形になったことを、リーダーとしてとてもうれしく思っています。この領域を中心的に開発したメンバーをはじめ、開発や開設に携わったみなさま、そしてユーザーのみなさまに感謝申し上げます。
次回予告
次回は「LB 編」と題して、CIU の LB 基盤である Cycloud Load Balancing についてお届けします。お楽しみに!
連載記事一覧
本シリーズでは、各チームが新基盤構築における技術的挑戦を公開しています。他のレイヤーの知見については、ぜひ連載の他の記事も併せてご覧ください。
- Cycloud 新基盤の全貌 第一回 新リージョン基盤の全体像
- Cycloud 新基盤の全貌 第二回 HW 編
- Cycloud 新基盤の全貌 第三回 NW 編
- Cycloud 新基盤の全貌 第四回 IaaS 編
- Cycloud 新基盤の全貌 第五回 ComputeController 編
- Cycloud 新基盤の全貌 第六回 LB 編
- Cycloud 新基盤の全貌 第七回 KaaS 編
- Cycloud 新基盤の全貌 第八回 FrontEnd 編
各レイヤの Cycloud の新基盤に関して、より詳細な仕様や設計思想を知りたい方は、上記各チームの Developers Blog 記事をぜひご一読ください。

