エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

ECSのTask数がいつの間にか0に?Task消失事件の顛末

【デジカルチーム ブログリレー5日目】

デジカルチームの井上 渉 (@wtr_in) です。米がなければということで、餅をよく食べています。実は餅はお正月以外も食べて良いんですよ皆さん。

さて、2024 年 7 月と結構前の話になりますが、AWS で以下のようなアップデートがありました。

aws.amazon.com

このアップデートにより ECS が Task を起動する際にイメージを特定する挙動が変わったのですが、デジカルではその影響で、テスト環境の ECS Service の Task 数がいつの間に 0 になる というトラブルを経験しました。割とエッジケースなので、多くの方が遭遇することはないとは思いつつ、ある意味面白い事象だったのでその内容を紹介しようと思います。

どんなアップデートだったか?

前提知識として、ECS でコンテナイメージを実行する際には、以下のようなリソースを作成します。

  • Task Definition: 実行したいコンテナイメージ名や割り当てる CPU, Memory の量などを含む定義
  • Task: Task Definition に基づいて起動されたコンテナのインスタンス
  • Service: 指定された数の Task を維持・管理する仕組み。Service が Task Definition を使用して Task を起動する

このアップデート前は、Service が Task を起動する際は、Task Definition に書かれたコンテナイメージの Tag を見て、単純にその時点で Tag に紐づくイメージを Pull していました。

一方アップデート後は、ECS Task が起動時にイメージを取得する際に、Image Digest と呼ばれるイメージ固有のハッシュ値を用いて特定するような内部挙動になりました。

参考:とある ECR のイメージ一覧画面。Image tag と Digest の値の存在がわかる

具体的には、Service をデプロイして 1 つ目の Task を起動するときに、Task Definition で指定された Tag を含むイメージ名から Image Digest を解決(特定)し、以後 Service 内で新たに Task を立ち上げる際には初回に特定された Image Digest を指定してイメージを Pull するようになりました。*1

この仕様変更は、いわゆる latest タグのように、一つのタグを常に上書きする運用を行った場合に、Task の起動タイミングによってイメージのバージョンがバラバラになってしまう、という問題に対する解決策として行われたアップデートと理解しています。

Task Definition の情報だけでは実際に Task で実行されるコンテナイメージを特定できなくなるのが一抹の気持ち悪さを感じなくもないですが、まあ良さそうなアップデートですよね。とはいえ、それほど目立つアップデートでもないので、私は当初この変更に気づいていませんでした。

そして起きた ECS Task 消失事件 🚨

ある日チームの Slack で、テスト環境のとあるサービスがうまく動作していないぞ、という話が上がりました。

確認したところ、当該サービスの Task が消えて 0 になっています。いろいろ見ていると、ECS Service が Task を立ち上げようとした際に以下のようなエラーが発生していたことがわかりました。

CannotPullContainerError: pull image manifest has been retried 1 time(s):
failed to resolve ref ***.dkr.ecr.ap-northeast-1.amazonaws.com/***@sha256:***: not found

スケジューラが新たに Task を立ち上げようと Image Digest まで指定して Pull したが失敗した、ということのようでした。

事件の原因 🔍

イメージを保存している Elastic Container Registry (ECR) のリポジトリを見てみると、Task Definition で指定していた Tag のイメージは存在するものの、上記のエラーメッセージに書かれた Image Digest とは異なっており、Pull しようとした Digest のイメージは確かに存在しませんでした。

なぜこのようなことが起きたのでしょうか?詳しく調べてみると、以下の要因がかみ合ったために起きた事象だということがわかりました。

  • (1) 上記のアップデートによる仕様変更(前述のとおり)
  • (2) ECR で設定していた Lifecycle Policy
  • (3) Image Tag と Image Digest がユニークとなる条件の違い

要因(2) ECR で設定していた Lifecycle Policy

事象の発生したサービス用の ECR のリポジトリでは、Lifecycle Policy として「Tag なしイメージは 32 日経過後に削除する」というルールが設定されていました。

以前の ECS の挙動であれば、同一 Tag が Push されて Tag なしになったイメージはその後使われることはなかったので、容量節約のために設定されていたものです。

要因(3) Image Tag と Image Digest がユニークとなる条件の違い

デジカルでは、CDK ベースのデプロイ管理ツールを利用しており、コンテナイメージのビルドの際には CDK のライブラリに含まれる DockerImageAsset というクラスを使って Tag を生成しています。

生成される Tag は、Build context 全体の Fingerprint からハッシュを計算したもの*2なので、Build context として渡すソースコードがわずかでも異なれば必ず Tag も異なるはずです。

一方で、Image Digest は、ビルド結果のイメージの内容から計算される sha256 形式のハッシュ値です。こちらは最終的なイメージの内容がわずかでも異なれば必ず異なることになります。

「Build context が同一」と「ビルド結果が同一」というのは、ほとんど一致しそうに思えますが、実際には、原理的に違うものから生成している以上、「どちらかは同一だがどちらかは異なる」というケースが起こり得ます。

今回問題になったのは、ビルドを行った際にこの Tag が同一になるにもかかわらず、最終的にビルドされたイメージの Image Digest が異なるというケースでした。*3*4

これらの要因の組合せにより、以下のような流れでデプロイを行うと Task が消える事象が起きる状態になっていました。

  1. ECR へのイメージ build / push と ECS Service のデプロイを行う
    • Service で利用される Image Digest が確定する
  2. ソースコードの中身が同一の状態で ECR へのイメージの build / push のみ実行する
    • Tag は同一だが、Image Digest は異なる(場合がある)
    • push すると、Tag は同一なので上書きされ、古いイメージは Tag なしイメージになる
  3. 32 日経過後、Tag なしになった古いイメージが自動削除される
  4. この状態で、ECS Fargate 側の何らかの都合で Task の置き換えが起きた際に、1. のデプロイ時に特定した Image Digest を持つイメージはすでに自動削除されていて Pull に失敗する
  5. 最終的に Task が 0 に至る

行った対応 🛠️

イメージの build / push のみ行い、Service のデプロイを行わない、という通常行わない運用の場合にのみ発生する事象だったので、あまり深刻な問題ではありませんでしたが、念のため対処を行うことにしました。

直接の問題の一つが、同一 Tag のイメージを上書き Push できるところだったので、デプロイ管理ツールを改修し、イメージの Push 前に同一の Tag がすでに存在するかチェックし、存在する場合は Push しないようにしました。

またこれにより Tag なしイメージが新たに発生することは(ほぼ)なくなり、自動削除の Policy もほぼ意味をなさなくなるので、このPolicy についても削除しました。*5

参考までに、他にも以下のような解決方法もありました。

Task Definition で versionConsistency: false を指定する

versionConsistency: false を明示的に指定することで、アップデート前の挙動に戻すこともできるようです。ただ、個人的には(他に方法がない場合を除いて)極力デフォルト・推奨設定を使いたい派なので、今回は採用しませんでした。

docs.aws.amazon.com

ECR の設定で Tag Immutability を有効にする

ECR の設定にある Tag Immutability というオプションを有効にすることで、Tag の上書きを禁止できます。しかしデジカルでは docker buildx で指定するビルドキャッシュを ECR に配置しており、このキャッシュは ECR 上で同一名称で常に上書きできる必要があるため、今回はこの機能は利用しませんでした。

まとめ

まとめると、ECS はタスク定義に書かれた Tag だけでイメージを特定しているのではなく、最初の Task 起動時の Image Digest で特定するようになったのでうっかり古いイメージを消さないように気をつけようね、という話でした。

AWS をはじめとするパブリッククラウドを利用していると、毎月・毎週のように多くのアップデートがあり、すべて追いかけるのはなかなか難しいものです。事前に把握できるものは潰しつつ、起きてしまった問題についても、本番での大きな障害に至る前に早期に検出できるように、引き続き改善し続けていきたいところです。

We are hiring!

私が所属するデジカルチームでは、クリニックの診療を支えるクラウド型電子カルテであるエムスリーデジカルを開発しています。 開発チームの紹介資料もありますので是非ご覧ください!

speakerdeck.com

また、エムスリーではエンジニアを絶賛募集中です。 興味を持って頂けた方、カジュアル面談や採用へのご応募をお待ちしています。

jobs.m3.com

*1:詳細な挙動については、公式ドキュメントを参照ください。 https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/deployment-type-ecs.html#deployment-container-image-stability

*2:例えば "75bd5825f8ddb275764bcb0cc59f86007f79e65158c5b606d61288368cc91448" といったランダムな文字列になります。

*3:ビルド過程での何かしら非決定的な要素やメタデータなどが影響していると推測されますが、あまり詳しくは調べられていません。

*4:逆に、Image Digest が一致するのに Build context の違いで Tag が変わってしまい困っているケースもあるようです。 https://github.com/aws/aws-cdk/issues/30937

*5:同一 Tag の存在チェックを行っても、ほぼ同時にビルドが完了した場合の Race condition で意図せず Tag が上書きされる可能性は残るので、その点でも Policy は消した方が安心でした。