この記事は ラクスルの2022年アドベントカレンダー14日目の記事です。
はじめまして!ノバセル事業部 NA(Novasell Analytics)開発チーム、サーバーサイドエンジニアの浅田です。昨日の記事では、23新卒内定者インターン中のメンバーが、ラクスルグループ(ラクスル・ノバセル・ハコベル)の各サービスでどんなことをしているのかを紹介してくれました。
今日は、AWS CDKにおけるコンフィギュレーション管理のベストプラクティスに関する記事を書いていきます。よろしくお願いします!
前提
まず、前提として、CDKでAWSのインフラを構築する際には、コンストラクトに多くのプロパティを設定する必要があります。
例えば、以下の簡単な例では、account, region, vpcId, instanceTypeというプロパティを渡しているのがわかります。
import { App } from 'aws-cdk-lib'; import { InstanceClass, InstanceSize, InstanceType } from 'aws-cdk-lib/aws-ec2'; import { DbStack } from './DbStack'; const devEnv = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }; const prodEnv = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }; const app = new App(); // 開発環境のRDSインスタンスを作成 new DbStack(app, 'DevDb', { env: devEnv, vpcId: 'vpc-2f09a348', instanceType: 't3.micro', }); // 本番環境のRDSインスタンスを作成 new DbStack(app, 'ProdDb', { env: prodEnv, vpcId: 'vpc-abcd0123', instanceType: 'r5.xlarge', }); app.synth();
プロパティの数が少ないうちは、上記のように値を直書きでもいいかもしれませんが、一般的に、時間が経過するにつれて管理しなくてはいけない値は増えてくるので、別の管理アプローチが必要になります。
これらの値を定義し、時間の経過とともに維持する方法が、コンフィギュレーション管理です。
この管理手法には大きく分けて2つのやり方があります。Static Management、Dynamic Managementの2つです。以下ではそれぞれの概要を述べた上で、コンフィギュレーション管理のベストプラクティスを検討していきます。
Static Management(静的構成管理)
コンフィギュレーション管理の最も一般的な手法が、このStatic Management(静的管理)です。
この手法が優れているところは、同じ入力が与えられた場合、常に同じ出力を生成する点です。この特徴は、後述するDynamic Managementの箇所でも非常に効果を発揮します。この決定論的であるという特徴はcdkにおいて最も重要なことです。なぜならcdkが扱っているのはインフラストラクチャであり、これが予期せず変更されてしまうことは非常に危険なことだからです。
このStatic Managementにはいくつか方法があります。
一つが、先述したように値を直書きする方法です。ただし、この方法は、値が増えてきた時に管理、維持が大変になるのでやめておいた方が良いでしょう。
幸い、CDKは汎用プログラミング言語を使用しているため、この設定を管理するためにさまざまな方法を活用できます。以下では、さらに3つのアプローチを見ていきます。
1. Context Variables(コンテキスト変数)
1つ目は、コンテキスト変数を利用する方法です。
コンテキスト変数に関しては以下の記事参照。
まず、コンテキスト変数の設定ですが、いくつかやり方があります。
コマンドラインからcontext変数を設定する方法
以下のようにcdkコマンド実行時にオプションでcontext変数に値をセットする方法です。ただし、この方法は、複数の値を設定するには不向きでしょう。
cdk synth -c dev-vpc-id=vpc-4321abcd -c prod-vpc-id=vpc-9876edcb
cdk.jsonファイル内で設定
cdkプロジェクト内にデフォルトで置かれているcdk.jsonファイル内でcontext変数に値をセットするやり方もあります。これが最も一般的な方法かもしれません。
{ "app": "npx ts-node --prefer-ts-exts src/main.ts", "context": { "dev-vpc-id": "vpc-4321abcd", "prod-vpc-id": "vpc-9876edcb" } }
さらに、設定したコンテキスト変数は、以下のようにtryGetContextメソッドを用いて簡単に取得できます。
const devVpcId = app.node.tryGetContext('dev-vpc-id') ?? 'vpc-2f09a348'; const prodVpcId = app.node.tryGetContext('prod-vpc-id') ?? 'vpc-abcd0123';
このコンテキスト変数を利用する方法は、最もシンプルですが、うまくスケーリングしないという欠点があります。
例えば、cdk.json配下でより深い構造を表現したい場合は、次のような命名規則が必要になります。
{ "app": "npx ts-node --prefer-ts-exts src/main.ts", "context": { "dev_vpc-id": "vpc-4321abcd", "dev_app_security-group": "sg-012345678'", "prod_vpc-id": "vpc-9876edcb", "prod_app_security-group": "sg-abcd01234'" } }
確かに可能ではありますが、小さなタイプミスや間違いが後で整合性の問題につながることもあります。
また、全ての環境の全てのプロパティをひとつのファイルで管理しているので、時間経過につれて肥大化し、管理しにくくなってしまいます。
コンテキスト変数は少量の設定であればうまく機能しますが、多くの設定を扱うのであれば、移動したほうがよいでしょう。
2. Static Files
Static Managementのアプローチの2つ目は、静的ファイルを利用する方法です。
ここでも汎用言語を使用している恩恵に預かれます。つまり、その言語がサポートする場所であれば、データを移動したり保存したりできるということです。
先述した例では、cdk.jsonに全てのプロパティを設定していました。まずはこれらすべてを別のファイルにリファクタリングし、そしてそのファイルを読み込んで、そのプロパティをスタックに渡します。
const devProperties = require('./env/dev.json'); const prodProperties = require('./env/prod.json'); // DEV RDSインスタンスを作成する new DbStack(app, 'DevDb', { env: devEnv, ...devProperties, }); // prod RDSインスタンスを作成する new DbStack(app, 'ProdDb', { env: prodEnv, ...prodProperties });
dev.json
{ "vpcId": "vpc-2f09a348", "instanceType": "t3.micro" }
prod.json
{ "vpcId": "vpc-abcd0123", "instanceType": "r5.xlarge" }
- 上では、特定の環境のすべての設定は、環境ごとに1つのファイルにあります。
- すべてを1つの場所に置くことで、環境で何かを変える場所を知ることは非常に明確かつ簡単になります。
- ただし、このアプローチにも課題があります。
- この場合、instanceTypeは文字列「t3.micro」「r5.xlarge」で設定されています。
- このような文字列値は、誤入力しやすく、間違って「t3, micro」を入力してしまった場合、エラーにつながります。
- また、このようなエラーは非常に見つけにくいです。
- 静的ファイルは、コピー/貼り付けているだけ、または間違えにくい文字列がたくさんある場合に最適だといえるでしょう。
3. Less Static-y Files
先ほど見たように、静的.jsonファイルでは、InstanceTypeクラスのようなより複雑な型を指定できません。代わりに以下のような.tsファイルの使用に変更すると、ある種の安全性を得られます。
prod.ts
import { InstanceClass, InstanceSize, InstanceType } from 'aws-cdk-lib/aws-ec2'; export const config = { vpcId: 'vpc-abcd0123', instanceType: InstanceType.of(InstanceClass.R5, InstanceSize.XLARGE), };
ここでのもうひとつの利点は、dev.tsとprod.tsファイルがより複雑なロジックで、jsonファイルよりも複雑な型を表せることです。
たとえば、これらの値の一部を環境変数からオーバーライドしたいときには、以下のようにすることで対応できます。
prod.ts
import { InstanceClass, InstanceSize, InstanceType } from 'aws-cdk-lib/aws-ec2'; export const config = { vpcId: process.env.DEV_VPC ?? 'vpc-abcd0123', instanceType: InstanceType.of(InstanceClass.R5, InstanceSize.XLARGE), };
ここで、注意するべきことは、コンテキスト変数や環境変数などについては、以下のように適切に検証する必要があるということです。
const vpcId = process.env.VPC_ID; if (!vpcId || !isValidVpcId(vpcId)) { throw new Error("Please provide a valid VPC_ID environment variable"); }
- もし、パイプラインの変更によりvpcIdが誤って変更された場合、スタックが失敗する可能性があるからです。
- 先述したように、cdkのコードは常に決定論的になるように配慮することが最も重要なことです。
- この点に関して言えばcdk.jsonなどで静的に管理するアプローチの方が優れています。
- 基本はtsファイルで管理しつつ、コンテキスト変数や環境変数などの入力に対して適切にチェックするという手法がベストでしょう。
Dynamic Management
コードの設定を取得するためのよりダイナミックな方法もあります。設定値をサードパーティのサービスに置いて、実行時に動的に取得する方法です。
RDSインスタンスの設定がJSONファイルに保存されている例に戻りましょう。
{ "vpcId": "vpc-2f09a348", "instanceType": "t3.micro" }
JSONは、データ転送に移植性が高く、頻繁に使用される形式です。必要に応じて、このデータはローカルファイルではなくサービスから取得できます。
axios.get('https://someconfig.novasell.com/dev') .then(devConfig => { const app = new App(); const stack = new DbStack(app, 'DevDb', results); app.synth(); });
ここでは、シンプルなHTTPクライアントであるサードパーティのaxiosライブラリが、何らかのconfigサーバーを呼び出してconfig情報を取得し、dev DB Stackを作成します。
- これは非常に強力な方法ですが、非常に危険な方法でもあります。
- なぜなら、このコードが実行されるたびに、https://someconfig.novasell.com/devへの要求の結果が同じ結果を返すという保証はない、つまり決定論的ではないからです。
- 入力が異なる場合、予期せぬタイミングでスタックが変容してしまう恐れがあります。
- このアプローチをより決定論的にするためのステップとして、以下のようなアプローチがあります。
- これは非常に強力な方法ですが、非常に危険な方法でもあります。
まず取得したデータをファイルに保存するように変更します。
axios.get('https://someconfig.novasell/dev') .then(devConfig => { fs.readFileSync('./env/dev.json', devConfig); });
- そうすると、先述したような静的ファイルの読み込みというアプローチに帰着します。
const devProperties = require('./env/dev.json'); // Create our DEV RDS instance new DbStack(app, 'DevDb', { env: devEnv, ...devProperties, });
- 上のようなアプローチをとることで、データの取得は2つの別々のステップになり、最初のステップで結果をキャッシュに書き込み、2番目のステップで使用するようになりました。
- このように2つのステップに分けることで、予期せぬ変更が起こる可能性を減らせます。
- なぜなら、コードを合成すると、静的な設定から再び駆動されるため、常に同じ出力になるからです。
ベストプラクティス
これまで、コンフィギュレーション管理の様々なアプローチを概観してきました。
まとめると、以下のように分類できます。
Static Management
contextで渡す
configの値をファイルに分ける(json)
configの値をファイルに分ける(ts)
Dynamic Management
- サードパーティのサービスに置いて、実行時に動的に取得する
cdk.jsonに書いていく方法は、肥大化しがちです。
jsonファイルに分ける方法もありですが、例えばInstanceTypeのような複雑な型を指定するようなケースには不向きです。
実行時に動的に取得する方法は、configで制御するものが少ない場合、用意する手間と見合わないかもしれないので、動的に取得することによって得られる利益と用意する手間とを比較衡量して判断するようにしましょう。
以上をまとめると、最初はtsのファイルに分ける方法で、値が増えてきたら、実行時に動的に取得する方法に切り替えるのがいいでしょう。ただし、この方法を採用する場合は、コードが決定論的になるように、コンテキスト変数や環境変数などについては、適切に検証する必要があります。
おわりに
今回は、AWS CDKにおけるコンフィギュレーション管理の種々のアプローチを概観し、その中のベストプラクティスを検討してきました。
CDKは非常に奥が深いかつ非常に強力なツールなので、今後も継続してこの分野のインプット・アウトプットしていきます!
ラクスルの2022年アドベントカレンダーはまだまだ続きます!