【C#】CDKTF を使って Azure のリソースを作成する
C# を使う場合、Azure をセットで使う人が多いと思います。
みなさんは Azure のリソースをどのように作成していますか?
Azure ポータルから作成するか、Bicepを使ってコードから作成している、という方法が一般的だと思います。
Azure ポータルからの作業は簡単ではありますが、環境を複製したい場合には不向きだったり、うっかり変な設定をしていても気づかないこともあります。
そのため、Bicep を使ってコード化することでミスも減らし複製も楽チン...となればいいのですが、専用の言語を覚える必要があります。Bicep を調べても筋肉の画像が出てくるのも、さらに難易度を上げている要因となっています(冗談です)。
C# が好きな人は C# で統一したいと思っているのは私だけではないはず。
そこで便利なのが CDKTF(Cloud Development Kit for Terraform)です。
CDKTF とは
Terraform は HCL という言語を使ってインフラ設定をコードで書ける便利なものとして有名ですが、C# など主要言語で記述したものを Terraform 用のファイルに変換してくれるものが、CDKTF となります。
https://developer.hashicorp.com/terraform/cdktf より引用
最近は生成 AI が活用されることが多くなっており、IaC(Infrastructure as Code) も生成 AI に任せる人も増えてきているようですが、最終的にあっているかどうかの判断は自分でする必要があるため、自分やチームでレビューできる言語のほうが管理しやすいと考えます。
本記事のゴール
- CDKTF の概要をざっくりつかむ
- Azure のリソースグループを作成できるようになる
- チームで作業するときの注意点を理解する
CDKTF を使って Azure のリソースを作成する
全体の流れ
全体の流れとしては以下のとおりです。
-
cdktf init
で初期化 -
cdktf synth
でファイル生成(デプロイコマンドと同時にファイル生成されるので使わないです) -
cdktf deploy
でデプロイ
https://developer.hashicorp.com/terraform/cdktf/concepts/cdktf-architecture より引用
ただし、デプロイに関して注意しなければいけない点があります。
デプロイしたときの状態は自動生成される .tfstate
のファイルで管理されますが、複数人で作業する場合、このファイルをクラウド上のどこかにアップロードしておかなければ、状態の共有ができません。
したがって、以下の手順を踏む必要があります。
- 各環境の
.tfstate
を保存するためのリソース作成 - 各環境のリソース作成
今回は Storage Account のコンテナに状態を保存するようにします。
ローカルではなくクラウド上でファイル管理する仕組みのことを「リモートバックエンド」と呼びます。
1 で作成した .tfstate
をクラウド上に保存するためにはまた別に保存先が必要になってしまうため、ここは運用でカバーするしかないでしょう。
事前準備
いくつかインストールが必要です。
- Terraform CLI(v1.2 以上)
- Node.js(v16 以上)
- .NET Runtime(本記事では .NET 8 の環境になっています)
Mac の場合や詳細は公式ドキュメントを参照ください。
プロジェクトの初期化
以下のコマンドを実行してプロジェクトのテンプレートを作成します。
cdktf init --template=csharp
Azure リソースを作成するため、NuGet パッケージで HashiCorp.Cdktf.Providers.Azurerm を追加します。
また、NuGet パッケージとは別に Terraform が Azure など外部サービスと通信するときの変換レイヤーとして「プロバイダー」を定義する必要があります。
cdktf.json
の terraformProviders
に Azure のプロバイダを使用する旨を追記します。
今回指定したバージョンは 3.114.0 ですが、より新しいバージョンが出ていれば基本的にそちらを使用します。
{
"language": "csharp",
"app": "dotnet run -p MyTerraformStack.csproj",
"projectId": "8F27AC01-8105-4BF8-B84F-64C62E032B99",
"sendCrashReports": "false",
"terraformProviders": [ "hashicorp/azurerm@~> 3.114.0" ],
"terraformModules": [],
"context": {
}
}
また、Azure で作成したリソースについては、以下の Azure プロバイダーのドキュメントを見ながらコードを書いていくことになります。
構成について
今回フォルダ構成は以下のようにしました。
├── Constructs/
├── Settings/
├── Stacks/
- Stacks: 環境の種類ごとに作成する(Local、Development、Staging、Production、Shared)。
- Constructs: Azure のリソース作成をまとめておける。Stack から呼び出す。
- Settings: 環境ごとや Stack ごとで異なる設定を管理する。
簡単にいうと Stack の中で 1 つ以上の Construct を呼び出し、Setting の値で環境依存の差分を吸収するイメージです。
プログラムの実装
どんなクラスを作ったか書いていきます。
環境設定系のクラス
環境ごとに変わる値は EnvironmentSetting
で管理するようにしました。
開発環境が多い場合は初期値を設定しておくことで、毎回設定する必要がなくなります。
環境名(staging、prod など)は指定必須にしたいので required
をつけておくといいでしょう。
namespace MyTerraformStack.Settings;
public sealed class EnvironmentSetting
{
public string AspNetCoreEnvironment { get; init; } = "Development";
public required string Name { get; init; }
}
StackSetting
は Stack ごとに異なる設定を管理するクラスです。
例えばローカル環境においては App Service を使わずに localhost を使用する、DB もローカルを使用するという場合は false に設定しておき、EnvironmentStackBase
で分岐処理を書くと良いでしょう。
namespace MyTerraformStack.Settings;
public sealed class StackSetting
{
public bool CreateMssqlServer { get; init; } = true;
public bool CreateAppService { get; init; } = true;
}
コンストラクト
すべてを Stack に記載することもできますが、作成するリソースが多い場合は、Construct に切り出しておくとスッキリさせることができます。必ず使わなければいけないものではありません。
以下はリソースグループを作成する例です。
インスタンスを生成すると、そのリソースを作成するという意味になります。
Stack 側でリソースグループの情報を使用する場合、プロパティで公開しておくといいでしょう。
using Constructs;
using HashiCorp.Cdktf.Providers.Azurerm.ResourceGroup;
using HashiCorp.Cdktf.Providers.Azurerm.RoleAssignment;
using MyTerraformStack.Settings;
namespace MyTerraformStack.Constructs;
internal sealed class ResourceGroupConstruct : Construct
{
public ResourceGroup ResourceGroup { get; }
public ResourceGroupConstruct(Construct scope, string id, EnvironmentSetting environment)
: base(scope, id)
{
this.ResourceGroup = new ResourceGroup(this, "main", new ResourceGroupConfig
{
Name = $"terraform-{environment.Name}",
Location = "Japan East"
});
}
}
スタック
次に Stack を作成します。
各開発環境はもちろん、staging や production 環境も作成するリソースがほぼ同じ場合は、Base クラスを作成して継承しておくと修正漏れを防ぐことができます。
共通化してしまうと分岐が多くなりメンテナンスが困難になる場合は、それぞれ個別に記載するのがいいでしょう。
AzurermBackend
を呼び出すことで、ローカルではなく Storage Account でデプロイ状態を管理することができるようになります。
using Constructs;
using HashiCorp.Cdktf;
using HashiCorp.Cdktf.Providers.Azurerm.ApplicationInsights;
using HashiCorp.Cdktf.Providers.Azurerm.Provider;
using MyTerraformStack.Constructs;
using MyTerraformStack.Settings;
namespace MyTerraformStack.Stacks;
internal class EnvironmentStackBase : TerraformStack
{
public EnvironmentStackBase(Construct scope, EnvironmentSetting environment, StackSetting stackSetting)
: base(scope, environment.Name)
{
_ = new AzurermProvider(this, $"azure-rm-{environment.Name}", new AzurermProviderConfig
{
Features = new AzurermProviderFeatures(),
});
var resourceGroup = new ResourceGroupConstruct(this, $"resource-group-{environment.Name}", environment).ResourceGroup;
if (stackSetting.CreateMssqlServer){ }
if (stackSetting.CreateAppService){ }
_ = new AzurermBackend(this, new AzurermBackendConfig
{
ResourceGroupName = $"terraform-shared",
StorageAccountName = $"terraformsharedst001",
ContainerName = "terraform-state",
Key = $"{environment.Name}.tfstate",
});
}
}
EnvironmentStackBase
を継承して各環境の Stack を作成します。
開発環境の場合は StackSetting
を初期値で生成し、ローカル環境の場合は DB や App Service を作成する必要がないので、フラグを変更しました。
using Constructs;
using MyTerraformStack.Settings;
namespace MyTerraformStack.Stacks;
internal sealed class DevelopmentStack(Construct scope, EnvironmentSetting environment)
: EnvironmentStackBase(scope, environment, new ());
using Constructs;
using MyTerraformStack.Settings;
namespace MyTerraformStack.Stacks;
internal sealed class LocalStack(Construct scope, EnvironmentSetting environment)
: EnvironmentStackBase(
scope,
environment,
new()
{
CreateAppService = false,
CreateMssqlServer = false
});
また、先述した通り環境ごとのデプロイ状態を表した .tfstate
は別管理としなければならないため、SharedStack
を先に作成する必要があります。
リソースグループと、Storage Account およびコンテナを作成しています。
using Constructs;
using HashiCorp.Cdktf;
using HashiCorp.Cdktf.Providers.Azurerm.Provider;
using HashiCorp.Cdktf.Providers.Azurerm.ResourceGroup;
using HashiCorp.Cdktf.Providers.Azurerm.StorageAccount;
using HashiCorp.Cdktf.Providers.Azurerm.StorageContainer;
namespace MyTerraformStack.Stacks;
internal class SharedStack : TerraformStack
{
public SharedStack(Construct scope, string id)
: base(scope, id)
{
_ = new AzurermProvider(this, $"azure-rm-{id}", new AzurermProviderConfig
{
Features = new AzurermProviderFeatures(),
});
var resourceGroup = new ResourceGroup(this, $"resource-group-{id}", new ResourceGroupConfig
{
Name = $"terraform-shared",
Location = "Japan East"
});
var resourceGroupName = resourceGroup.Name;
var storageAccount = new StorageAccount(this, $"storage-account-{id}", new StorageAccountConfig
{
Name = $"terraformsharedst001",
ResourceGroupName = resourceGroupName,
Location = "Japan East",
AccountTier = "Standard",
AccountReplicationType = "LRS",
});
_ = new StorageContainer(this, "terraform-state", new StorageContainerConfig
{
Name = "terraform-state",
StorageAccountName = storageAccount.Name,
});
}
}
なお、各環境の Stack を生成するときに Program.cs
にすべてを記載すると処理が多くなってしまうので、EnvironmentStackFactory
として別クラスに切り出しておくとスッキリさせることができます。
using HashiCorp.Cdktf;
using System;
using System.Collections.Generic;
using System.Linq;
using MyTerraformStack.Settings;
namespace MyTerraformStack.Stacks;
public static class EnvironmentStackFactory
{
public static TerraformStack Create(App app, string environmentName)
{
var environment = Environments.FirstOrDefault(x => x.Name == environmentName);
if (environment is null)
{
throw new ArgumentException($"Environment {environmentName} not found");
}
return environmentName switch
{
"local" => new LocalStack(app, environment),
"staging" => new StagingStack(app, environment),
"prod" => new ProductionStack(app, environment),
_ => new DevelopmentStack(app, environment)
};
}
private static IEnumerable<EnvironmentSetting> Environments { get; } = new List<EnvironmentSetting>
{
new() { Name = "dev"} ,
new() { Name = "local"},
new() { Name = "staging", AspNetCoreEnvironment = "Staging"} ,
new() { Name = "prod", AspNetCoreEnvironment = "Production"} ,
};
}
スタートアップ
Program.cs
は非常にスッキリした形になります。
ただし、一度のデプロイコマンドで複数の Stack を処理することはできないので、コマンド実行時に「どの Stack を処理するか」を指定する必要があります。
using System;
using HashiCorp.Cdktf;
using MyTerraformStack.Stacks;
namespace MyTerraformStack;
class Program
{
public static void Main(string[] args)
{
var app = new App();
var environmentName = "dev";
_ = new SharedStack(app, "shared");
_ = EnvironmentStackFactory.Create(app, environmentName);
app.Synth();
Console.WriteLine("App synth complete");
}
}
デプロイ
各環境の .tfstate
ファイルをデプロイする先の環境をまずは作成します。
ただし、プログラム内で複数の Stack を生成している場合は、deploy コマンドを実行するときに Stack 名を指定する必要があります。
まずは共通のものを作成したいので shared を指定します。
cdktf deploy shared
shared のリソースが作成できたら、dev など各環境を作成していきます。
cdktf deploy dev
本当にデプロイしてしまう前に、前回との差分が出たり Approve したりという手順もありますが、今回は割愛します。
どういう手順でやるのがよいか
Azure のリソースをポータルから手動で作成したことがない状態で CDKTF を触るにはイメージがつきにくく、なかなか作業が進まないと思います。
おすすめは以下の手順です。
- Azure ポータルから手動でリソースを作成する(このとき、入力した画面はキャプチャしておく)
- 必要に応じて az コマンドでリソース情報を取ってくる
- 1 と 2 の情報を見ながら CDKTF の実装を進めていく
基本的には手動で作るときに入力した情報をコードから指定すれば問題ありません。
例えば DB のプラン名など、ドキュメントだけではわかりにくいケースがあるので、az コマンドで取得して具体的な設定値を確認するといいでしょう。
az sql db list --resource-group MyResourceGroup --server myserver
終わりに
C# の CDKTF を使って Azure のリソースを作成する方法について解説しました。
まとまった時間が取れない場合は手動でやってしまうのが早いというのが正直なところですが、開発環境が何個も必要になる場合はそのぶん作成に時間がかかってしまったり、設定漏れも発生する可能性が出てくるため、時間をかける価値はあると思います。
私は今回 Bicep から CDKTF への移行をしてみたのですが、Terraform を触ったことがない身からすると結構大変でした。
しかし、「C# で統一できた」という満足感はあります(気持ちの問題)。
もし興味があれば、この記事を参考にぜひ試してみてください。
Discussion