💨

AWS CDK(TypeScript)でFargate + CloudFront構成を立ち上げて悩んだところ

2023/12/14に公開

この記事はTypeScript アドベントカレンダー2023 16日目の記事です。

背景

会社で新規サービスを立ち上げるにあたり、AWS CDKの導入にチャレンジしています。これまで1つの事業に注力してきましたためIaCはほとんど進めていませんでしたが、アセットを活かしつつ複数のサービス展開を見据えていく背景があり、AWSでベーシックなインフラを立ち上げることがある程度誰でも簡単にできるようにIaC化したいなということで取り組んでいます。

AWS CDKを採用した理由は端的にいえば、TypeScriptでインフラ定義が書けることです。社内では現在全員がTypeScriptの実装が可能ですので、本来の目的である誰でもインフラを立ち上げられるようになる、というゴールに相対的に近いと考えました。
加えて、すでに現在でも既存事業で一部CloudFrontをCDKで定義しているコードがあり[1]、既存メンバー全員がCDKを触った経験自体はあります。そういう意味でもCDKの選定が妥当でした。

本記事では、AWS CDKを用いてベーシックなFargate + CloudFront構成を立ち上げる最中で、既存のCDKドキュメントやCDK Workshop等の文献では見当たらなかった点で悩んだところをまとめていきます。

  • 悩んだところ
  • 悩みながらも、こうしようと決めたところ
  • 悩んだうえで、どうしたらいいか未だにわかっていないところ
    の観点でまとめますので、これからCDKを始めようと思っているがどういったところで詰まるのか知りたいといった方や、すでにある程度成熟している方にも見ていただきたいと思います。

1人で2週間ほど掛けて調べたり試した内容ですので、欠けているノウハウなどあるかと思います。もしコメントなどありましたらいただけますと踊って喜びます。

構築したもの

以下のリソースを構築するCDKコードを実装しました(厳密にはまだ本番稼働前なので、完成しているわけでは有りません)。

  • VPC
    • SecurityGroupやSubnetなど
  • RDS(Aurora)
  • ElastiCache
  • Fargate(for Laravel)
    • ALB
    • TaskDef
  • App Runner(for Next.js)
  • ECR
  • Bastion
    • EC2
  • IAM
  • CloudFront
    当然ながら、CloudFrontからApp RunnerおよびALBにリクエストを流すようにオリジンの設定をしたり、Fargate上のLaravelコンテナからRDSやElastiCacheに接続できるように接続情報をSecretで渡すなども設定しています。

また、後述しますがソースコードのデプロイはGitHub Actionsに寄せたいため、CodeCommitやCodeBuildにはさほど依存しない前提です。

悩んだところ

Stackをどのように分けるか

CDKを一通り学んだところ、CDKはデプロイおよびロールバックの単位としてStackが使えることがわかりました。
参照

Stackは実態としてはTypeScriptのclassであり、Extendsすることで作成できます。
デプロイの単位としてStackを用いることができるということは、言い換えれば一つのStackにあらゆるリソースの定義を書いてしまった場合、インフラの更新を切り分けることができなくなるということです。

悩みながらも、こうしようと決めたところ

現時点では、以下のような単位でStackを分けています。

└── stacks
    ├── backendApp
    │   ├── api-server-ecr-deploy.ts
    │   ├── api-server.ts
    ├── bastion
    │   └── bastion.ts
    ├── cdn
    │   └── cloudfront.ts
    ├── datastore
    │   ├── elasticache.ts
    │   └── rds.ts
    ├── frontendApp
    │   ├── front-server.ts
    │   └── frontend-ecr-deploy.ts
    ├── iam
    │   └── iam-ci.ts
    └── vpc
        └── vpc.ts

原則はAWSサービス単位で分けるのですが、たとえばFargateなどのように中に厳密にいえばECSやLoad Balancerなどを含んでいる概念の場合は、実際は「APIサーバー」として使われるよねというところで、backendApp/api-server.tsとしてひとまとまりで定義しています。

続いて、このようにたくさんのStackを作ってどうやってまとめるのかという話ですが、以下のようにHogeServiceStackというクラスを作って、そのクラスが内部で具体的なスタックを呼ぶようにネストする方法を今は有力視しています。

export class HogeServiceStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpcStack = new VpcStack(this, 'VpcStack', props);

    const apiServerEcrDeploy = new ApiServerEcrDeploy(this, 'ApiServerEcrDeploy', props);

    const rdsStack = new RdsStack(this, 'RdsStack', props);

    const elasticacheStack = new ElasticacheStack(this, 'ElasticacheStack', props);

    const apiServerStack = new ApiServerStack(this, 'ApiServerStack', props);

    const frontendEcrDeploy = new FrontendEcrDeploy(this, 'FrontendEcrDeploy', props);

    const frontendServerStack = new FrontendServerStack(this, 'FrontendServerStack', props);

    const cloudfrontStack = new CloudfrontStack(this, 'CloudfrontStack', props);

    const bastionStack = new BastionStack(this, 'BastionStack', props);

    new IamForCiStack(this, 'IamForCiStack', props);
}}

bin/app.tsは以下のようにシンプルになります。

const app = new cdk.App();
new HogeServiceStack(app, 'Infra', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});

こうすると、デプロイ時は以下のようなコマンドを実行します。これで変更ファイルパスと対応付けてそれぞれGitHub Actionsでデプロイパイプラインを作れそうです。

npm run cdk deploy Infra/ApiServerStack -- --no-previous-parameters --profile [PROFILE NAME]

上記のようにHogeServiceStackとしてまとめるメリデメを簡単にまとめると以下のとおりです。

メリット

  • app.tsがシンプルになる(bin以下をいじるのはあまり直感的ではない開発フローだと感じるので、できればlib以下を触るのみで開発フローを済ませたいと感じた)
  • Hogeという運営サービス単位でまとめられる。もちろん1サービス1アカウントがベストプラクティスではあるが、たとえば監視系など、サービスに所属させるか微妙なものを完全に別管理にしやすい(別管理であることが直感的にわかるのでは?)
  • 今はやっていないがStack間参照を組みたいときにコンストラクタ関数のスコープで実装できる

デメリット

  • さほど無い。若干デプロイコマンドに癖が出ちゃうくらい

ということで、メリットも強くはないがデメリットも強くないので、ネストしておいたほうが何かと拡張性があるのかなと思ったのでネストさせることにしました。

Stack間の情報の受け渡し

Stackを分割すると、RDS用のスタックからVPC参照したいなとか、Fargate用のスタックからRDS接続情報を参照したいなといったことが起きます。

前述のクラスメソッドさんの記事においては、Stack間のデータの受け渡しはTypeScriptの文法を活用して、Stackインスタンス自体を受け渡しするなどで実現しています。

しかし、私がその方針を模倣してしばらく検証を続けていたところ、簡単にdestroyできない問題が起きました。

Export InfraStackVpcStackXXXXXXXX:ExportsOutputRefMyVpcPrivateSubnet1SubnetXXXXXXXXXXX cannot be deleted as it is in use by InfraStackApiServerXXXXXXXXX

エラー文言自体は見ての通り、今Destroyしようとしているスタックの中に、他スタックから参照されているリソースがあるので消せませんでした、といったものなのですが、このとき気軽にAPI Serverスタックを消していいかは状況によりそうだなと思ったのです。

確かに上記の例だとSubnetを消したらAPI Serverも消えるのが自然なので正しいエラーではあるのですが、(実際に起きたときのメモが残っていなくて恐縮ですが)僕の場合は開発中に気軽に他スタックにスタックのインスタンスを受け渡ししていたがゆえに、互いの参照関係がぐちゃぐちゃになってしまい、簡単に消せないような事態になったこともありました(確か循環参照を踏んだはず)。

また、こちらの記事 で言及されているように、ステートフルなリソースを有するStackが下手に参照を有してしまって、かつうっかりRemovalPolicyもろくに設定していませんでした。となった場合の取り扱いも非常に面倒そうな印象を受けました。

参考) https://dev.classmethod.jp/articles/aws-cdk-props-cross-stack-reference-problem-and-handle/
参考) https://chariosan.com/2021/08/25/cdk_cross_stack_auto_export/

CDK導入にあたって、多少の学習コストが必要になるのは受け入れたとしても、スタック間参照で躓くというのはリスクが高いし、誰もメンテしたくなくなり負債化する懸念もありそうに思いました。(参照をキレイにしたり、RemovalPolicyやバックアップを設計するのを徹底したらいい話ではあるが、自分の感覚としてはそこまでトライアンドエラーするのはROI・リスクリターンの観点で合わないと思いました。本来CDKを選んだ理由がTSによる学習コストの低下ですし)

ちなみにimportValueやexportValueを明示的に実装することで他Stackから参照するときのOutput名を固定するという対処策もあり、記事によってはそちらを有力視しているようでした。

https://dev.classmethod.jp/articles/aws-cdk-closs-stack-reference-exportvalue/

悩みながらも、こうしようと決めたところ

現時点での自分の回答としては、参照はTypeScriptの変数でやりくりするのではなく、exportValueを使うというわけでもなく、以下のいずれかの手法で対応するというものです。

  • 接続情報など→Secret Managerに突っ込む→名前指定で参照
  • Secretにするほどでもない文字列→SSMのString Parameterに突っ込む→名前指定で参照
  • セキュリティグループなどのリソース名→名前指定でリソースを作成→fromLookUpで参照

まず、Secret Managerについては普通こうすると思うので大したノウハウではないのですが、たとえばAuroraに関してはDatabaseClusterのコンストラクトを実行するときに

credentials: {
  username: 'superUltraSaikyoAdmin',
  secretName: `rds/superUltraSaikyoAdmin`,
},

といったパラメータを指定することで、Secret Managerに接続情報が格納されます。

それをECS Taskの定義時に指定することで参照できます。

secrets: {
  RDB_USER_NAME: ecs.Secret.fromSecretsManager(secretManagerInstance, 'username'),
  RDB_PASSWORD: ecs.Secret.fromSecretsManager(secretManagerInstance, 'password'),
  RDB_HOSTNAME: ecs.Secret.fromSecretsManager(secretManagerInstance, 'host'),
},

Secretにするほどでもない文字列はSSM Parameterに格納します(ARNは機密情報ではないのでSSMで良いはず)。

// cloudfrontのoriginに使うために、load balancerのarnをparameter managerに保存する
new cdk.aws_ssm.StringParameter(this, 'FargateLoadBalancerArn', {
  parameterName: '/fargate/load_balancer/arn',
  stringValue: service.loadBalancer.loadBalancerArn,
});

CloudFront用のStackから以下のようにfromLookUpで参照します。

const loadBalancer = cdk.aws_elasticloadbalancingv2.ApplicationLoadBalancer.fromLookup(this, 'LoadBalancer', {
  loadBalancerArn: StringParameter.valueFromLookup(this, '/fargate/load_balancer/arn'),
});

リファクタリングするなら、parameterNameをconstraintsとして定数管理するといいはずです。型で守られる堅牢性は落ちますが、Output/Inputをコントロールできない不便さとのトレードオフかなと思っています。

コンテナのビルド、ECRへのPush、ECSへのデプロイ

実運用を考えると、Laravelソースコードの変更があるたびに最新のコンテナをビルドし、ECRへPush、そしてECSサービスの更新(というかCode Deployのキック)ができる仕組みも必要です。
とはいえCDKに期待していることや責務を考えると、ここはCDKに任せず別なのでは、という感覚もあります。
調べたところ、まず技術要素としては以下の考慮事項や選択肢があるようでした。

<構築時、およびデプロイ時の方針>

    const container = fargateTaskDefinition.addContainer("NextAppContainer", {
      image: ecs.ContainerImage.fromAsset("./docker/sandbox-create-next-app-ts/"),
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'next-app',
        logRetention: log.RetentionDays.ONE_MONTH,
      }),
    })
const phpDockerImageAsset = new DockerImageAsset(this, 'PHPDockerImageAsset', {
  directory: path.join(__dirname, '../../../../backend'),
  file: 'docker/php-app/Dockerfile',
  platform: Platform.LINUX_AMD64,
});
new ecrdeploy.ECRDeployment(this, 'PHPDeployDockerImage', {
  src: new ecrdeploy.DockerImageName(phpDockerImageAsset.imageUri),
  dest: new ecrdeploy.DockerImageName(getApiServerEcrRepositoryName(new cdk.ScopedAws(this))),
});
  • CodeBuildを使ってECRにPush
  • GitHub Actionsを使ってECRにPush
  • ecspressoを使ってサービスを更新(デプロイを強制)

<その他考慮するところ>

  • ローリングアップデートを使うか、Blue/Greenを使うか(私は経験上Blue/Greenを全推ししている)
  • ECR上のイメージのタグを固定でlatestなどにするか、GitHubコミットハッシュを使うか
    • 後者の場合、CDKで完結することが難しくなりそうで、ecspressoなどを日頃のデプロイでは併用したほうがいいかもしれない
  • CDKコードとアプリケーションコードを同じリポジトリに入れるか
    • 入れたくない場合、fromAsset系のソリューションを使うことが難しそうです。
    • ECRに関するCDKコードだけアプリケーションコードと同リポジトリに入れる・・・のも微妙そう

この辺の話については、以下が神スライドなので読むと良いです
https://speakerdeck.com/tomoki10/ideal-and-reality-when-implementing-cicd-for-ecs-on-fargate-with-aws-cdk

悩みながらも、こうしようと決めたところ

まだ完成していないですし、アプリケーション側の都合やデプロイに対する要求にもよるのでこれが良いのは言えないのですが、私が有力視している方針は以下のとおりです。

  • CDKによるECRのセットアップとECS Taskとの紐づけは初期構築のためのものと割り切る
    • Infra/ApiServerStack への更新はGitHub Actionsのセットアップをしない、とか?
  • ソースコード更新時のECRへのPushはGitHub Actionsで行う
  • GitHub Actionsでは、コンテナビルド、ECRへのPush、コミットハッシュの取得、ecspressoを用いたタスク定義の更新およびサービスの更新を行う
    • トリガー(on)をバックエンドコードの更新時(phpファイルが含まれている、とかUMLのみの更新ではない、など細かく設定する)やworkflow_dispatch実行時(ブランチ単位で検証環境に上げてみたいときに利用)など細かく設定
    • ecspressoは機能としてテンプレートがあり、外からIMAGE_TAGといった命名でコミットハッシュを突っ込むことが可能
    • なおecspressoを使わなくてもaws-actions/amazon-ecs-deploy-task-definition で同様のことなら可能なはず?なので、そこは選択肢あり

CDKをしばらく触ってみて思ったのですが、CDKをどこまで使い切るかを決めるのも重要だなと感じています。デプロイフローなどアプリケーションと密に関わり動的な部分や、ログ分析基盤などアプリケーションとあまり関わらないところはあえてIaCにこだわらないことも重要そうです。


以上で、ざっくりではありましたがCDKで一通り構築してみて悩んだポイントを終わります。

CDKの感想

全体的な感想としては、以前CloudFrontをCDKで構築したときと同様、コンソールからポチポチで構築したことがあれば、同様の機能を型定義から見つけてくるのはさほど難しいことでは有りませんでした。なので、詰まった点としてはCDK特有のポイントが大半だったかなと思います。
インフラ作業全体に言えることですが、待ち時間が多くてもったいない気分になりますね。なので並列で開発タスクを持っておいてCFnへの反映中にそちらを進めたりしていました。

あとはIAM周りでTaskRoleとかECRへのアクセスなどで少し詰まりましたが、それらも通常コンソールでやっていてもこれくらいは詰まるかなという範疇で収まった印象です。

正直CloudFront + Fargate + Auroraなんてベタ中のベタなので、GitHubなどにそのままコピペできるサンプルコードがないのかなと思ったのですが意外と見当たらないものですね(もし知っている方がいたら教えてほしいです。Fargate単体とかはいくつもあったのですが・・・)。

悩んでいるがさっぱりわからないこと

運用をまだ始めているわけではないので、主に運用フェーズを想定するとわからないことが多いなと思っています。

  • RDSやElastiCache等のアップグレードもCDKでやるのが普通なのか。なんとなくアプデは怖いので、コンソールからやりたい気持ちがある(メンテナンスウィンドウがあるので予期せぬ事態は起きなさそうではあるが)
  • 外部サービスのAPIキーをSecret Managerで管理するのはCDKでやるほどでもない?
    • 構築するサービス特有ということを考えてもメリットが薄いと思う

以上で本記事は終わります。

脚注
  1. Nuxt v2からNext.jsへ順番に移行している背景で、CloudFrontのビヘイビアを気軽にいじれる環境構築が重要でした ↩︎

マナリンク Tech Blog

Discussion