CDKTF로 Amazon VPC 구성하기

Cloud Development Kit for Terraform (CDKTF)를 타입스크립트로 작성해서 AWS에서 Amazon VPC를 구성해보겠습니다.
2023.03.31

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

Cloud Development Kit for Terraform (CDKTF)를 타입스크립트로 작성해서 AWS에서 Amazon VPC를 구성해보겠습니다.

전체 프로젝트는 아래 레포지토리에서 확인하실 수 있습니다.

https://github.com/Tolluset/cdktf-for-vpc

셋업

terraform과 cdktf를 다운받아줍시다.

# install terraform
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

# install cdktf
npm install --global cdktf-cli@latest
or
brew install cdktf

사용한 버전은 아래와 같습니다.

❯ terraform --version
Terraform v1.4.2
on darwin_amd64
❯ cdktf --version
0.15.5
❯ tsc --version
Version 5.0.2

프로젝트를 진행할 디렉토리를 생성하고 프로젝트를 만들어줍시다.

mkdir cdktf-for-vpc && cd cdktf-for-vpc

cdktf init --template="typescript" --providers="aws@~>4.0" --local

? Project Name cdktf-for-vpc
? Project Description construct vpc by 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 No

Checking whether pre-built provider exists for the following constraints:
  provider: aws
  version : ~>4.0
  language: typescript
  cdktf   : 0.15.5

Found pre-built provider.
Adding package @cdktf/provider-aws @ 12.0.11
Installing package @cdktf/provider-aws @ 12.0.11 using npm.
Package installed.

위와 비슷한 메시지가 나오셨다면 성공입니다.

코드

구성은 하나의 Amazon VPC 안에 두 개의 AZ 그리고 각각 퍼블릭, 프라이벳 서브넷을 두고 퍼블릭 서브넷은 인터넷 게이트웨이를 바라보는 라우팅 테이블에 연결하도록 하겠습니다.

import { Construct, IConstruct } from "constructs";
import { App, TerraformStack, Aspects, IAspect } from "cdktf";
import {
  vpc,
  provider,
  subnet,
  internetGateway,
  routeTable,
  route,
  routeTableAssociation,
} from "@cdktf/provider-aws";

const NOW = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });

type TaggableConstruct = IConstruct & {
  tags?: { [key: string]: string };
  tagsInput?: { [key: string]: string };
};

function isTaggableConstruct(x: IConstruct): x is TaggableConstruct {
  return "tags" in x && "tagsInput" in x;
}

class Tagged implements IAspect {
  constructor(private tags: Record<string, string>) {}

  visit(node: IConstruct): void {
    if (isTaggableConstruct(node)) {
      const currentTags = node.tagsInput || {};
      node.tags = {
        Name: node.node.id,
        CreatedAt: NOW,
        ...this.tags,
        ...currentTags,
      };
    }
  }
}

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

    new provider.AwsProvider(this, `${id}-aws-provider`, {
      region: "ap-northeast-2",
    });

    const newVpc = new vpc.Vpc(this, `${id}-vpc`, {
      cidrBlock: "10.0.0.0/16",
    });

    const newInternetGateway = new internetGateway.InternetGateway(
      this,
      `${id}-internet-gateway`,
      {
        vpcId: newVpc.id,
      }
    );

    const publicSubnetA = new subnet.Subnet(this, `${id}-public-subnet-a`, {
      vpcId: newVpc.id,
      availabilityZone: "ap-northeast-2a",
      mapPublicIpOnLaunch: true,
      cidrBlock: "10.0.1.0/24",
    });

    const publicSubnetC = new subnet.Subnet(this, `${id}-public-subnet-c`, {
      vpcId: newVpc.id,
      availabilityZone: "ap-northeast-2c",
      mapPublicIpOnLaunch: true,
      cidrBlock: "10.0.3.0/24",
    });

    const publicRouteTable = new routeTable.RouteTable(
      this,
      `${id}-public-route-table`,
      {
        vpcId: newVpc.id,
      }
    );

    new routeTableAssociation.RouteTableAssociation(
      this,
      `${id}-route-table-association-public-subnet-a`,
      {
        routeTableId: publicRouteTable.id,
        subnetId: publicSubnetA.id,
      }
    );

    new routeTableAssociation.RouteTableAssociation(
      this,
      `${id}-route-table-association-public-subnet-c`,
      {
        routeTableId: publicRouteTable.id,
        subnetId: publicSubnetC.id,
      }
    );

    new route.Route(this, `${id}-route-public-to-internet-gateway`, {
      destinationCidrBlock: "0.0.0.0/0",
      routeTableId: publicRouteTable.id,
      gatewayId: newInternetGateway.id,
    });

    const privateSubnetA = new subnet.Subnet(this, `${id}-private-subnet-a`, {
      vpcId: newVpc.id,
      availabilityZone: "ap-northeast-2a",
      cidrBlock: "10.0.101.0/24",
    });

    const privateSubnetC = new subnet.Subnet(this, `${id}-private-subnet-c`, {
      vpcId: newVpc.id,
      availabilityZone: "ap-northeast-2c",
      cidrBlock: "10.0.103.0/24",
    });

    const privateRouteTable = new routeTable.RouteTable(
      this,
      `${id}-private-route-table`,
      {
        vpcId: newVpc.id,
      }
    );

    new routeTableAssociation.RouteTableAssociation(
      this,
      `${id}-route-table-association-private-subnet-a`,
      {
        routeTableId: privateRouteTable.id,
        subnetId: privateSubnetA.id,
      }
    );

    new routeTableAssociation.RouteTableAssociation(
      this,
      `${id}-route-table-association-private-subnet-c`,
      {
        routeTableId: privateRouteTable.id,
        subnetId: privateSubnetC.id,
      }
    );

    Aspects.of(this).add(new Tagged({ CreatedBy: "cdktf", Project: id }));
  }
}

const app = new App();

new VpcStack(app, "cdktf-for-vpc");

app.synth();

기본적으로는 테라폼 코드를 타입스크립트로 재작성하는 느낌으로 쓰면 됩니다. 예를 들면, 서브넷의 경우 서브넷 테라폼 문서 에 적혀져 있는 옵션들을 스네이크 케이스에서 카멜 케이스로 변환해서 사용하면 됩니다. 또한, 노드 모듈에서 모듈을 참조하고 있기 때문에 실제 구현체의 타입을 확인해도 됩니다.

혹은 깃헙 레포지토리에 적혀져 있는 경우도 있지만, 없는 리소스도 있는 것 같습니다.

서브넷 깃헙 문서

특이한점으로는 리소스를 재정의하는 부분입니다.

const NOW = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });

type TaggableConstruct = IConstruct & {
  tags?: { [key: string]: string };
  tagsInput?: { [key: string]: string };
};

function isTaggableConstruct(x: IConstruct): x is TaggableConstruct {
  return "tags" in x && "tagsInput" in x;
}

class Tagged implements IAspect {
  constructor(private tags: Record<string, string>) {}

  visit(node: IConstruct): void {
    if (isTaggableConstruct(node)) {
      const currentTags = node.tagsInput || {};
      node.tags = {
        Name: node.node.id,
        CreatedAt: NOW,
        ...this.tags,
        ...currentTags,
      };
    }
  }
}

...

    Aspects.of(this).add(new Tagged({ CreatedBy: "cdktf", Project: id }));

Aspects.of(this).add(new Tagged({ CreatedBy: "cdktf", Project: id }));

이 부분이 정의한 리소스들을 재정의 하는 부분입니다. Aspects 라는 클래스는 cdktf 모듈에서 불러오고 있지만, 주석을 보면 원래는 Constructs 모듈에서 정의되어 있었고 이후에 aws-cdk 로 이동된 것처럼 보이네요.

//  Originally from aws-cdk v2 because with constructs v10 Aspects where moved to the AWS CDK
//  https://github.com/aws/aws-cdk/blob/dcae3eead0dbf9acb1ed80ba95bb104c64cb1bd7/packages/@aws-cdk/core/lib/aspect.ts

https://github.com/hashicorp/terraform-cdk/blob/v0.15.5/packages/cdktf/lib/aspect.ts#LL3-L4

Aspects 클래스를 살짝 들여다 보겠습니다.

// Copyright (c) HashiCorp, Inc
// SPDX-License-Identifier: MPL-2.0
//  Originally from aws-cdk v2 because with constructs v10 Aspects where moved to the AWS CDK
//  https://github.com/aws/aws-cdk/blob/dcae3eead0dbf9acb1ed80ba95bb104c64cb1bd7/packages/@aws-cdk/core/lib/aspect.ts
import { IConstruct } from "constructs";

const ASPECTS_SYMBOL = Symbol("cdktf-aspects");

/**
 * Represents an Aspect
 */
export interface IAspect {
  /**
   * All aspects can visit an IConstruct
   */
  visit(node: IConstruct): void;
}

/**
 * Aspects can be applied to CDK tree scopes and can operate on the tree before
 * synthesis.
 */
export class Aspects {
  /**
   * Returns the `Aspects` object associated with a construct scope.
   * @param scope The scope for which these aspects will apply.
   */
  public static of(scope: IConstruct): Aspects {
    let aspects = (scope as any)[ASPECTS_SYMBOL];
    if (!aspects) {
      aspects = new Aspects();

      Object.defineProperty(scope, ASPECTS_SYMBOL, {
        value: aspects,
        configurable: false,
        enumerable: false,
      });
    }
    return aspects;
  }

  private readonly _aspects: IAspect[];

  private constructor() {
    this._aspects = [];
  }

  /**
   * Adds an aspect to apply this scope before synthesis.
   * @param aspect The aspect to add.
   */
  public add(aspect: IAspect) {
    this._aspects.push(aspect);
  }

  /**
   * The list of aspects which were directly applied on this scope.
   */
  public get all(): IAspect[] {
    return [...this._aspects];
  }
}

Aspects can be applied to CDK tree scopes and can operate on the tree before synthesis.

합성 (synthesis) 과정전에 트리 스코프에 특정 작업을 수행하거나 얻을 수 있다고 하네요. 실제로 add 를 사용해서 태그를 추가하는 형태로 사용했습니다.

그런데 여기서 트리 스코프라는 용어가 나오는데 이 스코프의 타입은 IConstruct 이고 Constructs 모듈에서 불러오고 있습니다.

import { IConstruct } from "constructs";

...


  public static of(scope: IConstruct): Aspects {

리소스를 정의할 때도 매번 "스코프" 를 넣어주고 있었습니다. 그러면 이 Constructs 는 대체 무슨 녀석이길래 이렇게 자주 참조되고 있는 걸까요.

// AwsProvider 타입

constructor AwsProvider(scope: Construct, id: string, config?: provider.AwsProviderConfig | undefined): provider.AwsProvider

Constructs are classes which define a "piece of system state". Constructs can be composed together to form higher-level building blocks which represent more complex state.

https://github.com/aws/constructs/tree/10.x

AWS에서 만든 자바스크립트의 클래스를 통해 시스템의 상태를 표현하기 위한 모듈입니다. 살펴보다보니 isConstruct 라는 함수의 신기한 주석이 있었습니다.

 /**
   * Checks if `x` is a construct.
   *
   * Use this method instead of `instanceof` to properly detect `Construct`
   * instances, even when the construct library is symlinked.
   *
   * Explanation: in JavaScript, multiple copies of the `constructs` library on
   * disk are seen as independent, completely different libraries. As a
   * consequence, the class `Construct` in each copy of the `constructs` library
   * is seen as a different class, and an instance of one class will not test as
   * `instanceof` the other class. `npm install` will not create installations
   * like this, but users may manually symlink construct libraries together or
   * use a monorepo tool: in those cases, multiple copies of the `constructs`
   * library can be accidentally installed, and `instanceof` will behave
   * unpredictably. It is safest to avoid using `instanceof`, and using
   * this type-testing method instead.
   *
   * @returns true if `x` is an object created from a class which extends `Construct`.
   * @param x Any object
   */
  public static isConstruct(x: any): x is Construct {
    return x && typeof x === 'object' && x[CONSTRUCT_SYM];
  }

디스크 내에서 클래스로 생성된 인스턴스들이 instanceof 로 구별되지 않는 문제가 있었나 봅니다. 특히 모노레포 같이 심링크로 모듈을 참조하는 경우 instanceof 가 예상대로 동작하지 않기도 했나보네요. 신기하네요...

돌아와서 Aspects 를 사용해서 리소스들에 태그를 지정하게 해준 클래스를 살펴봅시다.

const NOW = new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });

type TaggableConstruct = IConstruct & {
  tags?: { [key: string]: string };
  tagsInput?: { [key: string]: string };
};

function isTaggableConstruct(x: IConstruct): x is TaggableConstruct {
  return "tags" in x && "tagsInput" in x;
}

class Tagged implements IAspect {
  constructor(private tags: Record<string, string>) {}

  visit(node: IConstruct): void {
    if (isTaggableConstruct(node)) {
      const currentTags = node.tagsInput || {};
      node.tags = {
        Name: node.node.id,
        CreatedAt: NOW,
        ...this.tags,
        ...currentTags,
      };
    }
  }
}

IAspect 를 구현한 클래스로 visit 이라는 함수가 있습니다. visit 함수는 트리 스코프 내의 노드들을 방문할 때 동작합니다. 이러한 동작을 바탕으로 태그 설정이 가능한 리소스들에 대해 기본 태그들을 붙여 줍니다. Terraform의 default_tags 같은 기능을 만드는 것인데, CreatedAt: NOW 와 같이 좀 더 유틸성이 필요한 동작도 가능해집니다.

실제로 배포시에 태그가 아래와 같이 달리게 됩니다.

배포

배포를 하기전에 배포하려는 aws 계정을 설정해주세요. 저는 MFA가 설정된 상태에서 assume-role로 계정을 변경하는데 awsume가 편리했습니다. 추천드립니다.

https://github.com/trek10inc/awsume

aws 계정을 설정하고 배포를 진행해봅시다.

cdktf deploy

위 커맨드를 입력하면 생성되거나 업데이트 될 리소스들이 보이게 되고 배포할 지 선택을 할 수 있게됩니다.

aws_subnet.cdktf-for-vpc-public-subnet-c (cdktf-for-vpc-public-subnet-c) will be updated in-place
                 ~ resource "aws_subnet" "cdktf-for-vpc-public-subnet-c" {
                       id                                             = "subnet-xxxxxx"
                     ~ tags                                           = {
                         ~ "CreatedAt" = "2023. 3. 31. 오전 1:49:42" -> "2023. 3. 31. 오전 1:51:02"
                           "CreatedBy" = "cdktf"
                           "Name"      = "cdktf-for-vpc-public-subnet-c"
                           "Project"   = "cdktf-for-vpc"
                       }
                     ~ tags_all                                       = {
                         ~ "CreatedAt" = "2023. 3. 31. 오전 1:49:42" -> "2023. 3. 31. 오전 1:51:02"
                           # (3 unchanged elements hidden)
                       }
                       # (14 unchanged attributes hidden)
                   }

                 # aws_vpc.cdktf-for-vpc-vpc (cdktf-for-vpc-vpc) will be updated in-

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

Approve를 해주시면 배포가 진행되게 됩니다.

아래와 같이 생성된 것을 확인할 수 있습니다. ??? (이름 없는 라우팅 테이블은 VPC 생성 시에 기본으로 생성되는 리소스입니다)

마무리

요금이 나오는 리소스들은 아니지만 사용하지 않으면 삭제해줍시다.

cdktf destroy

배포시와 마찬가지로 삭제될 리소스들이 보이고 Approve 하면 삭제가 진행됩니다.

마지막으로

AWS CDK도 있고 Terraform도 존재하는데 왜 CDKTF라는 비슷한 걸 만드는지 잘 몰랐습니다. 그런데 실제로 사용해보고 나니, AWS CDK 처럼 타입스크립트로 작성할 수 있으면서도 AWS CloudFormation 위에서 동작하는 것이 아닌 점이 다릅니다. 이 점이 좋을수도 있고 나쁠 수도 있지만 이러한 점이 있기 때문에 다른 방법이게 됩니다. 또한, 테라폼 코드도 아웃풋으로 나오기 때문에 테라폼 코드로도 확인 가능한 점이 좋았습니다. 코드도 타입스크립트 같은 언어를 사용해서 기존 개발자들이 쉽게 접근할 수 있고, 기존의 언어 개발 세팅, 테스트 코드도 작성할 수 있게 됩니다.

CDK 형식으로 AWS 이외에도 사용하고 싶거나 AWS 위에서 사용하더라도 AWS CloudFormation 위에서가 아닌 방법으로 사용하고 싶은 경우에 추천드립니다.