🎄

TypeScript で cdktf

2022/12/04に公開

本記事は以下の4日目のエントリです。

今年の8月末に突然、Cloudfront + S3 でWebサイトのインフラ構築を急ぎやってほしい、という案件がありまして、ちょうどそのころGAとなった cdktf [4][5] でやってみるか、と思い立ちやり切ったときのことを書き出してみます。

とはいうものの、実は当時 Cloudfront 何もワカラナイ状態だったので、自分の sanbox 環境に↓のサンプルを作って HCL だとどういう設定なの?を terraformer で import して把握することをまずやりました。「Launch on AWS」べんり。
https://github.com/aws-samples/amazon-cloudfront-secure-static-site

HCL での感じを掴んだ次にやったことは TypeScript でどう書けばいいのかな?だったと思うのですが、そのあたりはその頃覚えたてだった google/zx を利用したスクリプト [6] を眺めてなんとなく書き始めました。

これくらいの "1年生" 状態でもやりきれたのは、↓の契機がありました。運命。Twitterのスレッド追うと Spike の Scrap も残ってて懐かしい。
https://twitter.com/sogaoh/status/1566000389496811520

GA時点では 0.12 でしたが、今は 0.14 まで上がってますね。

PreRquirements

当時書いた README の抜粋です。こういう準備をしてました。

コード紹介

おさらいで自分の環境に適用したコードを一部ご紹介します。

package.json
  • "plan": "cdktf diff",
  • "apply": "cdktf deploy",

は追加しました。


{
  "name": "2_main",
  "version": "1.0.0",
  "main": "main.js",
  "types": "main.ts",
  "license": "MPL-2.0",
  "private": true,
  "scripts": {
    "get": "cdktf get",
    "plan": "cdktf diff",
    "apply": "cdktf deploy",
    "build": "tsc",
    "synth": "cdktf synth",
    "compile": "tsc --pretty",
    "watch": "tsc -w",
    "test": "jest",
    "test:watch": "jest --watch",
    "upgrade": "npm i cdktf@latest cdktf-cli@latest",
    "upgrade:next": "npm i cdktf@next cdktf-cli@next"
  },
  "engines": {
    "node": ">=14.0"
  },
  "dependencies": {
    "@cdktf/provider-aws": "^9.0.37",
    "cdktf": "^0.12.2",
    "constructs": "^10.1.109",
    "dotenv": "^16.0.2"
  },
  "devDependencies": {
    "@types/jest": "^29.0.3",
    "@types/node": "^18.7.18",
    "jest": "^29.0.3",
    "ts-jest": "^29.0.1",
    "ts-node": "^10.9.1",
    "typescript": "^4.8.3"
  }
}
tsconfig.json

たしかデフォルトで作られるものからいじってないと思います


{
  "compilerOptions": {
    "alwaysStrict": true,
    "charset": "utf8",
    "declaration": true,
    "experimentalDecorators": true,
    "inlineSourceMap": true,
    "inlineSources": true,
    "lib": [
      "es2018"
    ],
    "module": "CommonJS",
    "noEmitOnError": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "resolveJsonModule": true,
    "strict": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": true,
    "stripInternal": true,
    "target": "ES2018",
    "incremental": true,
    "skipLibCheck": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "node_modules",
    "cdktf.out"
  ]
}
cdktf.json

s3cloudfront の公式モジュールを利用しました。
ここがぼくの cdktf を使ってみようと思ったきっかけ。
自作モジュールもいけるらしいです。(まだ試してない)


{
  "language": "typescript",
  "app": "npx ts-node main.ts",
  "projectId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "sendCrashReports": "false",
  "terraformProviders": [],
  "terraformModules": [
    {
      "name": "s3",
      "source": "terraform-aws-modules/s3-bucket/aws"
    },
    {
      "name": "cloudfront",
      "source": "terraform-aws-modules/cloudfront/aws"
    }
  ],
  "context": {
    "excludeStackIdFromLogicalIds": "true",
    "allowSepCharsInLogicalIds": "true"
  }
}

globals.d.ts

汎用的にしたくて、このあたり変えれば使いまわせるよな、というところをまとめました。
.env に正しく書けばいけると思います。(ぼくは実際にいけました...)


declare namespace NodeJS {
  interface ProcessEnv {
    readonly AWS_PROFILE: string
    readonly AWS_REGION: string
    readonly DOMAIN: string
    readonly PUBLIC_DNS: string
    readonly ZONE_ID: string
    readonly BACKEND_BUCKET: string
    readonly BACKEND_KEY: string
    readonly OWNER_ACCOUNT_IDENTIFIER: string
    readonly LOG_BUCKET_NAME: string
    readonly SITE_BUCKET_NAME: string
  }
}
functions/index.js

index.html がなかったらつけてあげるスクリプトです。


//refs https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/example-function-add-index.html
function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // Check whether the URI is missing a file name.
  if (uri.endsWith('/')) {
    request.uri += 'index.html';
  }
  // Check whether the URI is missing a file extension.
  else if (!uri.includes('.')) {
    request.uri += '/index.html';
  }

  return request;
}

main.tf は細かめに

連結すればちゃんと動いてたソースです。
補足しておきますと、terraform module を使用する場合なのかそれに限らないのか未確認ですが、

  • 1階層目はキャメルケース
  • 2階層目以降はスネークケース

で定義するのがポイントみたいです。

AwsProvider・S3Backend と ACM Certificate パート

ACM Certificate の region を us-west-1 (バージニア) にするのがけっこう「ミソ」

パート1

import * as dotenv from "dotenv";
import { Construct } from "constructs";
import { App, S3Backend, TerraformOutput, TerraformStack } from "cdktf";
import { AwsProvider, acm, route53, cloudfront, s3, iam } from "@cdktf/provider-aws";
import { S3 } from "./.gen/modules/s3";
import { Cloudfront } from "./.gen/modules/cloudfront";
import * as fs from "fs";

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

    dotenv.config()

    // Aws Provider
    new AwsProvider(this, "aws", {
      region: `${process.env.AWS_REGION}`,
    });

    //S3 Backend - https://www.terraform.io/docs/backends/types/s3.html
    new S3Backend(this, {
      bucket: `${process.env.BACKEND_BUCKET}`,
      key: `${process.env.BACKEND_KEY}`,
      region: `${process.env.AWS_REGION}`,
      profile: `${process.env.AWS_PROFILE}`
    });


    //-----------------------------------------------
    // ACM Certificate
    //-----------------------------------------------
    const provider = new AwsProvider(this, "aws.route53", {
      region: "us-east-1",
      alias: "route53",
    })
    const acmCert = new acm.AcmCertificate(this, "acmCert", {
      domainName: `${process.env.DOMAIN}`,
      options: {
        certificateTransparencyLoggingPreference: "ENABLED"
      },
      subjectAlternativeNames: [`*.${process.env.DOMAIN}`],
      validationMethod: "DNS",
      provider,
    })
    new TerraformOutput(this, "ACM_Certificate_Arn", {
      value: acmCert.arn
    })

    const acmCertValidateRecord = new route53.Route53Record(this, "acmCertValidateRecord", {
      name: acmCert.domainValidationOptions.get(0).resourceRecordName,
      type: acmCert.domainValidationOptions.get(0).resourceRecordType,
      records: [acmCert.domainValidationOptions.get(0).resourceRecordValue],
      zoneId: `${process.env.ZONE_ID}`,
      ttl: 60,
      allowOverwrite: true,
    });
    new acm.AcmCertificateValidation(this, "acmCertValidation", {
      certificateArn: acmCert.arn,
      validationRecordFqdns: [acmCertValidateRecord.fqdn],
      provider,
    });

Log Bucket パート

アクセス許可のところのコード化はよい勉強になりました

パート2

    //-----------------------------------------------
    // Log Bucket
    //-----------------------------------------------
    const logBucketName = `${process.env.LOG_BUCKET_NAME}`
    const logBucket = new S3(this, logBucketName, {
      bucket: logBucketName,
      forceDestroy: false,

      //プロパティ > バケットのバージョニング
      versioning: {
        "enabled": "true",
        "mfa_delete": "false"
      },
      //プロパティ > デフォルトの暗号化
      serverSideEncryptionConfiguration: {
        rule: {
          apply_server_side_encryption_by_default: {
            "sse_algorithm": "AES256"
          }
        }
      },

      //アクセス許可 > ブロックパブリックアクセス (バケット設定)
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true,
      //アクセス許可 > オブジェクト所有者
      objectOwnership: "ObjectWriter",
      //アクセス許可 > アクセスコントロールリスト (ACL)
      grant: [
        {
          //バケット所有者 AWSアカウント
          //refs https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/finding-canonical-user-id.html
          id: `${process.env.OWNER_ACCOUNT_IDENTIFIER}`,
          type: "CanonicalUser",
          permission: "FULL_CONTROL"
        },
        {
          //外部アカウント
          id: "c4c1ede66af53448b93c283ce9448c4ba468c9432aa01d700d3878632f77d2d0",
          type: "CanonicalUser",
          permission: "FULL_CONTROL"
        },
        {
          //S3 ログ配信グループ バケットACL
          uri: "http://acs.amazonaws.com/groups/s3/LogDelivery",
          type: "Group",
          permission: "READ_ACP"
        },
        {
          //S3 ログ配信グループ オブジェクト
          uri: "http://acs.amazonaws.com/groups/s3/LogDelivery",
          type: "Group",
          permission: "WRITE",
        }
      ],

      //管理 > ライフサイクルルール
      lifecycleRule: {
        rule: {
          //非現行バージョンのアクション
          id: "noncurrent_version_delete_rule",
          enabled: true,
          noncurrent_version_transition: [
            {
              //30日で 標準IA に移動
              "days"         : 30,
              "storage_class": "STANDARD_IA"
            }
          ],
          noncurrent_version_expiration: [
            {
              //100日で削除
              "days": 100
            }
          ]
        }
      },
    })
    new TerraformOutput(this, "S3_WebSiteLogBucket_Id", {
      value: logBucket.s3BucketIdOutput
    })
    new TerraformOutput(this, "S3_WebSiteLogBucket_DomainName", {
      value: logBucket.s3BucketBucketDomainNameOutput
    })
    new TerraformOutput(this, "S3_WebSiteLogBucket_RegionalDomainName", {
      value: logBucket.s3BucketBucketRegionalDomainNameOutput
    })

CloudFront OAI と WebSite Bucket パート

「書き順」の事情でここに来た Cloudfront OAI パート
WebSite Bucket パートには Bucket Policy Document の記述もあります

パート3

    //---------------------------------------------------------
    // CloudFront OriginAccessIdentity (use Website Bucket)
    //---------------------------------------------------------
    const originAccessIdentity = new cloudfront.CloudfrontOriginAccessIdentity(this, "cf_oai", {
      comment: "cloudfront OriginAccessIdentiry"
    })
    new TerraformOutput(this, "Cloudfront_OriginAccessIdentity_Id", {
      value: originAccessIdentity.id
    })
    new TerraformOutput(this, "Cloudfront_OriginAccessIdentity_Path", {
      value: originAccessIdentity.cloudfrontAccessIdentityPath
    })
    new TerraformOutput(this, "Cloudfront_OriginAccessIdentity_Arn", {
      value: originAccessIdentity.iamArn
    })


    //*********************************/
    // WebSite Bucket Name
    const webSiteBucketName = `${process.env.SITE_BUCKET_NAME}`
    // Bucket Policy Document
    const websiteBucketPolicyDocumentName = `${webSiteBucketName}-bucket-policy-document`
    const websiteBucketPolicyDocument = new iam.DataAwsIamPolicyDocument(this, websiteBucketPolicyDocumentName, {
      version: "2012-10-17",
      statement: [
        {
          effect: "Allow",
          principals: [{
            type: "AWS",
            identifiers: [
              `arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${originAccessIdentity.id}`
            ]
          }],
          actions: [
            "s3:GetObject"
          ],
          resources: [
            `arn:aws:s3:::${webSiteBucketName}/*`
          ]
        },
        {
          effect: "Allow",
          principals: [{
            type: "AWS",
            identifiers: [
              `arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${originAccessIdentity.id}`
            ]
          }],
          actions: [
            "s3:ListBucket"
          ],
          resources: [
            `arn:aws:s3:::${webSiteBucketName}`
          ]
        }
      ]
    })
    // Bucket Policy
    const websiteBucketPolicyName = `${webSiteBucketName}-bucket-policy`
    const websiteBucketPolicy = new s3.S3BucketPolicy(this, websiteBucketPolicyName, {
      bucket: webSiteBucketName,
      policy: websiteBucketPolicyDocument.json
    })
    new TerraformOutput(this, "S3_WebSiteBucket_PolicyId", {
      value: websiteBucketPolicy.id
    })

    //-----------------------------------------------
    // WebSite Bucket
    //-----------------------------------------------
    const webSiteBucket = new S3(this, webSiteBucketName, {
      bucket: webSiteBucketName,
      forceDestroy: false,

      //プロパティ > バケットのバージョニング
      versioning: {
        "enabled": "true",
        "mfa_delete": "false"
      },
      //プロパティ > デフォルトの暗号化
      serverSideEncryptionConfiguration: {
        rule: {
          apply_server_side_encryption_by_default: {
            "sse_algorithm": "AES256"
          }
        }
      },
      //プロパティ > サーバーアクセスのログ記録
      logging: {
        target_bucket: logBucket.s3BucketIdOutput,
        target_prefix: "origin/"
      },

      //プロパティ > 静的ウェブサイトホスティング
      website: {
        redirect_all_requests_to: {
          host_name: `${process.env.PUBLIC_DNS}`,
          protocol: "https"
        }
      },

      //アクセス許可 > ブロックパブリックアクセス (バケット設定)
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true,
      //アクセス許可 > バケットポリシー => websiteBucketPolicyDocument.json
      //アクセス許可 > オブジェクト所有者
      objectOwnership: "ObjectWriter",
      //アクセス許可 > アクセスコントロールリスト (ACL)
      grant: [
        {
          //バケット所有者 AWSアカウント
          //refs https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/finding-canonical-user-id.html
          id: `${process.env.OWNER_ACCOUNT_IDENTIFIER}`,
          type: "CanonicalUser",
          permission: "FULL_CONTROL"
        }
      ],

      //管理 > ライフサイクルルール
      lifecycleRule: {
      }
    })
    new TerraformOutput(this, "S3_WebSiteBucket_Id", {
      value: webSiteBucket.s3BucketIdOutput
    })
    new TerraformOutput(this, "S3_WebSiteBucket_RegionalDomainName", {
      value: webSiteBucket.s3BucketBucketRegionalDomainNameOutput
    })

Cloudfront パート

CloudfrontFunction の呼び出しはどこで情報見つけたのか・・・忘れました。
Cloudfront のHCL設定がマネコンでどのタブのどの項目かはなめるように見回して覚えました。(コード化したのでもう覚えてない。覚えられない。)

パート4

    //-----------------------------------------------
    // CloudFront
    //-----------------------------------------------
    // Security Header
    const securityHeaderPolicyName = "cloudfront_response_headers_policy"
    const securityHeaderPolicy = new cloudfront.CloudfrontResponseHeadersPolicy(this, "cf_rhp", {
      name: securityHeaderPolicyName,
      securityHeadersConfig: {
        contentTypeOptions: {
          override: true,
        },
        frameOptions: {
          frameOption: "DENY",
          override: true,
        },
        referrerPolicy: {
          override: true,
          referrerPolicy: "same-origin",
        },
        strictTransportSecurity: {
          accessControlMaxAgeSec: 15768000,
          includeSubdomains: false,
          override: true,
          preload: false,
        },
        xssProtection: {
          modeBlock: true,
          override: true,
          protection: true,
          reportUri: "",
        },
      },
    })
    new TerraformOutput(this, "Cloudfront_SecurityHeader_Id", {
      value: securityHeaderPolicy.id
    })

    const cloudfrontCachePolicyName = "cloudfront_cache_policy"
    const cloudfrontCachePolicy = new cloudfront.CloudfrontCachePolicy(this, "cf_cp", {
      name: cloudfrontCachePolicyName,
      defaultTtl: 86400,
      maxTtl: 31536000,
      minTtl: 0,
      parametersInCacheKeyAndForwardedToOrigin: {
        cookiesConfig: {
          cookieBehavior: "none"
        },
        headersConfig: {
          headerBehavior: "whitelist",
          headers: {
            items: ["Origin"]
          }
        },
        queryStringsConfig: {
          queryStringBehavior: "none"
        },
        enableAcceptEncodingGzip: true,
        enableAcceptEncodingBrotli: true
      }
    })
    new TerraformOutput(this, "Cloudfront_CachePolicy_Id", {
      value: cloudfrontCachePolicy.id
    })

    const cloudfrontFunctionName = "cloudfront_function"
    const cloudfrontFunction = new cloudfront.CloudfrontFunction(this, "cf_fn", {
      name: cloudfrontFunctionName,
      runtime: "cloudfront-js-1.0",
      code: fs.readFileSync("./functions/index.js").toString()
    })
    new TerraformOutput(this, "Cloudfront_CustomFunction_Arn", {
      value: cloudfrontFunction.arn
    })

    const publicDns = `${process.env.PUBLIC_DNS}`
    const cloudfrontDistributionName = "cloudfront-distribution"
    const cloudfrontDistribution = new Cloudfront(this, cloudfrontDistributionName, {
      enabled: true,
      isIpv6Enabled: true,

      //WAF ACL (メンテナンス設定時に適宜利用可能)
      //webAclId: `${process.env.WAF_WEB_ACL_ARN}`,
      webAclId: "",

      //一般 > 設定
      aliases: [
        publicDns,
        `www.${publicDns}`
      ],
      priceClass: "PriceClass_All",
      httpVersion: "http2",
      viewerCertificate: {
        acm_certificate_arn: acmCert.arn,
        cloudfront_default_certificate: false,
        minimum_protocol_version: "TLSv1.2_2021",
        ssl_support_method: "sni-only"
      },
      loggingConfig: {
        bucket: logBucket.s3BucketBucketDomainNameOutput,
        include_cookies: false,
        prefix: "cdn/"
      },
      defaultRootObject: "index.html",

      //オリジン
      origin: [
        {
          origin_id: `S3-${cloudfrontDistributionName}`,
          domain_name: webSiteBucket.s3BucketBucketRegionalDomainNameOutput,
          s3_origin_config: {
            cloudfront_access_identity_path: originAccessIdentity.cloudfrontAccessIdentityPath
          },
          connection_attempts: 3,
          connection_timeout: 10
        }
      ],

      //ビヘイビア
      defaultCacheBehavior: {
        target_origin_id: `S3-${cloudfrontDistributionName}`,
        cache_policy_id: cloudfrontCachePolicy.id,
        compress: true,
        viewer_protocol_policy: "redirect-to-https",
        allowed_methods: ["GET","HEAD","OPTIONS"],
        cached_methods: ["HEAD", "GET"],
        use_forwarded_values: false,
        response_headers_policy_id: securityHeaderPolicy.id,
        smooth_streaming: false,
        function_association: {
          "viewer-request": {
            function_arn: cloudfrontFunction.arn
          }
        }
      },

      //エラーページ
      customErrorResponse: [
        {
          error_code: 403,
          error_caching_minttl: 60,
          response_code: 403,
          response_page_path: "/403.html"
        },
        {
          error_code: 404,
          error_caching_minttl: 60,
          response_code: 404,
          response_page_path: "/404.html"
        },
      ],

      //地理的制限
      geoRestriction: {
        restriction_type: "none",
      },

    })
    new TerraformOutput(this, "Cloudfront_Distribution_Id", {
      value: cloudfrontDistribution.cloudfrontDistributionIdOutput
    })
    new TerraformOutput(this, "Cloudfront_Distribution_DoaminName", {
      value: cloudfrontDistribution.cloudfrontDistributionDomainNameOutput
    })

Route53 パート と app.synth();呼び出し

DNS Aレコードのパートです。上の方にCNAMEレコード(AcmCertificateValidation)の定義もありますが、あんなにも上にあるのは ACM Certificate 作る際の「順序事情」のためだった気がします。
www なしとありのレコードを同時に定義してるところがたぶんレアです(そうでもないかも)。
あと、app.synth(); は「おまじない」というのがぼくの現在の理解です。

パート5

    //-----------------------------------------------
    // Route53 DNS (A Record)
    //-----------------------------------------------
    const dnsARecord = new route53.Route53Record(this, "dns-a-record", {
      name: publicDns,
      type: "A",
      zoneId: `${process.env.ZONE_ID}`,
      alias: [
        {
          name: cloudfrontDistribution.cloudfrontDistributionDomainNameOutput,
          zoneId: cloudfrontDistribution.cloudfrontDistributionHostedZoneIdOutput,
          evaluateTargetHealth: true,
        },
      ],
    })
    new TerraformOutput(this, "A_Record_of_Route53_DNS", {
      value: dnsARecord.name
    })
    const dnsARecordWWW = new route53.Route53Record(this, "dns-a-record-www", {
      name: `www.${publicDns}`,
      type: "A",
      zoneId: `${process.env.ZONE_ID}`,
      alias: [
        {
          name: cloudfrontDistribution.cloudfrontDistributionDomainNameOutput,
          zoneId: cloudfrontDistribution.cloudfrontDistributionHostedZoneIdOutput,
          evaluateTargetHealth: false,
        },
      ],
    })
    new TerraformOutput(this, "A_Record_of_Route53_WWW_DNS", {
      value: dnsARecordWWW.name
    })
  }
}

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

適用方法

README に書いて渡したやつです
cd /path/to/..ts-cdktf

node バージョン設定

nodenv local 16.17.0
node -v

依存関係インストール

bun install
bun run get

plan

bun run plan  # cdktf diff

apply

bun run apply  # cdktf deploy

さいごに

いかがでしたでしょうか?

unit test も書けるようだし、来年はもう少し踏み込んで使っていければなあ、と思っています。
あと、main.tf を適度に分割するのも覚えたいです。

おまけで、tfstate と向き合った時のつぶやきを置いておきます。

明日5日目の予定は以下になっています。お楽しみに。

脚注
  1. 3日目は @roki18d さんの Terragrunt 導入で Terraform コードを DRY にしてみた でした。 ↩︎

  2. 3日目は ・・・12/4 10:30 時点で未定です。 ↩︎

  3. 3日目は @nkmrkz さんの ServerlessLandのテンプレートからIoTCoreにMQTTメッセージを連携するPOST APIをさっと作ってみる でした。 ↩︎

  4. CDK for Terraform Is Now Generally Available ↩︎

  5. CDK for Terraform on AWS 一般提供 (GA) のお知らせ ↩︎

  6. refs zx で Makefile をリプレースする試みで得たあれこれ ↩︎

Discussion