動機
AWS CDKで遊んでいたのはいいのですが、ぼちぼち実運用が気になってきました。
ネイティブなCloud Formationでは1つのtemplate.yamlから複数のスタックを作成して3ランドスケープ(開発・検証・本番)を実現していましたが、AWS CDKではどうするんだろう?ということでサクッとやってみました。
免責事項
「こんな感じでどうだろう?」という感じでざっくり試してみた程度です。
こういった管理を推奨するわけでも無いですし、ベストプラクティス的なもの知っているわけでもありません。
むしろベストプラクティス的なものがあってそれを知っている方いらっしゃいましたらコメント等で教えてください。。。
やりたいこと
1スタックの構成
1 apigateway + 1 lambdaのシンプル構成(ただの検証なので)
ただし、 環境ごとに異なる設定値(環境変数)を渡して、その反映がわかりやすいような戻り値にする。
ランドスケープ
- dev(開発)
- qa(検証)
- prod(本番)
の3ランドスケープ。
ただし、dev以外の2環境はサービスの呼び出しにAPI Keyが必須とする。
当然、API Keyは環境を跨いで使い回しは出来ない。qaはqa用の、prodはprod用のAPI Keyを用いる。
やってみた
プロジェクトの初期化
$ mkdir switch-context-inspection
$ cd switch-context-inspection
$ cdk init --language typescript
検証用Lambdaの定義
lambda用のtypeのインストール
$ npm install --save-dev @types/aws-lambda
関数コードの作成
$ mkdir -p lambda/greeting
$ touch lambda/greeting/index.ts
import { APIGatewayProxyEvent, Context, APIGatewayProxyResult} from 'aws-lambda';
//環境変数から持ってくる。ランドスケープごとに値が異なる。
const MY_NAME = process.env.NAME;
export async function handler(event: APIGatewayProxyEvent, context: Context): Promise<APIGatewayProxyResult>{
//Lambda統合proxyを使いたいので、APIGatewayProxyResultを返す
return {
statusCode: 200,
body: JSON.stringify({
message: `Hi, I am ${MY_NAME}!`
})
}
}
何気なくlambda自体もTypeScriptで書いているが、別にjsやpythonで書いても一向に構わない。
TypeScriptで書く場合、上記の@type/aws-lambda
のように@type
はCDKのプロジェクト直下のnpmで管理しておくと、$ npm rub build
でlambdaとCDK両方が一括でビルド出来るので便利だと思う。
環境(ランドスケープ)定義の作成
今回は環境(ランドスケープ)名やそれに紐づく設定値をenvironment.tsというファイルを作って管理することにした。ここのやり方は色々あると思うので、一例としてみてください。
$ touch lib/environment.ts
/**
* 環境名の定義
*/
export enum Environments {
PROD = 'prod',
QA = 'qa',
DEV = 'dev'
};
/**
* 各環境に紐づく環境変数のinterface
*/
export interface EnvironmentVariables {
lambdaEnvironmentVariables: {[key: string]: any}, //lambdaに渡す環境変数
apiKeyRequired: boolean //API Keyが必要かどうか
};
/**
* 各環境ごとの具体的な設定値
*/
const EnvironmentVariablesSetting: {[key:string]: EnvironmentVariables} = {
[Environments.PROD] : {
lambdaEnvironmentVariables: {
NAME: "PROD SERVICE",
},
apiKeyRequired: true
},
[Environments.QA] : {
lambdaEnvironmentVariables: {
NAME: "QA SERVICE"
},
apiKeyRequired: true
},
[Environments.DEV] : {
lambdaEnvironmentVariables: {
NAME: "DEV SERVICE"
},
apiKeyRequired: false //dev環境はAPI Key不要
}
};
/**
* envに紐づく環境変数を返す
* @param env 取得したい対象の環境
* @return envに紐づく環境変数
*/
export function variablesOf(env: Environments): EnvironmentVariables{
return EnvironmentVariablesSetting[env];
}
スタック定義の編集
cdk init
した時に作成されたlib/switch-context-inspection-stack.tsを編集して作成したいスタックを定義する。
まずは必要なライブラリをnpm install
$ npm install --save @aws-cdk/aws-lambda aws-cdk/aws-apigateway
スタック定義を編集
import cdk = require('@aws-cdk/core');
import * as lambda from '@aws-cdk/aws-lambda';
import * as apigateway from '@aws-cdk/aws-apigateway';
import * as environment from './environment';
export class SwitchContextInspectionStack extends cdk.Stack {
/**
* 元(cdk.Stack)のコンストラクタを拡張して
* target: environment.Environmentsを受け取るようにした
* 当初一番最後に定義しようとしたら「Optional引数の後に普通の引数定義するな」と
* 怒られたので(まあそらそうか)後ろから二番目に定義した
*/
constructor(scope: cdk.Construct, id: string, target: environment.Environments, props?: cdk.StackProps) {
super(scope, id, props);
new GreetingRestService(this, `greetingService-${target}`, target);
}
}
/**
* lambda + apigatewayを1つのConstructとしてまとめたもの
* このくらいの量であればStackのコンストラクタに直書きしても良さそうだが、なんとなく。
*/
class GreetingRestService extends cdk.Construct {
/**
* こちらも当然、target: environment.Environmentsを受け取る
*/
constructor(scope: cdk.Construct, name: string, target: environment.Environments){
super(scope, name);
//受け取った環境に対応する環境変数を取得
const environmentVariables = environment.variablesOf(target);
// Lambda Function
const greetingLambda = new lambda.Function(this, `greetingLambda-${target}`, {
code: lambda.Code.asset('lambda/greeting'),
handler: 'index.handler',
runtime: lambda.Runtime.NODEJS_10_X,
timeout: cdk.Duration.seconds(3),
environment: environmentVariables.lambdaEnvironmentVariables //先ほど定義した環境変数からlambdaの環境変数に渡す
});
// API Gateway
const api = new apigateway.RestApi(this, `greetingApi-${target}`, {
deployOptions:{
stageName: "api"
}
});
//Lambda統合プロキシ
const greetingIntegration = new apigateway.LambdaIntegration(greetingLambda);
//テキトーなリソース/メソッドに紐づける
const v1 = api.root.addResource('v1');
const hello = v1.addResource("hello");
//API Keyが必要かどうかは環境次第
hello.addMethod("GET", greetingIntegration, {apiKeyRequired: environmentVariables.apiKeyRequired});
//API Keyが必要な環境であればAPI Keyを作成して紐づける
if(environmentVariables.apiKeyRequired){
const key = api.addApiKey(`keyfor-${target}`);
const plan = api.addUsagePlan('UsagePlan', {
name: `for-${key.keyId}`,
apiKey: key
});
plan.addApiStage({stage: api.deploymentStage});
}
}
}
メインスクリプトの編集
今回は外部からContext
としてリリースターゲット(dev|qa|prod)を受け取り、その環境をdeployするような挙動としたい。
Context
については公式を参照。
ということで、cdk init
した時に出来たbin/switch-context-inspection.ts
を修正。
#!/usr/bin/env node
import 'source-map-support/register';
import cdk = require('@aws-cdk/core');
import { SwitchContextInspectionStack } from '../lib/switch-context-inspection-stack';
import * as environment from '../lib/environment';
const app = new cdk.App();
//Contextから'target'として対象の環境(dev|qa|prod)を取得
const target: environment.Environments = app.node.tryGetContext('target') as environment.Environments;
//targetが定義されていない、もしくは不正な値だった場合エラーで落とす
//XXX:これもうちょっとカッコいい方法無いだろか。。。
if(!target || !environment.variablesOf(target)) throw new Error('Invalid target environment');
//先ほど定義した第3引数として受け取ったtargetを渡す
new SwitchContextInspectionStack(app, `SwitchContextInspectionStack-${target}`, target);
テスト
ビルド
$ pwd
# ${YOUR_ROOT}/switch-context-inspection
# CDKのプロジェクトルートで実行
$ npm run build #前述の通り、CDKとlambdaを一括でビルド出来て楽チン
target未指定でデプロイしようとした場合
$ cdk deploy
Invalid target environment
Subprocess exited with error 1
ちゃんとエラーで落ちた。
不正な環境名でデプロイしようとした場合
$ cdk deploy -c target=hoge
Invalid target environment
Subprocess exited with error 1
同じくちゃんとエラーで落ちる。
devでデプロイしてみる
$ cdk deploy -c target=dev
#本当はなんや色々出てくるけど省略
✅ SwitchContextInspectionStack-dev
Outputs:
SwitchContextInspectionStack-dev.greetingServicedevgreetingApidevEndpoint41D4DEDF = https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxxx:stack/SwitchContextInspectionStack-dev/1760cf50-c0a8-11e9-aa4b-0e819627e6da
デプロイ成功。API Gatewayにリクエスト投げてみる。
devなのでAPI Keyなしで受け付けてくれるはず。
$ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/v1/hello
{"message":"Hi, I am DEV SERVICE!"}
お、成功。ちゃんとLambdaに環境変数も渡ってますね。
qaでデプロイしてみる
$ cdk deploy -c target=qa
#本当はなんや(ry
✅ SwitchContextInspectionStack-qa
Outputs:
SwitchContextInspectionStack-qa.greetingServiceqagreetingApiqaEndpoint28F0136E = https://yyyyyyyyy.execute-api.ap-northeast-1.amazonaws.com/api/
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxx:stack/SwitchContextInspectionStack-qa/132d5240-c0bd-11e9-a939-0a73531ad5e4
デプロイ成功。載せられないですが、API Gatewayのドメインはdevとは別のものが振られています。
ということでリクエストを投げてみる。
$ curl https://yyyyyyyyy.execute-api.ap-northeast-1.amazonaws.com/api/v1/hello
{"message":"Forbidden"}
qaはAPI Keyが必要なのでちゃんと拒否(Forbidden)されましたね。
ということで、コンソールに入ってAPI Keyを確認してもう一度。
$ curl https://yyyyyyyyy.execute-api.ap-northeast-1.amazonaws.com/api/v1/hello -H 'x-api-key:${コンソールで確認したqa用のAPI Key}'
{"message":"Hi, I am QA SERVICE!"}
ちゃんと返ってきました。
ちなみに、いちいちコンソールでAPI Keyを確認するのが面倒なので、なんとかOutputsに出力出来ないか色々探してみたのですが、私が探した限りでは無理な(APIが無い)ようでした。まあ、セキュリティ上やむなしですか。。。
prodでデプロイしてみる
$ cdk deploy -c target=prod
#本当は(ry
✅ SwitchContextInspectionStack-prod
Outputs:
SwitchContextInspectionStack-prod.greetingServiceprodgreetingApiprodEndpoint49BB03C5 = https://zzzzzzzzz.execute-api.ap-northeast-1.amazonaws.com/api/
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxx:stack/SwitchContextInspectionStack-prod/449a7310-c0bf-11e9-a7e1-0e842c318628
デプロイ成功。
まずは API Keyなしでアクセスを試みてみる。
$ curl https://zzzzzzzzz.execute-api.ap-northeast-1.amazonaws.com/api/v1/hello
{"message":"Forbidden"}
当然拒否られる。
ちなみに先ほどqa用のAPI Keyでアクセスを試みても...
$ curl https://zzzzzzzzz.execute-api.ap-northeast-1.amazonaws.com/api/v1/hello -H 'x-api-key:${先ほどのqa用のAPI Key}'
{"message":"Forbidden"}
まあ、当然拒否られる。
ということでコンソールで新たに発行されたAPI Keyを確認して再実行。
$ curl https://zzzzzzzzz.execute-api.ap-northeast-1.amazonaws.com/api/v1/hello -H 'x-api-key:${改めて確認したprod用のAPI Key}'
{"message":"Hi, I am PROD SERVICE!"}
上手く行きましたね。まあ、動作確認はこんなところでしょうか。
###後片付け
当然といえば当然だが、環境ごとにdestroyしなければいけない。。。
$ cdk destroy -c target=dev
#本当はare you sure?的なこと訊かれたり色々あるけど省略
$ cdk destroy -c target=qa
$ cdk destroy -c target=prod
所感
設定値を全てContextに持たせる vs ソースに埋め込んでしまう
ベタな発想をすれば、今回でいうNAMEやapiKeyRequiredなど細々した設定値もContextに持たせて、
cdk deploy -c target=dev -c NAME="DEV SERVICE" -c apiKeyRequired=false
みたいなやり方も思いつくのですが、
- CodeBuildなどで環境切り分けする時めんどくさい
- cdk_dev.json,cdk_qa.json...みたいにファイルで管理するにしても環境に応じて
mv cdk_${ENV}.json cdk.json
みたいにするのがあまりスマートだと思えない。。。
- cdk_dev.json,cdk_qa.json...みたいにファイルで管理するにしても環境に応じて
- 折角のTypeScriptなのに型安全じゃ無い
というのが嫌だったので、今回のようにenvironment.tsに押し込める実装にしてみたのですがいかがなもんですかね...?
当然、他のAPIに対するAPI Keyなど、ソースに埋め込みたく無い設定値は実運用上発生してくると思うので、そういうのは流石にContext経由で渡した方が良いでしょうね。
もう少しCDKであることを生かした美しい方法は無いだろうか...
折角yamlのようなマークアップ言語でなく、ゴリゴリのオブジェクト指向プログラミング言語で書いているのだから、それを生かした美しい実装はないものですかね。。。書いてはみたものの、ちょっとモヤモヤしてます。
細かいことでいえば、例えば、
cdk destroy -c target=all
みたいにした時にdev,qa,prodを全部掃除してくれるとかは出来そうですね。(運用上怖い説がありますが、そもそもIAMのロール管理しっかりしようって話ですね)
ということで、まだまだ研究の余地があるような気がしてます。
「もっとスマートにやってるぜ!」っていう方は是非コメントで教えてください(切実)