2022年11月に、Cloudflare APIのOpenAPIスキーマへの移行を発表しました。当時、私たちはOpenAPIスキーマをSDKエコシステムとリファレンスドキュメントの信頼できる情報源にするという大胆な目標を掲げました。2024年のDeveloper Weekでは、当社のSDKライブラリがこれらのOpenAPIスキーマから自動的に生成されるようになったことを発表しました。そして本日、「Terraformプロバイダー」と「APIリファレンスドキュメント」も自動生成される最新のエコシステムの一部となったことをお知らせします。
これは、新しい機能や属性が私たちの製品に追加され、チームがそれをドキュメント化した瞬間に、SDKエコシステム全体でその使い方が確認でき、すぐに利用できることを意味します。遅延もなく、APIエンドポイントの対応が不十分になることもありません。
新しいドキュメントサイトはhttps://developers.cloudflare.com/api-next/で確認でき、Terraformプロバイダーのプレビューリリース候補は、5.0.0-alpha1をインストールすることでお試しいただけます。
なぜTerraformなのか
Terraformを知らない方のために説明すると、Terraformはインフラをコードとして管理するためのツールで、アプリケーションコードと同じようにインフラを管理できます。大規模から小規模まで多くのお客様が、Terraformを使ってテクノロジーに依存しない方法でインフラを管理しています。内部的には、TerraformはHTTPクライアントであり、ライフサイクル管理が組み込まれているため、公開されているAPIを使ってリソースのCRUD(作成、読取、更新、削除)を効率的に行います。
Terraformを最新の状態に維持 — これまでの方法
これまでCloudflareは、Terraformプロバイダーを手動でメンテナンスしてきましたが、プロバイダーの内部構造が特有の方式を必要とするため、メンテナンスとサポートの責任は一握りの個人の負担となっていました。サービスチームは、プロバイダーに1つの変更を加えるだけでも大きな負担がかかるため、多くの変更に対応するのが難しい状況でした。チームがプロバイダーを変更するのに、少なくとも3つ(cf-terraformingに対応を追加する場合は4つ)のプルリクエストが必要でした。
4つのプルリクエストが完了しても、利用可能なすべての属性をカバーする保証はありませんでした。つまり、小さいながらも重要な詳細は忘れられる可能性があり、顧客に公開されない可能性があり、リソース設定の都度フラストレーションが溜まる作業でした。
これに対処するために、Terraformプロバイダーも、他のSDKエコシステムがすでに活用しているのと同じOpenAPIスキーマを使用する必要がありました。
Terraformの自動更新
TerraformとSDKの違いは、リソースのライフサイクルの管理です。これにより、既知の値やリクエストとレスポンスのペイロードの違いを管理するという新たな問題が生じます。新しいDNSレコードの作成とそれを取得する2つの異なるアプローチを比較してみましょう。
Go SDK導入後:
// Create the new record
record, _ := client.DNS.Records.New(context.TODO(), dns.RecordNewParams{
ZoneID: cloudflare.F("023e105f4ecef8ad9ca31a8372d0c353"),
Record: dns.RecordParam{
Name: cloudflare.String("@"),
Type: cloudflare.String("CNAME"),
Content: cloudflare.String("example.com"),
},
})
// Wasteful fetch, but shows the point
client.DNS.Records.Get(
context.Background(),
record.ID,
dns.RecordGetParams{
ZoneID: cloudflare.String("023e105f4ecef8ad9ca31a8372d0c353"),
},
)
Terraformでは:
resource "cloudflare_dns_record" "example" {
zone_id = "023e105f4ecef8ad9ca31a8372d0c353"
name = "@"
content = "example.com"
type = "CNAME"
}
表面上は、Terraformのアプローチの方がシンプルに見えますし、その判断は正しいです。新しいリソースの作成と変更の維持に関する複雑な作業は、お客様に代わってTerraformが処理します。しかし、問題は、Terraformがこの抽象化とデータ保証を提供するために、適用時にすべての値を把握しなければならないことです。つまり、プロキシされた値を使用していない場合でも、状態ファイルに保存し、今後その属性を管理するために、Terraformはその値が何であるかを知る必要があるということです。以下のエラーは、適用時に値が不明な場合に、Terraformのオペレーターがプロバイダーから一般的に表示されるものです。
Error: Provider produced inconsistent result after apply
When applying changes to example_thing.foo, provider "provider[\"registry.terraform.io/example/example\"]"
produced an unexpected new value: .foo: was null, but now cty.StringVal("").
一方、SDKを使用する場合、フィールドが必要ない場合は省略するだけで、既知の値の維持について心配する必要はありません。
OpenAPIスキーマでこれに取り組むことは、決して簡単な作業ではありませんでした。Terraform生成サポートを導入して以来、スキーマの品質は飛躍的に向上しました。現在では、すべてのデフォルト値、リクエストペイロードに基づく可変応答プロパティ、およびサーバー側で計算された属性を明示的に呼び出しています。これにより、APIを利用するすべての人にとって、より良い体験が提供されるようになっています。
「terraform-plugin-sdk」から「terraform-plugin-framework」への移行
Terraformプロバイダーを構築して、リソースまたはデータソースをオペレーターに公開するには、主にプロバイダーサーバーとプロバイダーの2つが必要です。
プロバイダーサーバーは、Terraformコア(CLIを通じて)がリソースを管理したり、オペレーターが提供する設定からデータソースを読み取る際に通信するためのgRPCサーバーを公開します。
プロバイダーは、リソースとデータソースのラップ、リモートサービスとの通信、状態ファイルの管理を担当します。これを行うには、terraform-plugin-sdk(一般にSDKv2と呼ばれる)またはterraform-plugin-frameworkを使用します。terraform-plugin-frameworkには、内部を正しく管理するためにTerraformによって提供されるすべてのインターフェイスとメソッドが含まれています。どのプラグインを使用するかは、プロバイダーの使用年数に依存します。SDKv2は古くから存在し、ほとんどのTerraformプロバイダーで使用されていますが、古さと複雑さにより、多くのコアとなる未解決の問題が残されていますが、これに依存するユーザーの後方互換性を促進するために残されています。terraform-plugin-framework
は新しいバージョンで、SDKv2程の幅広い機能はありませんが、プロバイダーを構築するためのGoのようなアプローチを提供し、SDKv2の根本的なバグの多くに対処しています。true
, or false
SDKv2とフレームワークのより深い比較については、Octopus DeployのJohn Bristowe氏との対談をご覧ください。)
Cloudflare Terraformプロバイダーの大部分はSDKv2を使用して構築されていますが、2023年初頭に当社のプロバイダーで両方を多重化して提供することに踏み切りました。なぜこれが必要だったのかを理解するために、SDKv2について少し説明しましょう。SDKv2の構造化方法は、nullまたは「未設定」の値を一貫して確実に表現するのに適していません。実験的なResourceData.GetRawConfigを使用すれば、値が設定されているか、nullであるか、不明であるかどうかを確認することはできますが、nullとして書き戻すことは実際にはサポートされていません。
この問題が最初に発生したのは、Edge Rules Engine(ルールセット)が新しいサービスを導入し始め、そのサービスが、未設定(または欠落)、true
、false
の3つの状態を持つブール値をAPIレスポンスでサポートする必要が出てきた時です。それぞれの状態には理由や目的があり、Cloudflareでは一般的なAPI設計ではないものの、有効なやり方です。しかし、前述の通り、SDKv2プロバイダーはこれに対応できませんでした。これは、レスポンスに値が含まれていない場合や、状態に読み込まれなかった場合、Go互換のゼロ値がデフォルトとして設定されるためです。この結果、falseとして状態に書き込まれた値を、その後に未設定に戻すことができない(逆も同様)という問題が発生しました。
これらのブール値の3つの状態を確実に使用するための唯一の解決策は、未設定の値を書き戻すことができるterraform-plugin-framework
に移行することです。
古いプロバイダーにterraform-plugin-framework
を使用したところ、明らかに開発者体験が向上しました。そのため、当社は今後SDKv2を使用しないようにする制限を設け、無意識のうちにこの問題に直面することを防ぐようにしました。
Terraformプロバイダを自動生成することに決めたとき、すべてのリソースをterraform-plugin-framework
に移行し、SDKv2の問題を完全に解消することが適切だと考えました。この移行は複雑になりましたが、内部が改善されたことでスキーマやCRUD(作成、読取、更新、削除)操作など主要なコンポーネントの変更に慣れる必要がありました。しかし、プロバイダーの基盤を将来に備えられたこと、バグの多い古い内部構造に囚われてより良いTerraform体験を妥協することが減ったことなど、これは価値ある投資でした。
反復的なバグ発見
コード生成パイプラインでよくある課題の一つは、新しい機能を実装する既存のツールがない場合、その機能が正しく動作するか、実用的かを判断するのが難しいことです。もちろん、新しい機能をテストするためのコードも生成できますが、パイプラインにバグがあった場合、バグは想定される動作であることを示すテストアサーションを生成することになるため、そのバグを見逃してしまう可能性があります。
既存の受け入れテストスイートは、私たちにとって重要なフィードバックループの1つです。既存のプロバイダー内のすべてのリソースには、回帰テストと機能テストが混在していました。最も良い点は、このテストスイートが実際のリソースを作成・管理しているため、HTTPトラフィックを確認することで、API呼び出しがリモートのエンドポイントで正しく受け入れられているかどうかを簡単に判断できることです。テストスイートの移植は、すべての既存テストをコピーし、型アサーションの違い(リストから単一のネストされたリストなど)を確認した後、テスト実行を開始してリソースが正しく動作しているかを確認するだけでした。
中央集約型のスキーマパイプラインは、スキーマ修正がエコシステム全体にほぼ瞬時に反映されるという点で非常に便利でしたが、最大の課題である「他のバグを隠すバグの発見」には対応できませんでした。Terraformの問題を解決する際、次の3つの場所でエラーに遭遇する可能性があるため、これには時間がかかりました。
API呼び出しが行われる前に、Terraformは論理スキーマの検証を実施します。この際、検証エラーが発生すると処理はすぐに停止します。
API呼び出しが失敗した場合、CRUD(作成、読取、更新、削除)操作の段階で診断が返され、ただちに停止します。
CRUD(作成、読取、更新、削除)操作の実行が完了すると、Terraformはすべての値が認識されているかを確認するチェックを行います。
つまり、ステップ1でバグに遭遇して修正した場合でも、次に待っているバグがないという保証はありません。さらに、ステップ2でバグを見つけて修正しても、次回のテストで再び最初のステップにバグが見つかる可能性があるということです。
ここに特効薬はなく、私たちの対策は、スキーマの動作における問題のパターンを把握し、コード生成パイプラインに入る前にOpenAPIスキーマ内でCIリンティングルールを適用することでした。このアプローチを取ることで、ステップ1と2のバグの数が徐々に減少し、最終的にはステップ3のタイプだけを扱うようになりました。
モデルと構造化変換のための、より再利用可能なアプローチ
TerraformプロバイダーのCRUD(作成、読取、更新、削除)操作では、主に次のような定型文が使用されます。
var plan ThingModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
out, err := r.client.UpdateThingModel(ctx, client.ThingModelRequest{
AttrA: plan.AttrA.ValueString(),
AttrB: plan.AttrB.ValueString(),
AttrC: plan.AttrC.ValueString(),
})
if err != nil {
resp.Diagnostics.AddError(
"Error updating project Thing",
"Could not update Thing, unexpected error: "+err.Error(),
)
return
}
result := convertResponseToThingModel(out)
tflog.Info(ctx, "created thing", map[string]interface{}{
"attr_a": result.AttrA.ValueString(),
"attr_b": result.AttrB.ValueString(),
"attr_c": result.AttrC.ValueString(),
})
diags = resp.State.Set(ctx, result)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
簡単な説明:
req.Plan.Get()
を使用して、提案された更新プランを取得します新しい値で更新API呼び出しを実行します
Go型からTerraformモデルにデータを操作します(
convertResponseToThingModel
)resp.State.Set()
を呼び出して状態を設定します
最初は、これはあまり問題がないように見えます。しかし、しかし、Go型をTerraformモデルに操作する3番目のステップは、型と関連するTerraformモデルの間のすべてのリソースがこれを行う必要があるため、すぐに厄介でエラーが発生しやすく、複雑になります。
必要以上に複雑なコードが生成されるのを避けるために、当社のプロバイダーで取り上げられた改善点の1つは、すべてのCRUD(作成、読取、更新、削除)メソッドで統一された
apijson.Marshal
、 apijson.Unmarshal
、 and apijson.UnmarshalComputed
メソッドを使用することです。これにより、構造体のタグに基づいて変換と処理のロジックを集中管理し、この問題を解決しています。
var data *ThingModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
dataBytes, err := apijson.Marshal(data)
if err != nil {
resp.Diagnostics.AddError("failed to serialize http request", err.Error())
return
}
res := new(http.Response)
env := ThingResultEnvelope{*data}
_, err = r.client.Thing.Update(
// ...
)
if err != nil {
resp.Diagnostics.AddError("failed to make http request", err.Error())
return
}
bytes, _ := io.ReadAll(res.Body)
err = apijson.UnmarshalComputed(bytes, &env)
if err != nil {
resp.Diagnostics.AddError("failed to deserialize http request", err.Error())
return
}
data = &env.Result
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
型からモデルへの変換メソッドの数百のインスタンス生成する代わりに、Terraformモデルに適切なタグを付けて、データのマーシャリングとアンマーシャリングを一貫した方法で処理できるようにしています。これはコードに小さな変更を加えることで、長期的には、生成をより再利用可能で読みやすいものにするものです。さらに、このアプローチはバグの修正に最適です。特定のフィールドタイプでバグを特定した場合、その修正が統一インターフェースで行われるため、まだ見つかっていない他の問題も修正されます。
しかし、待ってください、他にも(ドキュメントが)あります!
OpenAPIスキーマの活用をさらに進めるために、新しいAPIドキュメントサイトとのSDKの統合を強化しています。過去2年間投資してきたものと同じパイプラインを使用し、一般的な使用上の問題に対処しています。
SDK対応
当社のAPIドキュメントサイトをご利用されたことがあれば、このサイトでは、curlのようなコマンドラインツールを使ったAPIの操作例があることをご存知でしょう。これは素晴らしい出発点ですが、SDKライブラリの1つを使用している場合、頭の中で組み合わして、使用したいメソッドや型定義に変換する必要があります。現在、SDKとドキュメントを生成するために同じパイプラインを使用しているため、curlだけでなく、使用可能なすべてのライブラリで例を提供することで、この問題を解決しています。
すべてのゾーンを取得する例(cURLを使用)。
すべてのゾーンを取得する例(Typescriptライブラリを使用)。
すべてのゾーンを取得する例(Pythonライブラリを使用)。
すべてのゾーンを取得する例(Goライブラリを使用)。
この改善により、言語選択も記憶されるようになりました。そのため、Typescriptライブラリを使用してドキュメントを表示して様々な箇所をクリックした場合、言語がスワップアウトされるまで、Typescriptを使用した例が表示され続けます。
何より素晴らしいのは、既存のエンドポイントに新しい属性を追加したり、SDKの言語を追加した場合、このドキュメントサイトはパイプラインと自動的に同期されることです。これにより、最新の情報を維持するための大きな労力が不要になりました。
より高速で効率的なレンダリング
私たちは常々、APIエンドポイント数の多さと、その表現方法にいつも悩まされてきました。この記事の時点で、1,330のエンドポイントがあり、それらの各エンドポイントに対して、リクエストペイロード、レスポンスペイロード、そしてそれに関連付けられた複数のタイプがあります。これまで使用してきたソリューションでは、これだけの情報をレンダリングするために、一部表示内容を削る必要がありました。
APIドキュメントサイトの次の改良では、これに2つの方法で対処します。
これは、対話型のクライアント側の操作と静的な事前レンダリングされたコンテンツを組み合わせた、最新のReactアプリケーションとして実装されています。そのため、初回の読み込みが速く、ナビゲーションも快適です。(もちろん、JavaScriptが有効でなくても動作します)。
ナビゲートしながら基になるデータを段階的に取得します。
この根本的な問題を解決することで、これまで必要としていたようなトレードオフをせずに、ドキュメントサイトやSDKエコシステムのさらなる改善を実現し、ユーザーエクスペリエンスを向上させることができました。
権限
ドキュメントサイトへの再実装を最も要望する機能の1つは、APIエンドポイントに最小限必要な権限でした。ドキュメントサイトの以前のイテレーションの1つは、これを利用可能でした。以前のバージョンのドキュメントサイトにはこの機能がありましたが、人知れずその値は手動で維持されており、常に不正確な状態でした。このため、サポートへの問い合わせや、ユーザーの不満を引き起こしていました。
CloudflareのIDおよびアクセス管理(IAM)システムにおいて、「このエンドポイントにアクセスするためには何が必要か?」という問いに答えることは簡単ではありません。その理由は、コントロールプレーンに向けられたリクエストの通常のフローでは、完全な答えを提供するために組み合わせによって質問の一部を提供する2つの異なるシステムが必要だからです。最初はこのプロセスをOpenAPIパイプラインの一部として自動化できなかったため、正確に確認できない情報を提供するくらいなら、その機能を除外することを選択しました。
そして今回、エンドポイントの権限が復活しました!私たち、この質問への答えを抽象化する新しいツールを、コード生成パイプラインに統合し、すべてのエンドポイントが自動的にこの情報を取得できるように構築しました。他のコード生成プラットフォームと同様に、サービスチームが高品質なスキーマを所有し、それを維持できるようにしながら、付加価値のある改善が自動的に追加される仕組みになっています。
アップデートを待つのはやめましょう
これらの発表で、SDKエコシステムへのアップデートを待つことに終止符を打ちます。これらの新しい改善により、チームが新しい属性とエンドポイントを文書化した瞬間から、その能力を合理化することができます。迷っている暇はありません。TerraformのプロバイダーとAPIに関するドキュメントサイトを今すぐご確認ください。