ECSで個人的に好みなデプロイフロー

はじめに

仕事では基本的にECSを触ることが多いのだが、デプロイフローは各社苦労しているなーというのが本業や副業でいろんな現場を見てきて感じている

個人的にこうなっていると嬉しいなーという形がだいたい固まってきたので、サンプルを作りながら整理してみた

前提としてWebのシステムであることは留意いただきたいし、リリースには様々な事情があるのもわかっているので全てをカバーしているつもりもないのであくまで個人思想

ECSと題してるけどおまけにLambdaとかCloudRunとかの話もしている

デプロイフローでやりがちな構成と問題点

1. ImageをLatestで定義して、毎回それをforce new deploymentで差し替える

イメージを常に latest で push し、aws ecs update-service --force-new-deployment で入れ替えるパターン

現状アプリがどのバージョンで動いているのかが分からなくなるのと、タスク定義のRevisionを元に戻してもロールバックにならないので運用が辛くなるので避けるべき

2. タスク定義をIaC等で持っていて、アプリ側のCIがイメージタグだけ差し替える

TerraformやCDKでタスク定義まで管理し、アプリ側のCIがイメージを更新するパターン

あるいはTerraformとCDKにタグを動的に渡して適用しちゃうってのもある

workflow_dispatch の入力やBuild時のHash値,タグ等をCIのワークフローの中で渡してデプロイするような形だと、今本番で動いているのが何なのかを知るにはCIの履歴を掘るか実際に環境を見に行くしか無い

またロールバックもアプリごとRevertしたり、前動いていたタグ等を探してそれらを実行するようにしないといけずなにかトラブったときのロールバックに手間取る

ローカルから適用する時にも、どのタグを指定するかを環境変数や引数等で渡すハメになり、数が増えれば増えるほど適用が面倒になる

思想

本当に実現したいのはWeaveworksが提唱した真のGitOps的な形だが、ECSやLambdaでは実現が難しくそのためにPipeCD等を用意するのか?と言われるとそこまででも無いので、GitがSingle Source of Truthな状況が作れていればとりあえず問題ないと思う

とりあえず大まかに実現したいのは以下

Buildとリリースは分ける

実際にBuildしたりAssetを準備するフェーズと、リリースをするタイミングは基本的に別にしたい。

コンパイル時間等が実際のリリースタイミングに乗ってくるのはリリース担当者の時間を無駄に奪うし、そもそも事前にできるものは予め用意しておけば良い。

最近だとサプライチェーン攻撃も加速してるし、予めライブラリやイメージのスキャン掛けておいて問題がないアセットだけを本番のレジストリに登録みたいなのもリリースじゃないタイミングで行えるのもメリット

またMTTR的な観点でもリリースを差し戻せば良いだけなので、素早く切り戻せる

アプリケーションのリポジトリとリリース管理用のリポジトリは分ける

アプリケーションコードのライフサイクルと、リリースのライフサイクル、インフラのライフサイクルは全て別だと思っている

アプリはガンガンMergeしてあるタイミング(日次とか週次とか月次とか、main mergeされたら好きなタイミングというのもあり)のタグを切ることでリリース内容を確定させるまでが責務

リリース管理は上記で確定したリリース内容を実際に任意の環境へ適用する

さらにはフィーチャーフラグで実際に動いている環境上で機能をON/OFFするというのも低リスクなリリースには必要になっていくとは思うが今回は扱わない

リリース用のリポジトリはmainにあるものが正であり、ワークフローの動的な値や入力を使わない

あまり物を覚えてられないタイプなので、mainブランチにあるものがSSoT! 以上! みたいな世界が良い

大体どこのリポジトリも運用がバラバラだったりして把握するのが大変になるのが嫌

また脱出ハッチとして何か突発的にローカルから適用しないといけないときも、ワークフローに入力があるとそれらを考慮してローカルから適用する必要があり、MTTRが劣化する。

そしてそういうのは得てして切羽詰まっている時にやりたくなるのだ...

そのためにImageのタグやLambdaのasset用のZIPがおいてあるS3のパスを必ずベタの値で書き込むようにする

すべての作業はPRベースで完結させる

あまり物を覚えてられないタイプなので(略

ロールバックもただリリースPRをRevertすればいいだけなので覚えることが少なく楽

もちろんDB Migration等、ステートフルなリソースへの適用はその限りではないので別途ロールバックして問題ないか?は考える必要はある

構成

サンプルはこの2つ

だいぶサボっているのでインフラリソースは実際には用意してない

アプリ側は組織体制に合わせて任意の数のリポジトリがあってよく、リリース用は中央集権的に扱う。

リリース用のリポジトリはk8sでいうmanifestリポジトリ相当のものだと思ってくれれば良い

なおAWS側のリソース(ECSクラスタ、ALB、IAMロールなど)はこの記事の範囲外で、 それらは別途インフラ用のリポジトリを作成して、そこで管理する。

個人的にはインフラもモノレポが楽だと思う。プロダクトが増えるたびにインフラ用のコードのガバナンス整えるのも面倒だし。

特にLambdaとかRuntimeのバージョン管理を無限に増えるリポジトリで管理するのは無理(そしてインフラチームが謎にバージョンアップだけをする期間が生まれたりする)

全体像

アプリリポジトリでのmainマージからリリースリポジトリでの各環境への適用までの流れはこんな感じ

flowchart TB
    subgraph app[アプリリポジトリ]
        M[PR を main にマージ] --> R["release.yml<br/>assetを build & push (sha-commit)"]
        R -->|成功後 workflow_run| T["tagpr.yml<br/>リリース PR を作成 / 更新"]
        T -->|リリース PR をマージ| TAG[タグ vYYYY.MMDD.N を push]
        TAG --> RT["release-tag.yml<br/>ECR のイメージに同じタグを付与"]
    end
    subgraph rel[リリースリポジトリ]
        D["Propose release → PR (dev/.env)<br/>auto-merge → dev に適用"]
        S["Propose release → PR (stg/.env)<br/>auto-merge → stg に適用"]
        P["Propose release → PR (prd/.env)<br/>人がマージ = 本番リリース"]
    end
    R -->|app名 / env / image| D
    RT -->|app名 / env / image| S
    RT -->|app名 / env / image| P

リリース側のディレクトリ

参考実装

https://github.com/gainings/blog-sample-release-repo

blog-sample-app/             # アプリケーション名
  dev/
    .env                   # IMAGE=..., IMAGE_NGINX=...
    ecs/
      ecspresso.yml
      ecs-task-def.json
      ecs-service-def.json
  stg/ (同じ構成)
  prd/ (同じ構成)
example-lambda-app/
  dev/{.env, lambda/function.json}
  prd/{.env, lambda/function.json}
example-cloudrun-app/
  dev/{.env, cloudrun/service.yaml}
  prd/{.env, cloudrun/service.yaml}
  • <アプリ名>/<env>/<方式>/
    • 第一階層はアプリケーション名で、アプリ側のワークフローが APP_NAME として明示する
    • 環境の下の ecs / lambda / cloudrun がデプロイ方式の宣言で、release.yml はこのディレクトリ名でどれを利用するか振り分けるだけにする
      • ここはecsだけ使うなら省略するというのもありだと思うのでお好きに。AI時代リファクタリングの手間も大したことないと思うし
  • イメージは環境直下の .env に書く
    • ecspressoもlambrollも --envfile で読めるので、定義側は {{ must_env `IMAGE` }} のまま
    • 1つのアプリがECSとLambdaの両方を持っていても、イメージの更新先はこの1ファイル

環境ごとにタスク定義の実体を持つので当然重複するが、diff -r stg prd で環境差分がそのまま見えるほうを取った。

テンプレートで共通化すると、prdだけ変えたいときに結局分岐が増えるので

ここはプロダクトによっては共通化しても良いかも。お好みで

アプリ側のワークフロー

GitHub Flow + tag運用みたいな感じで実現してみた

ここも組織に合わせて正直なんでも良い

dev環境へは mainの内容を常にリリースするようにしておく

またtagprで最新のmainからいつでもタグを切れるように、リリースPRを常に用意しておく

実際に作られるPRはこんなん

https://github.com/gainings/blog-sample-app-repo1/pull/12

tagprで生成されたPRをmergeすると、リリース側に通知してstgへは自動で反映される。prd用のPRも同時に作られるが、こちらは自動ではマージされず人がマージするまで反映されない

バージョンはCalVerが好み

継続的に出すWebアプリでSemVerの互換性の意味を維持するのは無理があるし人によってメジャーとマイナーのどっちを更新するかもブレるのであまり意味もないと思ってる

リリースされるべきタイミングで、アプリ側はリリース側の Propose release ワークフローを起動するだけにしている。

渡すのはアプリ名、環境、イメージ、auto-mergeするかの4つで、.env を書き換えてPRにするのはリリース側の仕事

アプリ側のワークフローを書き換えても、リリース側の中身は直接触れないようにしてる

リリース側のワークフロー

イメージはこんな感じ

prdのPRをマージするのが本番リリースの承認になる

mainへのpushで動く release.yml は、git diff で変更のあった <アプリ名>/<env>/<方式>/ を拾い、方式ごとの再利用ワークフローに任せる。

.env が変わったときは、その環境の下にある方式ディレクトリが全部対象になる。

この辺は正直何でも良くて、mainにMergeされたらリリースが始まるのを実現だけできていれば良い

いつ適用されるかはいつmainに入るかで決まるので、順序はアプリ側がPRを出すタイミングで表現すればよい

ロールバックしたい場合には、以下のように単純にリリースしたPRをRevertすれば良い。

https://github.com/gainings/blog-sample-release-repo/pull/35/changes

緊急時に特殊な要件が発生するならば手動でタグを書き換えるPRを作ればよいしそこはお好みで

GitHub App利用のポイント

デフォルトの GITHUB_TOKEN だといくつか問題がある

  • 他リポジトリのワークフローを起動できない
  • botが作ったPRでCIが走らない
  • pushしたタグで on: push: tags のワークフローが起動しない

なのでGitHub Appのインストールトークンを使うのだが、1つのAppに全部の権限を持たせて両方のリポジトリにインストールすると、 アプリリポジトリにある鍵でリリースリポジトリの中身が書けてしまう

アプリ側のワークフローを書き換えれば本番の定義を改ざんできるという状態は避けたいので役割ごとに分ける必要がある

App権限インストール先鍵の置き場所用途
tagpr用Contents, Pull requestsアプリリポジトリアプリリポジトリリリースPRの作成とタグ作成。App のトークンでタグをpushするので release-tag.yml が起動する
release PR作成用Actionsリリースリポジトリアプリリポジトリリリース側の Propose release を起動する。これしかできない
リリース用Contents, Pull requestsリリースリポジトリリリースリポジトリ.env を書き換えたPRを作る。botのPRでもCIが走り、auto-mergeできる

これでアプリリポジトリにある鍵では「Propose releaseを起動する」以上のことができない。何をどう書き換えてPRにするかはリリース側のワークフローが決める

さらにリリース側のmainはrulesetでPR必須、ci チェック必須、*/prd/ はCODEOWNERSの承認必須、バイパスなしにすれば鍵が漏れてもmainに直接pushはできないし、prdは人の承認なしには変わらないという状態は実現できる

おわりに

個人的な好きなデプロイ管理の思想を作ってみた

mainにあるものがリリースされているものという状態を作っておくと、今何が動いているかも、いつ誰が出したかも、ロールバックも、全部git(というかGitHub)で完結できる

特にAI時代だし、ガンガンPR作成するまではAIにやらせて最後の検証後にMergeボタンをぽちぽちするのだけは人間がやればよいのではないだろうか

もちろんシステムによってデプロイ順序を制御する必要があったり、様々な事情があるのは知っているのでgitをSSoTにするという思想だけ守って好きにカスタマイズすればよいと思う