🎤

CDKでデプロイ先を量産したり環境ごとの差をどうにか埋めたりした話

2022/04/14に公開

本記事 は 2022-04-09 の AWS CDK Conference Japan にて同タイトルで登壇したときの資料とした Notion から Markdown エクスポートしたものをコピペしたものです。
掲載しているコードは発表当時のものになります。
スライドの代わりに Notion を使う試みをしました。見出しの頭の数字がスライドのページ相当のつもりです。

AWS CDK Conference Japan (2022/04/09 13:00〜)

1. CDKでデプロイ先を量産したり環境ごとの差をどうにか埋めたりした話

  • ちゃちい (@chatii)

2. 自己紹介

  • PHPer
    • PHPを書いていると生き生きとしま
  • 受託開発の会社に勤めてたりフリーだったり自分の会社で仕事請けたりしてます
  • 古スタックエンジニア
  • (本日開催の) PHPerKaigi 2022 へ行きます

3. 最近の発表活動など

  • PHPerKaigi 2021
    • LAMPをこじらせてサーバーレスに乗り遅れたPHPerがLambdaに入門してみる
      • ECS/Fargate さえ飛び越して Lambda を使った話
      • 収録登壇
  • PHPerKaigi 2022 (本日から開催)
    • 日本語で開発してラクしよう、そして効率良く経験値をもらおう
      • パンフレット掲載原稿
  • ライブでの登壇ははじめてなのでお手柔らかにおねがいします… 🙇

4. ここから先はフィクションです

  • インフラ担当はいない
    • バックエンドエンジニア視点の物語です
    • インフラが本業では無いところ、いかにインフラ構築を乗り越えたかの話
      • 「あるある〜」があったら教えてください
      • 「あ〜それはイマイチかも〜」があったら教えてください
  • わかろうとしている人が案件ごとに CloudFormation 使ったり Terraform 使ったりカオス
    • どうにか統一しなければならないタイミング
    • できる限り、習得・運用が低負荷になるようにしたい

5. 新しいプロジェクトがはじまるよ!

  • せっかくだからちゃんと IaC したい(当社比)
  • 個人的に Terraform 記法が苦手
  • 個人的に YAML が苦手
  • AWS が大前提
  • おやこんなところに CDK が

6. AWS を使い倒したい (当社比)

  • Web Framework の認証は使いたくない(フレームワークを変える可能性) → Cognito
  • インスタンスを管理したくない → Lambda (検証の結果が前述の登壇)
  • RDS でさえ気にしたくない → Aurora Serverless (v2はどこいったんですか)
  • CORS で引っかかりたくない → CloudFront の後ろに S3 と API Gateway(HTTP API)

7. 本番もステージングも作りたい

  • やっと本題です
  • --context オプション で値を渡せることを知りました
  • でもアプリケーションを動かすにはやや多い設定が必要では…?
  • cdk.json"context" を知りました
  • app.node.tryGetContext('hoge') した場合、以下の優先順位で値が取得される
    1. 現在の AWS アカウントから自動的に
    2. cdk コマンドで設定した --context オプションの値
    3. プロジェクト内の cdk.json ファイルの "context" キーの値
    4. 実行ユーザーの ~/.cdk.json ファイルの "context" キーの値
    5. CDK のコード内で記述した cunstruct.node.setContext() メソッドを使ってセットした値
  • ひらめき
    • cdk.json は JSON なんだからオブジェクトとかいけるんじゃない?」
    • いけました

8. コンテキストにオブジェクトを入れた例

{
	"context": {
		"param1:" "fugafuga",
		"param2": {
			"value": 1
		}
  }
}
app.node.tryGetContext('param1')
// "fugafuga"
app.node.tryGetContext('param2')
// {value: 1}
  • 「なら、”環境” によって取り出すコンテキストを切り替えればいいんじゃ??」

  • なお cdk.context.json については CDK が管理するので触らない

    Context values are managed by the AWS CDK and its constructs, including constructs you may write. In general, you should not add or change context values by manually editing files. It can be useful to review cdk.context.json
     to see what values are being cached.

9. 環境を指定してコンテキストを切り替える例

{
	"context": {
		"production": {
			"fqdn": "hoge.example.com"
		},
		"staging": {
			"fqdn": "stg.hoge.example.dev"
		}
}
npx cdk deploy --all --context stage=staging
const stage = app.node.tryGetContext('stage') // staging
const context = app.node.tryGetContext(stage) // {fqdn: "stg.hoge.example.dev"}
  • stage コンテキストは cdk.json には書かないことで、CLIで指定することを必須にしています

10. これでかつる

環境ごとに切り替えたい値の例

  • 別々の AWS アカウントが用意できる場合の アカウントID
    • AWS アカウントが複数用意できない場合についてのお話しはこの後で
  • CloudFront などの A/AAAA レコードを設定するための ホストゾーンID
    • ホストゾーン自体を CDK で作らないようにするのは destroy が恐いので念のため
  • Lambda の メモリサイズ, Aurora Serverless の キャパシティ自動停止までの時間
    • 本番は潤沢にしたいとか
  • ECR などの Docker リポジトリのアドレス
    • ECR は手動で作成しておき、デプロイスクリプトは別途用意した
  • アプリケーション実行環境に渡す環境変数の値
    • .env ファイルは死すべし

11. AWS アカウントは環境ごとに用意しよう

  • せめて本番とステージングはまったく別にしたほうがいいのでは
  • でも開発者が使うアカウントを個別に用意するのはなかなかしんどい
    • ぼくはあまり詳しくないですが軽率にアカウントを発行できない場合も

12. AWS アカウントを個別に用意できない場合

  • 開発者は1つのアカウントを共用する場合
  • 問題になるのは CloudFormation のスタックが同じ名前になってしまう
    • A さんがデプロイした InfraStack と B さんがデプロイした InfraStack が上書きし合う
  • CDK でプログラム書こう!

13. スタック名を重複させない技

const stage = app.node.tryGetContext('stage')
const infraStack = new InfraStack(app, `InfraStack-${stage}`, props)
  • InfraStack-productionInfraStack-staging などになる
  • ということは開発者の名前をステージ名としてもいいのでは?

14. コンテキスト切替えを応用したスタック名の重複回避

{
	"context": {
		"production": {
			"fqdn": "hoge.example.com"
		},
		"staging": {
			"fqdn": "stg.hoge.example.dev"
		},
		"a-san": {
			"fqdn": "a-san.dev.hoge.example.dev"
    },
		"b-san": {
			"fqdn": "b-san.dev.hoge.example.dev"
		}
}
  • 同じAWSアカウント内でも InfraStack-a-sanInfraStack-b-san などになり競合しなくなる
  • 最初から スタック名が重複しないようにする前提 でCDKを組んだ方が不幸にみまわれない

15. より実践的な実装例

個人開発で実際に作ったものを紹介します

portal というフロントエンド用の設定 (CloudFront + S3) と bot というバックエンド用の設定 (API Gateway + Lambda)

my-stack.ts

CDKの Stack を直接使わずに、extends した自前の Stack クラスを作成、スタックIDを動的に作らせる

また、コンテキスト取り扱いクラスを全てのスタックで使うようにインターフェースを用意

import {NestedStackProps, Stack, StackProps} from 'aws-cdk-lib'
import {Construct} from 'constructs'
import {Context, EnvContext} from './context'

export interface MyStackProps extends StackProps {
    env: EnvContext,
    context: Context,
}

export interface MyNestedStackProps extends MyStackProps, NestedStackProps {
}

export class MyStack extends Stack {
    constructor(scope: Construct, id: string, props: MyStackProps) {
				// ここでスタックIDに「サフィックス」を付けている
        id = `${id}-${props.context.stage}`
        super(scope, id, props)
    }
}

cdk.json

{
	"context": {
		"my": {
			"test": {
				"env": { // `aws-cdk-lib の Environment に詰め込められる`
					"account": "XXXXXXXXXXXX",
					"region": "ap-northeast-1"
				},
				"hostedZoneName": "phperroom.in", // 作成済みのホストゾーン名
				"portal": {
					"subDomain": "test", // CloudFront へのサブドメイン名
					"resourceBucketName": "portal-resource" // S3 バケット名
				},
				"bot": {
					"subDomain": "bot.test" // API Gateway へのサブドメイン名
				}
			},
			"production": {
				"env": {
					"account": "XXXXXXXXXXXX",
					"region": "ap-northeast-1"
				},
				"hostedZoneName": "phperroom.in",
				"portal": {
					"subDomain": "", // サブドメイン名ナシも可
					"resourceBucketName": "portal-resource"
				},
				"bot": {
					"subDomain": "bot"
				}
			}
		}
}

コンテキストを扱う独自のクラス context.ts

import {Node} from 'constructs'
import {Environment} from 'aws-cdk-lib'

export type StageType =
    'test' |
    'staging' |
    'production'

interface ContextObjectInterface {
    env: EnvContext,
    portal: PortalContext,
    bot: BotContext,
    hostedZoneName: string,
}

export interface EnvContext extends Environment {
    account: string,
    region: string,
}

interface ApplicationContext {
    subDomain: string,
}

interface PortalContext extends ApplicationContext {
    subDomain: string,
    resourceBucketName: string,
}

interface BotContext extends ApplicationContext {
    subDomain: string,
}

export class Context {
    private _container: ContextObjectInterface

    public stage: StageType
    public appName: string
    public hostedZoneName: string
    public env: EnvContext // Stack が解釈する env
    public portal: PortalContext // フロントエンド用
    public bot: BotContext // バックエンド用

    constructor(stage: StageType, node: Node) {
        this.stage = stage
        const my = node.tryGetContext('my')
        this.appName = my.appName
        this._container = my[stage] as ContextObjectInterface

        this.hostedZoneName = this._container.hostedZoneName
        this.env = this._container.env as EnvContext
        this.portal = this._container.portal as PortalContext
        this.bot = this._container.bot as BotContext
    }

    public getFQDN(appContext: ApplicationContext): string {
        if (appContext.subDomain === '') {
            return this._container.hostedZoneName
        }

        return [
            appContext.subDomain,
            this._container.hostedZoneName,
        ].join('.')
    }

    public getS3BucketName(name: string): string {
        return `${this.appName}-${this.stage}-${name}`
    }
}

これらを使った bin/phperroom-in.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { PhperroomInStack as PHPerRoomInStack } from '../lib/phperroom-in-stack';
import {Context, EnvContext, StageType} from '../lib/context'

const app = new cdk.App();
// StageType で受け取れる stage の値を絞ってる
const stage = app.node.tryGetContext('stage') as StageType
const context = new Context(stage, app.node)
const env: EnvContext = context.env ?? {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
}

new PHPerRoomInStack(app, 'PHPerRoomInStack', {
    env,
    context,
});

スタックでは

ホストゾーンを取得し、CloudFrontやAPI Gateway用に証明書を作るところ

cdk.json に用意した hostedZoneName を使います

const hostedZone = HostedZone.fromLookup(this, 'RootHostedZone', {
    domainName: context.hostedZoneName,
})

const portalCertificate = new DnsValidatedCertificate(this, 'PortalCertificate', {
    hostedZone,
    domainName: context.getFQDN(context.portal),
    region: 'us-east-1',
})
const botCertificate = new DnsValidatedCertificate(this, 'BotCertificate', {
    hostedZone,
    domainName: context.getFQDN(context.bot),
})

16. コンテキスト取り回しクラスを使うメリット

  • フロントエンド・バックエンド、といった領域でオブジェクトを自由に区切り、さらにクラスを用意することで意味合いを保つ
  • interface や type を使って不正な値の混入を防いだり補完が使えるように
  • スタックIDと同様に、重複したら困る「S3バケット名」を生成するメソッドを用意する
  • アプリケーションエンジニアが気にするべきポイントが集中できる
    • インフラ構成のコードを汚さないで欲しい
  • TypeScript力が付く(ほんとに?)

17. CDK でコンテキスト扱うようにしてよかったこと

念のため再度 「フィクションです」

  • 「顧客のオンプレとDirectConnectした先のDBに繋ぎたい」
    • 本番環境の場合を if 文で判定し vpc.enableVpnGateway したりルーティングテーブル追加したり
    • DirectConnect の疎通は手動で行ったものの、作業内容を該当箇所のソースにコメントで残した
  • 客「本番用アカウントとそれ以外用アカウントです」
    • スタック重複回避が活きた

こんなのも

const bucket = new s3.Bucket(this, 'MyBucket', {
	// 略
	removalPolicy: context.isProduction() ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
	autoDeleteObjects: !context.isProduction(), // context.isDevelopment() とか
}

18. さらに発展系

  • GitHubへのプッシュ時にブランチ名を元に QA環境をポコポコ生やせそう
    • ホスト名として成り立つブランチ名じゃないとダメだけど replace() とかで回避はできる
    • QA環境がデプロイできたら Slack に通知するとか
    • (そんな人数・規模じゃないのでやってないです)

19. おしまい

  • 登壇が決まった時点では CDK v1 しか使ってませんでした

    本イベントではCDKv2をメインテーマに

  • 「やべえよ…やべえよ…」

    • 慌てて既存プロジェクトのマイグレートを実施
    • ゆっきーさんありがとう
  • cdk watch もできました

    • Lambda Layer (Bref.sh) で PHP を使っても watch 効いてて嬉しい
  • 登壇を薦めてくれた吉田さんに感謝

  • ご静聴ありがとうございました

Discussion