🛠

CDK for TerraformでGoogle Cloudのリソースを作ってみた 接触篇

2022/08/19に公開

クラウドエースの阿部と申します。TerraformをはじめとするIaCが大好きなエンジニアのような者です。

はじめに

2022年8月1日に「CDK for Terraform (CDKTF)」がGAリリースされました。(Hashicorpのブログ)
2年ほど前からAWS向けにコミュニティプレビューとして公開されていたフレームワークですが、しばらく前からGoogle Cloud向けにも使用できるようになっており、実際にどんな感じで使えるか見てみようと思います。
なお、筆者はTerraformのHCLについてはそれなりにコードを書ける自負はありますが、Typescriptは初級レベルなのでコーディングの程度についてはご容赦ください。
また、本記事は、Terraform自体についてある程度知っている前提で記述しています。

CDK for Terraform とは

CDK for Terraform(以降、CDKTF)とは、Terraformで使われているHCLという設定言語ではなく、プログラマが使い慣れた言語(Typescript等)でIaCを記述できるフレームワークになります。
現時点では、以下の言語に対応しています。

  • Typescript
  • Python
  • Java
  • C#
  • Go

TerraformのようなIaCをやってみようとは思っているが、HCLは使いづらいしそのための学習コストを負担するのはちょっとなぁ、という人には良い選択肢になりそうです。
ただし、残念ながらTerraformでサポートしているProvider(プラグイン)の全てに対応しているわけではなく、CDKTF向けにモジュールが提供されているProviderに限定されます。
現時点では、以下のProviderがPre-Built Providerとして提供されています。

  • AWS Provider
  • Google Provider
  • Azure Provider
  • Kubernetes Provider
  • Docker Provider
  • GitHub Provider
  • Null Provider

AWS, Azure, Google Cloudの三大クラウドに対応しているので、これらのクラウドを使っている方はちょっと興味が出てきたのではないかと思います。

CDKTFのセットアップ

前提環境

まず、CDKTFは以下がインストールされていることが前提となります。

  • Terraform CLI (v1.1以降)
  • Node.js (v16以降)

この記事の執筆環境は以下の通りです。

  • Windows 10 Professional (Ubuntu 20.04 on WSL2)
  • Terraform CLI v1.2.7 (tfenvで導入)
  • Node.js v16.15.0 (nvmで導入)

また、この記事では、TypescriptによるCDKTFの使用について説明していこうと思います。

セットアップ手順

以下のコマンドで、CDKTFのコマンドライン(CLI)をインストールします。

npm install --global cdktf-cli@latest

問題無ければ、cdktfコマンドが使えるようになります。

CDKTFでIaCしてみる

初期化の手順

最初に、cdktf initコマンドを使ってIaCを行うプロジェクトディレクトリを初期化します。
今回はtest-projectという名前のディレクトリで作業します。
まず、以下のコマンドでディレクトリを作成して移動します。

mkdir test-project
cd test-project

次に、以下のコマンドを実行します。

cdktf init --template=typescript --local

--template=typescriptはTypescript環境の指定、 --localはStateファイルをローカルにする指定です。

CDKTFから4つの質問が返ってきますので、それぞれ以下の要領で入力します。

  • Project Name ... プロジェクトの名前。(package.jsonnamemain.tsで定義するTerraformStackクラスの内部IDで使われる) デフォルトでディレクトリ名が使用される。
  • Project Description ... プロジェクトの簡潔な説明。
  • Do you want to start from a Terraform project? ... Terraformコードから逆生成するケースで使用すると思われる。(動作未確認) デフォルトでNoのため、今回はデフォルトのままにします。
  • Do you want to send crash reports to the CDKTF team? ... CDKTFチームにクラッシュレポートを送信するかどうか。(動作未確認) デフォルトでYesになっていますが、鬱陶しければNoでもよいと思います。

4項目の入力が完了すると、裏でnpmによるモジュールダウンロードが行われます。
完了すると、test-projectディレクトリは以下のような状態になります。

.
├── __tests__
├── cdktf.json
├── help
├── jest.config.js
├── main.ts
├── node_modules
├── package-lock.json
├── package.json
├── setup.js
└── tsconfig.json

Typescriptで開発出来る準備が整いました!

できあがったテンプレートファイルを見てみる

開発に必要なファイルやディレクトリが自動的に生成されていますが、実際に触っていくファイルはmain.tsになります。
cdktf initによる初期化直後のmain.tsは以下のような内容になっています。

main.ts
import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";

class MyStack extends TerraformStack {
  constructor(scope: Construct, name: string) {
    super(scope, name);

    // define resources here
  }
}

const app = new App();
new MyStack(app, "test-project2");
app.synth();

Google Cloudのリソース作成に必要なモジュールをインストールする

今回は、以下のリソースを作成してみようと思います。

  • VPCネットワーク
  • VPCサブネットワーク
  • Compute Engineインスタンス(含む、ディスク、内部アドレス)

初期状態だとGoogle Cloudリソースの定義に必要なNode.jsのモジュールがインストールされていないため、以下のコマンドを実行します。

npm install @cdktf/provider-google

Google Providerを定義する

Terraformでは、Providerを使用する際は以下のような記述が必要です。

provider "google" {}

これと同様にCDKTFでもProviderに相当するクラスのインスタンスを初期化・生成する必要があります。
まず、モジュールをインポートします。 import { App, TerraformStack } from "cdktf";の次の行に以下を記述します。

import {GoogleProvider} from "@cdktf/provider-google";

また、 MyStackのConstructorに、以下を記述します。"myProjectID"は、実際に使われているGoogle CloudプロジェクトのIDを記述してください。
GoogleProviderクラスの第1引数はthis固定、第2引数は内部ID(何でもよいはずですが、ここでは"google"を指定)、第3引数にTerraformのProvider Attributeを指定します。

new GoogleProvider(this, "google", {
    project: "myProjectID",
});

ここまで記述すると以下のようになります。

main.ts
import {Construct} from "constructs";
import {App, TerraformStack} from "cdktf";
import {GoogleProvider} from "@cdktf/provider-google";

class MyStack extends TerraformStack {
    constructor(scope: Construct, name: string) {
        super(scope, name);

        new GoogleProvider(this, "google", {
            project: "myProjectID"
        });
    }
}

const app = new App();
new MyStack(app, "test-project2");
app.synth();

VPCネットワークリソースを定義する

無事Providerも定義できたため、作成するリソースを定義していきましょう。
まずは、google_compute_networkに相当するリソース定義です。Terraformであれば以下のような記述になります。

resource "google_compute_network" "cdktf_network" {
  name                    = "cdktf-network"
  routing_mode            = "REGIONAL"
  auto_create_subnetworks = false
}

まず、クラス定義をインポートします。先ほどのProviderクラスのインポートの行を以下のように修正します。

import {GoogleProvider, ComputeNetwork} from "@cdktf/provider-google";

どうやら、provider-googleのリソースクラス名のルールとしては、Terraformのリソース名からgoogle_を抜いて、キャメルケースにした感じになるようです。
そのため、 google_compute_networkであればComputeNetwork、という感じです。

実際のリソース定義部分は、MyStackのConstructor内に、GoogleProviderの定義に続けて以下のように記述します。
なお、GoogleProviderの定義時とは異なり、ComputeNetworkクラスのインスタンスをnetwork変数に格納しています。これは、後でリソース参照で使用するためです。

const network = new ComputeNetwork(this, "cdktf-network", {
    name: "cdktf-network",
    routingMode: "REGIONAL",
    autoCreateSubnetworks: false,
});

VPCサブネットワークリソースを定義する

VPCネットワーク上にサブネットワークを記述します。Terraformであれば以下のような定義です。

resource "google_compute_subnetwork" "cdktf_subnetwork" {
  name          = "cdktf-subnetwork"
  network       = google_compute_network.cdktf_network.self_link
  ip_cidr_range = "10.10.0.0/16"
  region        = "asia-northeast1"
}

ComputeNetworkのときと同様、クラス定義をインポートします。

import {GoogleProvider, ComputeNetwork, ComputeSubnetwork} from "@cdktf/provider-google";

その後、MyStackのConstructorに以下の定義を追記します。network.selfLinkは先ほど定義したComputeNetworkインスタンスの変数です。
こんな感じで、変数を使って参照させればOKです。
ComputeSubnetworkクラスのインスタンスも変数に格納します。後で参照して使うためです。

const subnetwork = new ComputeSubnetwork(this, "cdktf-subnetwork", {
    name: "cdktf-subnetwork",
    network: network.selfLink,
    ipCidrRange: "10.10.0.0/16",
    region: "asia-northeast1",
});

ComputeEngineインスタンスリソースを定義する

さて、VPCネットワーク周りの定義は終わったので、ComputeEngineインスタンスにまつわるリソースの定義を行います。
インスタンス、ブートディスク、内部アドレスの3つをまとめて説明しようと思います。
Terraform HCLで記述するなら、以下のようなリソースになります。

resource "google_compute_address" "cdktf_address" {
  name         = "cdktf-address-internal"
  subnetwork   = google_compute_subnetwork.cdktf_subnetwork.self_link
  region       = google_compute_subnetwork.cdktf_subnetwork.region
  address_type = "INTERNAL",
}

resource "google_compute_disk" "cdktf_disk" {
  name  = "cdktf-disk"
  zone  = "asia-northeast1-b"
  type  = "pd-standard"
  size  = 10
  image = "ubuntu-os-cloud/ubuntu-2204-lts"
}

resource "google_compute_instance" "cdktf_instance" {
  name         = "cdktf-instance"
  zone         = "asia-northeast1-b"
  machine_type = "e2-medium"
  boot_disk {
    auto_delete = false
    source      = google_compute_disk.cdktf_disk.selfLink
  }
  networkInterface {
    network_ip = google_compute_address.cdktf_address.address
    subnetwork = google_compute_subnetwork.cdktf_subnetwork.self_link
  }
  
  can_ip_forward = false
  tags           = ["iap"]
}

これまでのリソースと同様、モジュールのインポートを行います。

import {
    GoogleProvider,
    ComputeNetwork,
    ComputeSubnetwork,
    ComputeAddress,
    ComputeDisk,
    ComputeInstance,
} from "@cdktf/provider-google";

※1行が長くなったためクラス毎に改行しています

MyStackのConstructorにおけるリソース定義は以下の通りです。

        const address = new ComputeAddress(this, "cdktf-address", {
            name: "cdktf-address-internal",
            subnetwork: subnetwork.selfLink,
            region: subnetwork.region,
            addressType: "INTERNAL",
        });

        const disk = new ComputeDisk(this, "cdktf-disk", {
            name: "cdktf-disk",
            zone: "asia-northeast1-b",
            type: "pd-standard",
            size: 10,
            image: "ubuntu-os-cloud/ubuntu-2204-lts",
        });

        new ComputeInstance(this, "cdktf-instance", {
            name: "cdktf-instace",
            zone: "asia-northeast1-b",
            machineType: "e2-medium",
            bootDisk: {
                autoDelete: false,
                source: disk.selfLink
            },
            networkInterface: [
                {
                    networkIp: address.address,
                    subnetwork: subnetwork.selfLink,
                }
            ],
            canIpForward: false,
            tags: ["iap"]
        });

ComputeAddressComputeDiskリソースはこれまでと同じ書き方だと思います。
ComputeInstanceリソースのnetworkInterfaceは、TerraformのHCLを知っているとちょっと異なる書き方をしていると思います。
HCLでは、networkInterfaceはブロックであるため中括弧({})で定義しますが、CDKTFにおける表現はオブジェクトのリストとして定義します。つまり、ブラケット([])で囲った後、オブジェクト表現である中括弧({})で定義します。
これは、CDKTFの方がREST APIの表現に近いので、APIを知っていれば自然な書き方になっていると思います。

全てのリソースを記述すると、main.tsは以下のようになっているはずです。

main.ts
import {Construct} from "constructs";
import {App, TerraformStack} from "cdktf";
import {
    GoogleProvider,
    ComputeNetwork,
    ComputeSubnetwork
    ComputeAddress,
    ComputeDisk,
    ComputeInstance,
} from "@cdktf/provider-google";

class MyStack extends TerraformStack {
    constructor(scope: Construct, name: string) {
        super(scope, name);

        new GoogleProvider(this, "google", {
            project: "myProjectID",
        });

        const network = new ComputeNetwork(this, "cdktf-network", {
            name: "cdktf-network",
            routingMode: "REGIONAL",
            autoCreateSubnetworks: false,
        });

        const subnetwork = new ComputeSubnetwork(this, "cdktf-subnetwork", {
            name: "cdktf-subnetwork",
            network: network.selfLink,
            ipCidrRange: "10.10.0.0/16",
            region: "asia-northeast1",
        });

        const address = new ComputeAddress(this, "cdktf-address", {
            name: "cdktf-address-internal",
            subnetwork: subnetwork.selfLink,
            region: subnetwork.region,
            addressType: "INTERNAL",
        });

        const disk = new ComputeDisk(this, "cdktf-disk", {
            name: "cdktf-disk",
            zone: "asia-northeast1-b",
            type: "pd-standard",
            size: 10,
            image: "ubuntu-os-cloud/ubuntu-2204-lts",
        });

        new ComputeInstance(this, "cdktf-instance", {
            name: "cdktf-instace",
            zone: "asia-northeast1-b",
            machineType: "e2-medium",
            bootDisk: {
                autoDelete: false,
                source: disk.selfLink
            },
            networkInterface: [
                {
                    networkIp: address.address,
                    subnetwork: subnetwork.selfLink,
                }
            ],
            canIpForward: false,
            tags: ["iap"]
        });
    }
}

const app = new App();
new MyStack(app, "test-project");
app.synth();

CDKTFでリソースをデプロイする

main.tsに一通りリソースを定義できたので、実際にデプロイしていきます。
Terraformでは、以下のようなコマンドを使ってリソースデプロイしていました。

# リソースデプロイの実行計画の表示
terraform plan

# リソースデプロイ
terraform apply

# 作成したリソースの破棄
terraform destroy

CDKTFでは、上記の代わりに以下のコマンドを使用します。

# リソースデプロイの実行計画の表示
# cdktf plan でも実行可能 
cdktf diff 

# リソースデプロイ
# cdktf apply でも実行可能
cdktf deploy

# 作成したリソースの破棄
cdktf destroy

まずは、cdktf diffを実行してリソース作成の実行計画を表示してみます。
基本的には内部的に実行されていると思われるterraform planと同等の内容が表示されます。

cdktf diff実行結果
[2022-08-16T18:09:39.908] [INFO] default - Error reporting disabled: SENTRY_DSN not set
test-project  Initializing the backend...
test-project  Initializing provider plugins...
              - Reusing previous version of hashicorp/google from the dependency lock file
test-project  - Using previously-installed hashicorp/google v4.31.0

              Terraform has been successfully initialized!

              You may now begin working with Terraform. Try running "terraform plan" to see
              any changes that are required for your infrastructure. All Terraform commands
              should now work.

              If you ever set or change modules or backend configuration for Terraform,
              rerun this command to reinitialize your working directory. If you forget, other
              commands will detect it and remind you to do so if necessary.
test-project  Terraform used the selected providers to generate the following execution
              plan. Resource actions are indicated with the following symbols:
              + create

              Terraform will perform the following actions:
test-project    # google_compute_address.cdktf-address (cdktf-address) will be created
                + resource "google_compute_address" "cdktf-address" {
              + address            = (known after apply)
              + address_type       = "INTERNAL"
              + creation_timestamp = (known after apply)
              + id                 = (known after apply)
              + name               = "cdktf-address-internal"
              + network_tier       = (known after apply)
              + project            = (known after apply)
              + purpose            = (known after apply)
              + region             = "asia-northeast1"
              + self_link          = (known after apply)
              + subnetwork         = (known after apply)
              + users              = (known after apply)
              }

                # google_compute_disk.cdktf-disk (cdktf-disk) will be created
                + resource "google_compute_disk" "cdktf-disk" {
              + creation_timestamp        = (known after apply)
              + id                        = (known after apply)
              + image                     = "ubuntu-os-cloud/ubuntu-2204-lts"
              + label_fingerprint         = (known after apply)
              + last_attach_timestamp     = (known after apply)
              + last_detach_timestamp     = (known after apply)
              + name                      = "cdktf-disk"
              + physical_block_size_bytes = (known after apply)
              + project                   = (known after apply)
              + provisioned_iops          = (known after apply)
              + self_link                 = (known after apply)
              + size                      = 10
              + source_image_id           = (known after apply)
              + source_snapshot_id        = (known after apply)
              + type                      = "pd-standard"
              + users                     = (known after apply)
              + zone                      = "asia-northeast1-b"
              }
test-project    # google_compute_instance.cdktf-instance (cdktf-instance) will be created
                + resource "google_compute_instance" "cdktf-instance" {
              + can_ip_forward       = false
              + cpu_platform         = (known after apply)
              + current_status       = (known after apply)
              + deletion_protection  = false
              + guest_accelerator    = (known after apply)
              + id                   = (known after apply)
              + instance_id          = (known after apply)
              + label_fingerprint    = (known after apply)
              + machine_type         = "e2-medium"
              + metadata_fingerprint = (known after apply)
              + min_cpu_platform     = (known after apply)
              + name                 = "cdktf-instance"
              + project              = (known after apply)
              + self_link            = (known after apply)
              + tags                 = [
              + "iap",
              ]
              + tags_fingerprint     = (known after apply)
              + zone                 = "asia-northeast1-b"

              + boot_disk {
              + auto_delete                = false
              + device_name                = (known after apply)
              + disk_encryption_key_sha256 = (known after apply)
              + kms_key_self_link          = (known after apply)
              + mode                       = "READ_WRITE"
              + source                     = (known after apply)

              + initialize_params {
              + image  = (known after apply)
              + labels = (known after apply)
              + size   = (known after apply)
              + type   = (known after apply)
              }
              }

              + confidential_instance_config {
              + enable_confidential_compute = (known after apply)
              }

              + network_interface {
              + ipv6_access_type   = (known after apply)
              + name               = (known after apply)
              + network            = (known after apply)
              + network_ip         = (known after apply)
              + stack_type         = (known after apply)
              + subnetwork         = (known after apply)
              + subnetwork_project = (known after apply)
              }

              + reservation_affinity {
              + type = (known after apply)

              + specific_reservation {
              + key    = (known after apply)
              + values = (known after apply)
              }
              }

              + scheduling {
              + automatic_restart           = (known after apply)
              + instance_termination_action = (known after apply)
              + min_node_cpus               = (known after apply)
              + on_host_maintenance         = (known after apply)
              + preemptible                 = (known after apply)
              + provisioning_model          = (known after apply)

              + node_affinities {
              + key      = (known after apply)
              + operator = (known after apply)
              + values   = (known after apply)
              }
              }
              }

                # google_compute_network.cdktf-network (cdktf-network) will be created
                + resource "google_compute_network" "cdktf-network" {
              + auto_create_subnetworks         = false
              + delete_default_routes_on_create = false
              + gateway_ipv4                    = (known after apply)
              + id                              = (known after apply)
              + internal_ipv6_range             = (known after apply)
              + mtu                             = (known after apply)
              + name                            = "cdktf-network"
              + project                         = (known after apply)
              + routing_mode                    = "REGIONAL"
              + self_link                       = (known after apply)
              }

                # google_compute_subnetwork.cdktf-subnetwork (cdktf-subnetwork) will be created
                + resource "google_compute_subnetwork" "cdktf-subnetwork" {
              + creation_timestamp         = (known after apply)
              + external_ipv6_prefix       = (known after apply)
              + fingerprint                = (known after apply)
              + gateway_address            = (known after apply)
              + id                         = (known after apply)
              + ip_cidr_range              = "10.10.0.0/16"
              + ipv6_cidr_range            = (known after apply)
              + name                       = "cdktf-subnetwork"
              + network                    = (known after apply)
              + private_ipv6_google_access = (known after apply)
              + project                    = (known after apply)
              + purpose                    = (known after apply)
              + region                     = "asia-northeast1"
              + secondary_ip_range         = (known after apply)
              + self_link                  = (known after apply)
              + stack_type                 = (known after apply)
              }

              Plan: 5 to add, 0 to change, 0 to destroy.

              ─────────────────────────────────────────────────────────────────────────────

              Saved the plan to: plan

              To perform exactly these actions, run the following command to apply:
              terraform apply "plan"

terraform planを実行すると、同ディレクトリ内にcdktf.outディレクトリが作成され、その中にTerraformStackクラスのインスタンス毎にTerraform CLIに必要なコマンド結果(terraform initで初期化したProviderプラグインファイルや、terraform planのplan結果等)が格納されるようです。

では、cdktf deployコマンドを実行して見ます。
terraform planと同様に実行計画が表示されますが、以下のようなプロンプトが表示されます。

Please review the diff output above for test-project
❯ Approve  Applies the changes outlined in the plan.
  Dismiss
  Stop

Approveを選択した状態でEnterを押せばデプロイが開始されます。
DismissStopを選択した場合はデプロイされずコマンドが終了します。
DismissStop違いは、複数のStackを定義している場合に現れるようです。
Dismissの場合は、表示されているStackのみデプロイされず次のStackに移行する(ただし、Stack間で依存関係がある場合は依存するStackも実行されない)、Stopは表示されているStack以降全て停止する、という違いのようです。(が、ちゃんとは検証してません。)

無事リソースの作成が完了すると、terraform applyと同様に以下のようなメッセージが表示されます。

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

コマンドラインで作成結果を確認してみます。無事、作成されているようです。

$ gcloud compute networks list --filter=name:cdktf
NAME           SUBNET_MODE  BGP_ROUTING_MODE  IPV4_RANGE  GATEWAY_IPV4
cdktf-network  CUSTOM       REGIONAL

$ gcloud compute networks subnets list --filter=name:cdktf
NAME              REGION           NETWORK        RANGE         STACK_TYPE  IPV6_ACCESS_TYPE  INTERNAL_IPV6_PREFIX  EXTERNAL_IPV6_PREFIX
cdktf-subnetwork  asia-northeast1  cdktf-network  10.10.0.0/16  IPV4_ONLY

$ gcloud compute instances list
NAME            ZONE               MACHINE_TYPE  PREEMPTIBLE  INTERNAL_IP  EXTERNAL_IP  STATUS
cdktf-instance  asia-northeast1-b  e2-medium                  10.10.0.2                 RUNNING

cdktf deployコマンドを実行すると、カレントディレクトリ内にterraform.test-project.tfstateというファイルが作成されていました。
素のTerraformとは異なり、CDKTFはStack毎にstateを管理しているように見えるため、stateファイル名にStack名を含めていると思われます。

作成してみた感想

こうして記事にしてみると、Terraform HCLより記述量が多かったり、リソースモジュール自体のマニュアルが無かったりするため一見定義し辛いようにも思いましたが、
実際は開発環境がTypescriptに対応しているとクラスのプロパティをサジェストしてくれるので思ったよりずっと簡単でした。
筆者はIntelliJ IDEAで開発しましたが、サジェストはTerraformプラグインよりも親切な気がします。
Terraformのブロック表現に慣れていると若干違和感があるくらいで、開発フローさえ確立できればCDKTFの方が言語としての柔軟性を考慮するとトータル良い気がします。

まとめ

CDK for TerraformはGAリリースになっていますが、GitHub上のバージョンは0.12であり、まだまだ発展途上のツールなのかな、という印象です。
(特に多種多様なProviderへの対応状況等)
しかし、使い慣れたプログラミング言語を使って開発できる点、特に静的型付け言語のサジェストやLINT、あるいは、単体テストを利用できることを考慮すると、今後も研究を続けなければと思う次第です。

Discussion