この記事はナイル Advent Calendar 2021 1日目の記事です。


以前書いた記事では、CloudFormationを使ってFargateを構築し、radikoを録音するということをやった。これはいまでも問題なく使えている。
だがいろいろ直したいところが出てきた。当時は自分のCloudFormation力が低く、面倒で不自然な構成で作ったりしていた。それを修正したい。

以下ではCloudFormationをCFnと書く。

やりたいこと

リポジトリsankaku/docker_rec_radikoで書いていたCFnによるインフラ構成部を改善したい。
これが改善前の状態

具体的には以下を改善したい。

  • 番組ごとのECSタスクを1つのCFnテンプレートから作る
    以前は番組ごとにECSタスクのCFnテンプレート(YAMLファイル)を作っていた。これでも問題はない。ただ、番組ごとに異なるのはテンプレートのうちParametersのところだけで、ほかはまったく同じ。大半が重複しているCFnテンプレートが大量に作られることになった。重複が無意味だし、あとで見返すときもストレスになるので、できるだけ単純化したい。

  • CFnのデプロイを楽にする
    前はデプロイのとき

    aws cloudformation create-stack -stack-name docker-rec-radiko-network-stack --template-body file://./cf/network.yaml
    

    というようなコマンドをいちいち打っていた。コピペで済むものの、もう少し楽にやりたい。

  • CFnスタック間でのリソース情報の受け渡しを楽にする
    これは前回も調べて、ベストプラクティスがわからなかったもの。そのときはパラメータストア(Systems ManagerのParameter Store)に入れていた。
    たとえばスタック1で作成したサブネットをスタック2で使うとき、スタック1での作成時にサブネットIDをパラメータストアに入れる。そしてスタック2ではパラメータストアの値を見て、サブネットを取得する。というようなやり方。
    パラメータストアが絡むぶん、リソースが多くなるし、CFnテンプレートの記述量が増える。他の方法を取りたい。

  • CFnスタック間で共通に使われる定数を定義する
    これは上のと似ているが、これはリソースとは関係なく、単なる定数の受け渡しの話。
    たとえばこのリポジトリで作ったリソース名に特定のプレフィックスをつけたい。そのとき、CFnテンプレートが分割されていると、それぞれのテンプレートでいちいちそのプレフィックスの文字列を書かないといけない。そうなるとプレフィックスを変えたくなったとき、いくつものファイルで変えることになり面倒。これを書くのを1度だけにしたい。

  • Lambdaを消す
    当時はLambdaをかまさなければECSタスクのスケジュール実行をCFnでは設定できなかった。現在ではCFnがアップデートされ、直接ECSタスクを実行できる。

やったこと

これが改善後の状態
使い方はREADME参照。

CFnとシェルスクリプトを組み合わせるようにした

これで以下を実現できる。

  • 番組ごとのECSタスクを1つのCFnテンプレートから作る
  • CFnのデプロイを楽にする
  • CFnスタック間で共通に使われる定数を定義する

インフラ構成部のディレクトリ構造をこうした。

infra/
├── cfn
│    ├── cluster.yaml
│    ├── ecr.yaml
│    ├── network.yaml
│    ├── roles.yaml
│    └── task.yaml
└── sh
      ├── 01-roles.sh
      ├── 02-network.sh
      ├── 03-cluster.sh
      ├── 04-ecr.sh
      ├── constants.template.txt
      ├── constants.txt
      └── tasks
            ├── my_program.sh
            └── task.template.sh

デプロイのときは sh 下のシェルスクリプトを01から04まで順番に実行して共通的なインフラを作ったあと、tasks以下を実行して番組ごとのECSタスクを作る。
たとえば01-roles.shは次のようになっている。

#!/bin/sh

cd `dirname $0`
. ./constants.txt

CFN_FILEPATH=../cfn/roles.yaml
CFN_STACK_NAME=$PROJECT_NAME-Roles

aws cloudformation deploy --template-file $CFN_FILEPATH \
                          --stack-name $CFN_STACK_NAME \
                          --capabilities CAPABILITY_NAMED_IAM \
                          --parameter-overrides \
                            ProjectName=$PROJECT_NAME

PROJECT_NAME はconstants.txtで定義している変数。これでプレフィックスを定義する部分は一箇所だけにできた。
デプロイのときに打つコマンドも ./infra/sh/01-roles.sh で済む。

ECSタスクはCFnテンプレートとしては task.yaml のみ。これを番組に合わせてパラメータだけ変えてデプロイする。ECSタスクのデプロイのためのシェルスクリプトはtask.template.shの変数部を変更して作る。このシェルスクリプトはこんな感じ。

#!/bin/sh

cd `dirname $0`
. ../constants.txt

CFN_FILEPATH=../../cfn/task.yaml

##### ここから編集
# ECSタスク名
ECS_TASK_NAME="TemplateTask"
# ECSタスクで実行するコマンド引数
ECS_COMMAND_ARGS="TBS, 1, サンプルの番組名"
# タスクを実行するスケジュールをcronで書く(UTC)
SCHEDULE_PATTERN_UTC="0/3 * * * ? *"

# (必要な場合に変更) CPU
ECS_TASK_CPU=256
# (必要な場合に変更) メモリ
ECS_TASK_MEMORY=512
##### ここまで編集

CFN_STACK_NAME="$PROJECT_NAME-Task-$ECS_TASK_NAME"

aws cloudformation deploy --template-file $CFN_FILEPATH \
                          --stack-name $CFN_STACK_NAME \
                          --parameter-overrides \
                            ProjectName=$PROJECT_NAME \
                            ECSCommandArgs="$ECS_COMMAND_ARGS" \
                            ECSTaskSchedulerPattern="$SCHEDULE_PATTERN_UTC" \
                            ECSTaskName=$ECS_TASK_NAME \
                            ImageVersion=$IMAGE_VERSION \
                            S3BucketName=$S3_BUCKET_NAME \
                            ECSTaskCpu=$ECS_TASK_CPU \
                            ECSTaskMemory=$ECS_TASK_MEMORY

こうすれば番組ごとにCFnテンプレートを作らずに済む。ただ、このシェルスクリプトも番組によって変わるのは「ここから編集」〜「ここまで編集」の部分だけで、まだかなりの行は重複することになるが…。

CFnスタック間のリソース情報の受け渡しにはクロススタック参照を使う

前はパラメータストアを使っていた部分。

今回はクロススタック参照を使うことにした。これならリソースが増えずに済むのでパラメータストアよりシンプルになる。
(ただしクロススタック参照だと、スタック作成後は値を変えることができないというデメリットはある。今回はこれは問題にならないが、場合によっては致命的になるかもしれない。もしスタック作成後に値を変えたいなら、やはりParameter StoreやSecrets Managerを使う必要がある。)

CFnテンプレートにOutputsセクションを書く。(以下はroles.yamlの一部。)

Outputs:
  OutputLauncherRoleArn:
    Description: "ARN of Fargate Launcher Role"
    Value: !GetAtt FargateLauncherRole.Arn
    Export:
      Name: !Sub "${ProjectName}-FargateLauncherRoleArn"

こうしておくとNameのところに書いた名前で、Valueのところに書いた値をエクスポートできる。

エクスポートした値を別のスタックで使うときは(task.yamlから抜粋)

RoleArn: {"Fn::ImportValue": !Sub "${ProjectName}-FargateLauncherRoleArn"}

のように書けば、ImportValueの右側の名前でエクスポートされた値になってくれる。(詳細はドキュメント参照。)

上では中途半端にJSONの書き方が混じっている。いま試してみたら下のようにYAMLでも書けた。おそらく(CFnテンプレートをYAMLで書いているなら)YAMLで書くのが普通。

RoleArn:
  Fn::ImportValue:
    !Sub "${ProjectName}-FargateLauncherRoleArn"

(参照方法には他にもスタックのネストを使う方法がある。クロススタック参照と比べたときのメリットがよくわからなかったので今回は使わなかった。)

Lambdaを消した

AWS::Events::Ruleを使えば、Lambdaを使わずにECSタスクをスケジュール実行できる。

その他

  • ログの有効期限を設定した
    AWS::Logs::LogGroupRetentionInDays で設定できる。
  • デプロイのコマンドは aws cloudformation deploy にした
    スタックの作成のときも更新のときもこれでできる。
  • ロールをやや精密化した
    前はAWS::ECS::TaskDefinitionExecutionRoleArnTaskRoleArn の違いがよく理解できていなかったので、この2つに同じロールを書いていた。だがコンテナの中でAWS CLIを実行してS3にアップロードする場合、 s3:PutObject の権限が必要なのは後者だけのようだ。なので別々のロールを作った。
  • cfn-lintを使ってみた

おわりに

  • シェルスクリプトと組み合わせるとだいぶ楽になる
    デプロイのときに長いコマンドをコピペしたり、番組ごとに長いCFnテンプレートを用意しなくてもいいのはメリット。
  • シェルスクリプトと組み合わせると簡単に複雑になる
    「この変数、どこで定義したっけ?」みたいなことが起きそう。現在はそこまで複雑になっていないが、もしもっと大量のスタックを扱うのであればルールが必要だと思う。たとえばディレクトリ階層の深さの上限を決めたり、ディレクトリ間での参照方向を固定するなど。
    あるいは変数など考えず、すべてCFnテンプレートに直書きする運用もありなのかもしれない。
  • Lambdaを消してすっきりした
    CFnのアップデートは追う価値がある。
  • クロススタック参照は楽
    これも参照が入り乱れたりすると大変になりそうな雰囲気はある。
  • デプロイ前にcfn-lintでチェックするとスムーズ
    CFnのlinterがあることに気付いたので使ってみた。だいたいの問題は発見できるので、デプロイしまくって試行錯誤するよりもかなり時間短縮できる。

参考