mazyu36の日記

某SIer所属のクラウドエンジニアのブログ

AWS CDKのチーム開発時に実装時の考慮事項、意識合わせした点について

はじめに

以下の記事でAWS CDKにおけるチーム開発のフローやテストについて記載した。

mazyu36.hatenablog.com

mazyu36.hatenablog.com

今回は実装内容についてチーム内で意識合わせしておいた方が良いと思った点をまとめておく。

前提

チーム開発のフローの記事に記載した環境構成を前提としている。

  • インフラ担当者担当者用の環境(infraA, infraB):インフラ担当者がCDK開発時に使用する環境。リソースの削除や再作成を頻繁に行う。
  • チーム内で共有する開発環境、試験環境(dev, stg):開発中のアプリの動作検証や試験に使用する環境。インフラ担当者用環境である程度フィックスしたものが反映されるため、リソースの削除や再作成の頻度は低い。
  • 本番環境(prd):本番提供用のアプリが動作する環境。

1. Stackの分割基準

Stackをどの単位で分割するかは事前に決めておいた方が良い。メンバによって分割基準が異なると混乱する可能性があるため。

考慮しないといけないのはStack間参照の存在による弊害である。これは以下のクラスメソッドさんの記事がよくまとまっている。

dev.classmethod.jp

簡単にいうと「被参照側のStackでリソースの変更ができなくなる可能性がある」というものである。 特に初期開発中だと検証のためリソースを作り直すこともあるので、これにハマりやすい。

過去あった事例ではCognitoのユーザープールとそれを参照するリソースを別のStackとして開発しており、ユーザープールの設定変更をする度にこの事象にハマったというものである(ユーザープールは作成後は設定変更できない項目も多く、初期開発時は作り直すこともしばしばあるのが理由)

CDKにおいてはStack内のリソースをファイルに分割する等が可能であることから、可読性のためにStackを分割する必要はない。

この点を踏まえると、Stackの分割については「なるべく分けない」、「性質で分ける」などの考え方がある。

  • 以下の記事では「できるだけ分けない」、分けるとすれば「責務による分割」とする考え方が記載されている。

tmokmss.hatenablog.com

  • 以下の記事では「ステートフル or ステートレス」で分割する考え方が記載されている。

zenn.dev

個人的にはどちらも同意できる内容であり、実際に以下のようにすることが多い。

  • 基本的にStackは分割しない。ただし環境単位ではなく、アカウント単位のリソースや、リージョン違いのリソースが存在する場合は分ける。
  • CloudFormationのクォータ(リソース数500)にあたる可能性がある場合は、共通的に使用するリソースを切り出す(責務による分割) ※共通的に参照するリソースは基本的にステートフルなリソースになることがほとんどな気がする。

以下、上記を踏まえよく行うパターンを記載する。なおWebサービス提供時によくある構成の1つと思われる以下を例とする。

  • Webサービスを提供するCloudFront, ALB, ECS, RDS
  • CI/CDパイプライン
  • CloudTrail, Config等セキュリティに関するサービス

極力Stackを分割しないパターン

以下のような構成になることが多い。

ポイントとしては以下である。

  • Governance Stack:セキュリティサービスに関するStackは個別に切り出すことが多い。なぜかというとこれらは環境単位ではなく、アカウント単位のリソースになるためであり、他のリソースとは粒度が異なるからである。またこのStackに属するサービスを他のStackから参照することはほぼ無く、独立性が高いことも理由である(Stackで分けるのではなく、Appの粒度で分ける、プロジェクト自体分けるというのもありかと思う)
  • Service Stack:基本は全てここに詰め込む。
  • WAF Stack:CloudFrontで使用するWebACLはus-east-1であることが必須のため、他リージョンで構築を行う場合は必然的にStackを分けざるを得ない(多分CloudFrontを使っているとあるある)。なおクロスリージョン参照を行う場合は以下が便利なのでよく使っている。

dev.classmethod.jp

共通的に使用するリソースを切り出すパターン

Stackをより分割したパターンとしては以下のような構成である。

  • Governance Stack:前パターンと同様であるため割愛
  • Common Stack:複数のServiceから共通的に参照されるリソースを分割。以下のリソースを切り出すことが多い。
  • Service Stack:ここはサービスごとに分割し、サービスが増えていくごとにStackが増えていくことになる。なおService Stack間で依存関係(Stack間参照)が発生することは極力避ける。発生してしまいそうな場合はStack分割が不適切、もしくはアーキテクチャを見直した方がいいケースが多い気がする。
  • WAF Stack:前パターンと同様であるため割愛

Stack間参照を避けられない場合の対応

以下のいずれかによってStack間を疎結合にすることが多い。

  • SSMパラメータストアを使う手法。これは以下がよく纏まっていた。

dev.classmethod.jp

  • リソース名で解決する。ただしこれは被参照側のリソースに明示的に名称をつけていないと使用できない。

2. ConstructIdの命名規則

ConstructId はインスタンス生成時の第2引数を示す。例でいうとAlbIngressが該当する。ConstructIdも事前に決めておかないとチーム内でバラバラになってしまうので事前に決めておいた方が良いと思う。

const lb = new elbv2.ApplicationLoadBalancer(this, 'AlbIngress', {
    vpc: props.vpc,
    internetFacing: true,
    securityGroup: props.sgIngress,
    vpcSubnets: {
        subnets: [props.subnetIngress1a, props.subnetIngress1c]
    }
});

観測範囲ではパスカルケースで記載されることが多いと思う。以下の記事ではパスカルケースにしておくと、自動生成リソースの名称が分かりやすくなるという点が記載されている。

qiita.com

上記踏まえConstructIdは「パスカルケース」で「サービス名+用途がわかる単語」等にすることが多い(この辺りは好みもあるので、チーム内で統一ができれば問題ないと思う)。

3. リソース名つけるか問題

CDKのベストプラクティスとしてリソース名は、自動命名のものを使用するべし(名前を指定しないようにする)というものがある。

aws.amazon.com

自動で生成されるリソース名を使用し、物理的な名前を使用しない

名前は貴重なリソースです。すべての名前は一度しか使えないので、テーブル名やバケット名をインフラやアプリケーションにハードコードしてしまうと、もうそのインフラの一部を2つ並べてデプロイすることはできません。

...中略...

より良い方法は、できるだけ名前を指定しないことです。リソース名を省略すると、一意の新しい名前が生成されるので、この種の問題は発生しません。次にアプリケーションをパラメータ化します。例えば実際に生成されたテーブル名 (AWS CDKアプリケーションではtable.tableNameとして参照できます) を環境変数としてLambda関数に渡したり、EC2インスタンスの起動時にコンフィグファイルを生成したり、実際のテーブル名を AWS Systems Manager Parameter Store に書き込み、アプリケーションがそこから読み込んで実際のテーブルを把握したりします。これはリソースのための依存性の注入のようなものです。

正直なところチーム開発では全リソースを自動命名にするのは無理だと思う。

言いたいことはほぼ全て以下の資料に書いてあった。

speakerdeck.com

一度全て自動命名としたことがあるが、以下のようなことが多発して断念した。

  • 単純に分かりづらい。今回のチーム開発例のように1アカウントに複数環境存在していると、自動命名されたリソースも似通ったものになり判別が難しくなる。
  • 上記により他チームメンバーが混乱する。特にアプリのログ、CI/CDの状況、DBリソースなどはインフラ担当者だけではなく、アプリ実装者等も確認する。しかし他メンバがリソースを取り違えるなどのミスが多く、生産性に影響が出てしまっていた(開発環境のリソースを見たいのに、試験環境のリソースを見てしまうなど)。
  • コマンドを打つのがしんどい。特にECS Execは多用するが、クラスター名やサービス名がめちゃくちゃ長くなってしまい辛い。

上記を踏まえると以下のようなアプローチをとることが多い。

  • インフラ担当者以外が参照することが多いリソースは名称をつける。
  • インフラ担当者以外はほとんど参照しないと思われるリソースは自動生成するが、判別するためにタグを付与する。

インフラ担当者以外が参照することが多いリソースは名称をつける

インフラ担当者以外としては「アプリ開発者」や「運用担当者」が参照することが多いと思う。ユースケースを踏まえると、以下のリソースについては名称を付与することが多い。

  • アプリの実行基盤のリソース確認:ECS, EC2, RDS, Lambda, CloudWatch Metrics
  • ログの確認:CloudWatch Logs, S3
  • CI/CDの状況確認:CodePipeline, CodeBuild, CodeDeploy

また名称ルールについては「環境名-サービス名-目的」など、デプロイ先の環境と目的がわかるものにすることが多い。

ただし、リソースによって使える記号や大文字の許容有無が異なるので、ある程度汎用的なルールにする必要がある(基本はパスカルケースにする、ただし不可である場合はケバブケース→スネークケースの優先順位で命名するなど)。

他リソースは自動生成するが、判別するためにタグを付与する

自動命名とする場合でも、タグでデプロイ先の環境を付与しておくと判別がしやすく、便利である。

CDKではAppに対してまとめてタグを付与することも可能なのでよく使用する。

以下の例はデプロイ先の環境をContextから取得し、Appに対して一括でタグを Environment:環境名 で付与する例である。

const app = new cdk.App();

// デプロイ先の環境はcontextから取得する
const envType = app.node.tryGetContext('env');

// 中略

// タグに環境名を付与
const envTagName = 'Environment';
cdk.Tags.of(app).add(envTagName, envType);

4. 不要なリソース、ログの残存の回避

特にL2 Constructにおいてステートフルなリソースはデフォルトで、以下のような設定になっていることが多い(なるべく削除されない設定になっている)。

  • スタック削除時はリソースを削除せずに残す(RemovalPolicyRETAIN
  • ログの保持期間が長めになっている or 無制限

しかしチーム開発時に以下のような問題が引き起こされることがある。

  • RemovalPolicyRETAINであることによるリソースの増殖。CDK開発時は「スタックの削除→再構築」、「リソースの定義の削除→追記」など試行錯誤をすることが多い。しかしRETAINにより削除時のリソースが保持され、同じ名称でデプロイしようとして失敗、自動命名の場合は不要なリソースがどんどん増え続けるということが起きてしまう(よくわからないCloudWatch Logsのロググループが量産されている等が典型例)
  • ログの長期保存によるコスト増。ログが意図せず長期保存されており、無駄にコストが掛かっている。特に開発環境等ではログの長期保存を必要としないケースが多い。

上記のため、以下のような方針にすることが多い。

  • RemovalPolicyは以下の方針
    • 本番環境は当然だがRETAIN
    • 開発環境、試験環境も基本RETAINのことが多い。開発環境、試験環境へのCDKのデプロイはインフラ担当者が試行錯誤して、構成が固まった状態であることが多いため。
    • インフラ担当者用の環境は基本DESTROY。スタックの再構築、リソースの削除・追加など試行錯誤を頻繁に行うため。
  • ログの保持期間は以下の方針
    • 本番環境は要件によって設定
    • 開発環境、試験環境は短めに設定。プロジェクトにもよるが、短い場合だと1週間など。ここはプロジェクト内で合意しておいた方が良い。
    • インフラ担当者用の環境はさらに短めに設定する(1日など)。基本的にこの環境ではログ解析などを行わないため(ログが出力されていることの確認ぐらいのことが多い)

以下、上記で記載した問題が起こることが多いリソースを3つ(L2 Construct)記載する。

※なお環境によってRemovalPolicyやログの保持期間を変える際に、誤って本番環境の設定を不適切なものにしていないかは要注意。そのためには本番環境の設定が正しいことのテスト(Fine-grained Assertions)を実装しておくのがおすすめ。

CloudWatch Logsのロググループ

デフォルトだとRETAINかつ保持期間がTWO_YEARSなので、増殖かつ不必要にログを保持し続けてしまっていることが多々ある。

そのため環境によっては以下のようにDESTROYかつ保持期間を短くする。

import * as cdk from 'aws-cdk-lib'
import { aws_logs as logs } from 'aws-cdk-lib';

new logs.LogGroup(this, "LogGroup", {
    removalPolicy: cdk.RemovalPolicy.DESTROY, // DESTROYに変更
    retention: logs.RetentionDays.THREE_DAYS // ログの保持期間を3日に変更
}),

S3バケット

S3バケットはデフォルトでRETAINかつライフサイクル設定なしとなっている。そのため無駄に大量にログを保存するバケットが残るなどがあり得る。

こちらも環境によってはDESTROYかつライフサイクルにより保持期間を短くする。

またS3バケットにおける考慮事項として、DESTROYにしてもバケット内にオブジェクトが存在する場合はエラーとなるため、autoDeleteObjectstrueにする必要がある。

import * as cdk from 'aws-cdk-lib'
import { aws_s3 as s3 } from 'aws-cdk-lib';

const bucket = new s3.Bucket(this, 'Bucket', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,  // DESTROYに変更
  autoDeleteObjects: true,  // trueにするとバケット削除時にオブジェクトが存在する場合は自動で削除してくれる
  lifecycleRules: [
    { expiration: cdk.Duration.days(3) } . // ライフサイクルを3日に設定
  ]
});

KMS CMK

KMS CMKもデフォルトでRETAINとなっているため、増殖しやすい。

そのため環境によってはDESTROYに変更する。

ただし誤って削除してしまうと復号できない等が起こり得るため注意が必要(削除する場合でも最低7日の猶予期間はあるが)。

実プロジェクトだと、インフラ担当者用の環境のみDESTROYにするケースが多かった。

import * as cdk from 'aws-cdk-lib'
import {aws_kms as kms}  from 'aws-cdk-lib';

const kmsKey = new kms.Key(this, 'Key', {
    removalPolicy: cdk.RemovalPolicy.DESTROY, // DESTROYに変更
});

おわりに

実装時に毎回考慮する点を数点まとめてみたが、日々試行錯誤中。

自プロジェクトでは毎回こうしてるよとかノウハウがあれば是非教えてください。