CDK for Terraformファーストタッチで困ったポイントや先に見ておいた方がよかった点を紹介します

「CDKは素晴らしい。Terraformも素晴らしい。2つ合わさればどうなっちゃうの?」ということで試してみました。
2023.04.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。

みなさんIaCしてますか?

IaCはビジネス的な目的を達成するための手段とはいうものの、インフラをコードとして管理できるのは楽しいのでいろいろ触ってみたいですよね。

ところでCDK for Terraformをご存じでしょうか?

一言で表現すれば「TerraformのしくみをTypeScriptやPythonなどさまざまな言語で利用できるもの」です。

以前から存在は知っていたのですが、なかなかHello,Wolrdする機会がなかったのでこの度ファーストタッチをしてみました!

また、その際に「事前に知っておいた方が良いな」と思ったことや「開始したは良いものの書き方がわからない」というポイントが数点あったので、まとめてみました!

いきなりまとめ

後述しますが以下のようなポイントを抑えておくと良いかと思います。

  • CDK for TerraformはAWS CDKでも利用しているaws/jsiiというしくみで多言語対応をしている(今回の記事で説明)
    • AWS CDKでは最終的にCloudFormationのテンプレートを生成するが、CDK For TerraformではTerraformで利用するJSONファイルを出力するようなイメージです
    • そのためAWS CDKではちょっと複雑なことをしようとするとCloudFormationの理解が必要ですが、CDK for Terraformでは同様にTerraformの理解が必要だと思います
  • Terraform Registryで公開されているモジュールを利用できる(今回の記事にて説明)
    • モジュールのOutputを参照する時にちょっとコツが必要な場合も
  • 既存のTerrformコードからTypeScriptなどのCDK用のコードを生成する機能がある(今回の記事では触れない)
  • まだテクニカルプレビューですがAWS CDKの機能を利用できるしくみもあります(今回の記事では触れない)

なお、今回私が作成したコードは以下リポジトリに格納しているので全文を確認したい方はこちらを参照ください。

CDK for Terraformとは?

CDK for TerraformとはAWS CDKでも利用しているaws/jsiiというしくみでTerraformの機能をいろいろなプログラミング言語で利用できるようにしたものです。

CDK for Terraform on AWS 一般提供 (GA) のお知らせ | Amazon Web Services ブログに概要や簡単な説明が書かれています。

AWS CDKではCloudFormationというしくみをラップしていて、各種プログラミング言語からうまいことCloudFormationの設定ファイルを生成するようなイメージです。

Install CDK for Terraform and Run a Quick Start Demo | Terraform | HashiCorp Developerに掲載されている以下の図が非常にわかりやすいのですが、CDK for Terraformでは各種プログラミング言語からうまいことTerraformで利用できるjsonファイルを生成するようなイメージです。

20230409_cdk_tf_hello_from_official_site

(出典: https://developer.hashicorp.com/terraform/tutorials/cdktf/cdktf-install)

そのためシンプルな利用では良いかもしれませんが、ちょっと複雑なことを利用しようとしたり、本番運用で活用しようとするとTerraformそのものの知識がどうしても必要になると思います。

また2023年4月9日現在、バージョンが0.15でありまだまだ発展途上であることも理解しておきましょう。

インストールとプロジェクトの初期化

Install CDK for Terraform and Run a Quick Start Demo | Terraform | HashiCorp Developerのページに従ってCDK For Terraformに必要なCLIをインストールできます。

以下が開発のGitHubのリポジトリです。

AWSリソース作成用のチュートリアルはBuild AWS Infrastructure with CDK for Terraform | Terraform | HashiCorp Developerにあります。

cdktfコマンドのインストールの前に必要なTerraform CLI(一定のバージョン以上)が必要であるため、Prerequiresを確認しましょう。

以下コマンドでCLIをインストールしました。

npm install --global cdktf-cli@latest

また、私はTypeScriptでコードを書いてみたかったので以下のコマンドでプロジェクトの初期化をしました。

cdktf init --template=typescript --providers=hashicorp/aws

その際対話式のインタフェースでいくつか質問をされるため、状況に応じて設定してください。

? Do you want to continue with Terraform Cloud remote state management? No
? Project Name hello
? Project Description A simple getting started project for cdktf.
? Do you want to start from an existing Terraform project? No
? Do you want to send crash reports to the CDKTF team? See
https://www.terraform.io/cdktf/create-and-deploy/configuration-file#enable-crash-reporting-for-the-cli for more
information (Y/n)

以下のようにファイル群が生成されます。

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

個人的にhelpというテキストファイルにコマンドの簡単な使い方が書かれていて、好きでした。

helpファイルの中身
========================================================================================================

  Your cdktf typescript project is ready!

  cat help                Print this message

  Compile:
    npm run get           Import/update Terraform providers and modules (you should check-in this directory)
    npm run compile       Compile typescript code to javascript (or "npm run watch")
    npm run watch         Watch for changes and compile typescript in the background
    npm run build         Compile typescript

  Synthesize:
    cdktf synth [stack]   Synthesize Terraform resources from stacks to cdktf.out/ (ready for 'terraform apply')

  Diff:
    cdktf diff [stack]    Perform a diff (terraform plan) for the given stack
# 省略

現時点でmain.tsの内容は以下のようになっていて、何もリソースが定義されていません。

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

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

    // define resources here
  }
}

const app = new App();
new MyStack(app, "hello");
app.synth();

普通ならHello,World!的なリソースを作ると思うのですが、Terraform for CDKでは「Terraform Registryで公開されているモジュールを利用できる」という点が気になったので使ってみることにしました。

Terraform Registoryのモジュールを使ってみる

terraform-aws-modules/vpc/awsのインストール

Modules - CDK for Terraform | Terraform | HashiCorp Developerに従ってモジュールを利用してみます。

まずはcdktf.jsonという設定ファイルにモジュールの利用を追記する必要があります。

以下のように追記しました。

{
  "language": "typescript",
  "app": "npx ts-node main.ts",
  "projectId": "${プロジェクトID}",
  "sendCrashReports": "false",
  "terraformProviders": [],
  "terraformModules": [
    // こちらに追記しました
    {
      "name": "vpc",
      "source": "terraform-aws-modules/vpc/aws",
      "version": "~> 4.0"
    }
  ],
  "context": {
    "excludeStackIdFromLogicalIds": "true",
    "allowSepCharsInLogicalIds": "true"
  }
}

そしてnpm run getコマンドを実行します。

❯ npm run get

> hello@1.0.0 get
> cdktf get

Generated typescript constructs in the output directory: .gen

出力されたメッセージにあるようにTypeScript用のコードが./genディレクトリに出力されたようです。

.gen/modules/vpc.ts という形で先ほどインポートしたモジュール用のtsファイルが生成されていました。

// generated by cdktf get
// terraform-aws-modules/vpc/aws
import { TerraformModule, TerraformModuleUserConfig } from 'cdktf';
import { Construct } from 'constructs';
export interface VpcConfig extends TerraformModuleUserConfig {
  /**
   * The Autonomous System Number (ASN) for the Amazon side of the gateway. By default the virtual private gateway is created with the current default Amazon ASN
   * @default 64512
   */
  readonly amazonSideAsn?: string;
  /**
   * A list of availability zones names or ids in the region
   * @default 
   */
  readonly azs?: string[];
  /**
// 以下省略

モジュールを呼び出し

ということでmain.tsファイルを以下のように記載しました。

main.ts

import { Construct } from "constructs";
import { App, TerraformStack, Token, Fn } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
// 先ほど生成されたモジュールをインポート
import { Vpc } from "./.gen/modules/vpc";

class MyStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    const projectName = "HelloCDKTF";
    new AwsProvider(this, "aws", {
      region: "ap-northeast-1",
      defaultTags: [
        {
          tags: {
            project: projectName,
            terraform: "true",
          },
        },
      ],
    });

    // terraform registroyのモジュールを利用してみる
    const vpc = new Vpc(this, "helloVPC", {
      cidr: "10.30.0.0/16",
      name: projectName,
      manageDefaultNetworkAcl: false,
      azs: ["ap-northeast-1a", "ap-northeast-1c"],
      privateSubnets: ["10.30.1.0/24", "10.30.2.0/24"],
      publicSubnets: ["10.30.11.0/24", "10.30.12.0/24"],
      enableDnsHostnames: true,
      enableDnsSupport: true,
      enableNatGateway: true,
    });
}

const app = new App();
new MyStack(app, "hello");
app.synth();

この段階でcdktf plancdktf diffコマンドを実行すると以下のように差分を確認できます。

hello    # module.helloVPC.aws_route.public_internet_gateway[0] will be created
         + resource "aws_route" "public_internet_gateway" {
             + destination_cidr_block = "0.0.0.0/0"
             + gateway_id             = (known after apply)
             + id                     = (known after apply)
             + instance_id            = (known after apply)
             + instance_owner_id      = (known after apply)
             + network_interface_id   = (known after apply)
             + origin                 = (known after apply)
             + route_table_id         = (known after apply)
             + state                  = (known after apply)

             + timeouts {
                 + create = "5m"
               }
           }

また、cdktf applyを実行することでインフラリソースをプロビジョニングできます。

その際以下のように確認画面が表示されます。

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

Approveを選択することで、terraform applyを実行した時と同様に経過が表示され、待っていると無事VPC周りのリソースが作られました。

hello  yes
hello  module.helloVPC.aws_vpc.this[0]: Creating...
       aws_iam_role.connectableInstanceRole (connectableInstanceRole): Creating...
hello  module.helloVPC.aws_eip.nat[0]: Creating...
       module.helloVPC.aws_eip.nat[1]: Creating...

ファイルを分割してみる

プログラミング言語っぽいメリットを味わってみたかったので、先ほど作成したVPCにSystems Managerを利用して接続できるEC2インスタンスを作成してみることにしました。

ということで、以下のようにファイルを作成します。

modules/connectableInstance.tsファイル

modules/connectableInstance.ts

import { Construct } from "constructs";
import { IamRole } from "@cdktf/provider-aws/lib/iam-role";
import { DataAwsIamPolicyDocument } from "@cdktf/provider-aws/lib/data-aws-iam-policy-document";
import { Instance } from "@cdktf/provider-aws/lib/instance";
import { DataAwsAmi } from "@cdktf/provider-aws/lib/data-aws-ami";
import { Vpc } from "../.gen/modules/vpc";
import { SecurityGroup } from "@cdktf/provider-aws/lib/security-group";
import { IamInstanceProfile } from "@cdktf/provider-aws/lib/iam-instance-profile";

export class ConnectableInstance {
  private scope: Construct;
  private vpc: Vpc;
  private subnetId: string;

  constructor(scope: Construct, vpc: Vpc, subnetId: string) {
    this.scope = scope;
    this.vpc = vpc;
    this.subnetId = subnetId;
  }

  public createResources(): Instance {
    const role = this.createRole();
    const instance = this.createInstance(role);
    return instance;
  }

  private createRole(): IamRole {
    const role = new IamRole(this.scope, "connectableInstanceRole", {
      name: "connectableInstanceRole",
      assumeRolePolicy: this.getAssumeRolePolicyDocument().json,
      managedPolicyArns: [
        "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
      ],
    });
    return role;
  }

  private createInstance(role: IamRole): Instance {
    // get latest amazon linux 2 ami
    const ami = new DataAwsAmi(this.scope, "connectableInstanceAmi", {
      mostRecent: true,
      owners: ["amazon"],
      filter: [
        {
          name: "name",
          values: ["amzn2-ami-hvm-*-x86_64-gp2"],
        },
      ],
    });
    const sg = this.createEgreeSG();
    const instanceProfile = new IamInstanceProfile(
      this.scope,
      "connectableInstanceProfile",
      {
        name: "connectableInstanceProfile",
        role: role.name,
      }
    );
    const instance = new Instance(this.scope, "connectableInstance", {
      ami: ami.id,
      instanceType: "t2.micro",
      iamInstanceProfile: instanceProfile.name,
      vpcSecurityGroupIds: [sg.id],
      subnetId: this.subnetId,
      tags: {
        Name: "connectableInstance",
      },
    });
    return instance;
  }

  private getAssumeRolePolicyDocument(): DataAwsIamPolicyDocument {
    return new DataAwsIamPolicyDocument(
      this.scope,
      "connectableInstanceAssumeRolePolicyDocument",
      {
        statement: [
          {
            actions: ["sts:AssumeRole"],
            principals: [
              {
                identifiers: ["ec2.amazonaws.com"],
                type: "Service",
              },
            ],
          },
        ],
      }
    );
  }

  private createEgreeSG(): SecurityGroup {
    const egress = new SecurityGroup(this.scope, "connectableInstanceEgress", {
      name: "connectableInstanceEgress",
      vpcId: this.vpc.vpcIdOutput,
      ingress: [],
      egress: [
        {
          fromPort: 0,
          toPort: 0,
          protocol: "-1",
          cidrBlocks: ["0.0.0.0/0"],
        },
      ],
      tags: {
        Name: "connectableInstanceEgress",
      },
    });
    return egress;
  }
}

こちらのファイルをインポートしてmain.tsから呼び出します。

main.ts

    const connectableInstance = new ConnectableInstance(
      this,
      vpc,
      // この書き方については後述します
      Fn.element(subnetIdList, 0)
    );

慣れたプログラミング言語のシンタックスを使えるので、書いていて楽しいのもCDK系列の良いところの1つだと感じました。

モジュールの値を参照する時に困ったポイント

先ほどは軽く流しましたが、EC2インスタンスを作成する際にterraform-aws-modules/vpc/awsで作成したプライベートサブネットのIDが必要になりました。

上記モジュールのOutputsを確認すれば、private_subnetsという出力を使えれば良さそうです。

Terraformだと以下のような書き方となるかと思います。

module "sample_vpc" {
  source = "terraform-aws-modules/vpc/aws"
  name = "niikawa-test"
  cidr = "172.29.0.0/16"

  enable_dns_hostnames = true
  enable_dns_support   = true
  //省略
}

module "public_ec2" {
  // 参照する時はこのような感じ
  subnet_id      = module.sample_vpc.private_subnets[0]
}

インポートしたVPCモジュールでは以下のようにprivate_subnetsの出力値を取得できるっぽい関数が用意されています。

.gen/modules/vpc.ts

  public get privateSubnetsOutput() {
    // getStringであることに注意
    return this.getString('private_subnets')
  }

ですがTerraformと同じノリで以下のように書くことはできません。

const connectableInstance = new ConnectableInstance(
  this,
  vpc,
  // これだとうまく動作しません
  vpc.privateSubnetsOutput[0]
);

return this.getString('private_subnets')のようにあくまで文字列としてTerraformモジュールの結果を出力されているに過ぎません。

ですが、元のモジュールではprivate_subnetsList of IDs of private subnetsのようにList型が定義されています。

そのため以下のようにListとして扱いながら、element関数を使ってListの要素を参照するとうまく動作しました。

    // 明示的にListとして扱う
    const subnetIdList = Token.asList(vpc.privateSubnetsOutput);
    const connectableInstance = new ConnectableInstance(
      this,
      vpc,
      // vpc.privateSubnetsOutput[0] ではうまく動作しない
      Fn.element(subnetIdList, 0)
    );
    connectableInstance.createResources();

もしくは、Functions - CDK for Terraform | Terraform | HashiCorp Developerに記載されているようにTerraformのビルトイン関数をインラインの文字列として記載できました。

// 以下の書き方でもOKでした
`\${${vpc.privateSubnetsOutput}[0]}`
`\${element(${vpc.privateSubnetsOutput}, 0)}`

個人的には見通しが悪い上、型の恩恵も受けたいので素直にToken.asListFn.element関数を使った方が良いかと感じました。

このあたりについてはVariables and Outputs - CDK for Terraform | Terraform | HashiCorp Developerに記載がありますので、ご参照ください。

ListやMapを返却値としているモジュールでは留意が必要そうです。

最後に

Teraform for CDKを使ってみました。

まだまだバージョンが0.15ということもあり、発展途上な点はあるかと思います。

ですが、「Terraformのしくみを使って」慣れた言語でリソースを記述できるというのは大きな利点だと感じています。

次回移行、もう少し他の利点・ループの書き方・テストの書き方などを検証できればと思います。

この記事がどなたかの参考になればうれしいです。以上今泉でした。