TypeScript で cdktf
本記事は以下の4日目のエントリです。
- terraform Advent Calendar 2022 [1]
- TypeScript Advent Calendar 2022 [2]
- AWS CDK Advent Calendar 2022 [3]
今年の8月末に突然、Cloudfront + S3 でWebサイトのインフラ構築を急ぎやってほしい、という案件がありまして、ちょうどそのころGAとなった cdktf [4][5] でやってみるか、と思い立ちやり切ったときのことを書き出してみます。
とはいうものの、実は当時 Cloudfront 何もワカラナイ状態だったので、自分の sanbox 環境に↓のサンプルを作って HCL だとどういう設定なの?を terraformer で import して把握することをまずやりました。「Launch on AWS」べんり。
HCL での感じを掴んだ次にやったことは TypeScript でどう書けばいいのかな?だったと思うのですが、そのあたりはその頃覚えたてだった google/zx を利用したスクリプト [6] を眺めてなんとなく書き始めました。
これくらいの "1年生" 状態でもやりきれたのは、↓の契機がありました。運命。Twitterのスレッド追うと Spike の Scrap も残ってて懐かしい。
GA時点では 0.12 でしたが、今は 0.14 まで上がってますね。
PreRquirements
当時書いた README の抜粋です。こういう準備をしてました。
- install
コード紹介
おさらいで自分の環境に適用したコードを一部ご紹介します。
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
s3 と cloudfront の公式モジュールを利用しました。
ここがぼくの 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日目の予定は以下になっています。お楽しみに。
-
TypeScript : by @2nofa11 さん
-
3日目は @roki18d さんの Terragrunt 導入で Terraform コードを DRY にしてみた でした。 ↩︎
-
3日目は ・・・12/4 10:30 時点で未定です。 ↩︎
-
3日目は @nkmrkz さんの ServerlessLandのテンプレートからIoTCoreにMQTTメッセージを連携するPOST APIをさっと作ってみる でした。 ↩︎
Discussion