CloudFormation & ECSの沼
本番環境へのリリースはこれまで数分で行われ、これまで特に問題も起きたことがなかったんですよね。
ところが、その日は違いまして、ECSサービスの更新が失敗しました。まあ、これはいちおうあり得る話です。しかし、ちょっといつもとちがったのは自動ロールバックが走ったところまではいいんですが、その先で、そのロールバックが失敗しちゃったんですよね。
CloudFormationスタックは UPDATE_ROLLBACK_FAILED というステータス。この状態だと、スタックは完全にロックされ、一切の更新を受け付けない状態です。これ自体、開発環境ではたまに目にしていました。だいたいこういうときは単純に続行して上書きでやり直したらうまくいくことが多いんですが……。この日は違っていました。なんど続行させても失敗します。
ロールバック失敗の原因
後から分かったことですが、僕がハマった根本的な原因は、ECSがロールバックしようとした過去のECRイメージがすでに削除されていたことでした。そこで思い出しました、先日、ぼくは運用していたECRのライフサイクルポリシーを「作成後7日経過したイメージを削除」という設定にしたんです(古いファイルがたまりすぎていて利用料金にも関係あるので減らしたかった)。そのため、ロールバック対象のイメージがすでに存在しなかったのです。
ECRのライフサイクルポリシーでは、日付ではなく世代数で管理する imageCountMoreThan(例: 30世代)を指定すべきでした……。
STEP 1:状況の切り分けと、本当の「失敗原因」の調査
スタックが固まっている問題とは別に、 「そもそも、なぜ最初の新しいデプロイが失敗したのか?」 を突き止める必要がありました。これを解決しない限り、無理やり更新をかけても同じことの繰り返しになるからです。
1-1. 揮発する「停止したタスク」
まず見たのは、ECSクラスターの 「タスク」タブ です。表示フィルタを 「停止したタスク」 に切り替えると、起動に失敗したタスクの残骸があります。そのタスクの詳細を開き、 「停止理由」 を確認します……が、これね、理由を見たら停止したタスクがわかると思うじゃないですか。まあわかるときもあります。あるんですが、ソフトウェアの起動にアプリケーションの問題で起動失敗しているときはここの理由はほとんどが「サーキットブレーカーがコンテナを停止しました」みたいなのです。
ようするに、コンテナが正常起動しない、サーキットブレーカーが異常を検知してコンテナを落とすというその一番外側の理由しか表示されないんです。そのためにはログを見る必要があります。
で。
ECSのデプロイ失敗で一番面倒なのはここです。この 「停止したタスク」は1時間くらいすると消えちゃう んですよ。理由もろとも。別にログは残っていてもよくね? と思うんですが、タスクの掃除とともに消えます。なので一時間以内にログを見ます。CloudWatch に移動してみることもできます(コピペとかしやすいのでこっちのほうがいいです)。ただ、ちなみに、ログ見てる間にタスクが消えるといきなり画面がリロードされて消えます。無慈悲だ。
1-2. マイグレーションの失敗の回避
そもそも最初のアップデートが失敗した原因は、ここでした。このアプリケーションのデータベースマイグレーションがうまくいかず、不整合を起こしていました。(複数コンテナで走ってしまうので、これは明らかに作りがよくないので直すべきなんですが)これにより別のコンテナはマイグレーションが終わってないと判断し、マイグレーションの終わりを待ち続けて、ついにはヘルスチェックで落とされるという現象がおきてました。これはつくりを直す時間がなかったので、DBをみてマイグレーションの修正そのものが適用されていることを確認し、リビジョンを強引に最新に書き換えることにしました。
STEP 2:ECSの手動更新
今回とにかく本番環境を正常に戻す必要がありました。最初の ECS の更新は上記の理由で起動に失敗していたので、ECS 自体は同じイメージでデプロイしなおせば稼働するのではないかとおもっていました。
2-1. 新しいタグをつけてタスクを作成
そこで、「サービス」から「サービスの更新」でタスク定義の新しいバージョンを作成します(空欄にすると細心のリビジョンが降られます)
そして更新させると、「デプロイのステータス」が「ロールバックの失敗」から「成功」に代わり、システムも正常稼働している状態になりました。
さてこれで、アップデート自体は成功し、ユーザが見える範囲では正常稼働に戻った状態を作ることができました。が、ECSの状態とスタックの状態に乖離があるため、このままだと次回更新時に失敗してアプリが更新できません。これには、CloudFormation内にあるスタックの UPDATE_ROLLBACK_FAILED をなんとかしないといけません。
STEP 3:状態の正常化
エラーの原因は複雑でしたが、UPDATE_ROLLBACK_FAILED から抜け出すための戦略はシンプルに考えました。「一度、絶対に成功する状態をつくり、インフラを安定させる」 というものです。バグった状態から無理やり進めるのではなく、安全なセーブポイントを作ります。
3-1. サービスの安定化
- 新しいタスク定義リビジョンを作成: 現在のタスク定義から新しいリビジョンを作り、イメージURLを latest をさすようにする(今回、URLが失われたアプリはサイドカーで Ngnix を使っていたので、サイドカーのイメージのURLも latest を指定)
- サービスを手動更新: ECSサービスの更新画面で、今作ったダミーのタスク定義リビジョンを選択し、「新しいデプロイの強制」にチェックを入れて実行。
無事、サービスは ACTIVE な状態になりました。
3-2. CloudFormationスタックのロックを解除
次に、固まったスタックのロック解除です。
- CloudFormationのコンソールで、
UPDATE_ROLLBACK_FAILED状態のスタックを選択。 - 「スタックアクション」から 「更新ロールバックを続ける」 を実行。
- しかし、これだけではまた失敗しました。手動でサービスの状態を変えてしまったので、CloudFormationの認識とズレが生じていたためです。そこで、再度同じ操作を行い、今度は失敗しているリソース(ぼくのの場合は
AWS::ECS::Service)をスキップするオプションにチェックを入れました(「回復不能になる可能性」という警告には一瞬ひるみましたが、これは次の手順で解決できると判断し、実行しました)
これで無事スタックのステータスが UPATE_ROLLBACK_COMPLETED 状態 になりました!!
ただ、このままアップデートすると、やっぱりエラーになります。最初に書いた「ロールバック先が見つからない」のと同じエラーになりました。とにかくイメージが存在しないらしい。
しかたないので、テンプレートを編集して、再デプロイのイメージを確実に存在するURLにしてデプロイを通します。テンプレートの編集はは、サービスの再デプロイの際に、Infurastructure Composer という機能を使います。開いたら、AWS::ECS::TaskDefinition リソース内のイメージ指定を、latest に書き換えました。
Resources:
MyTaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
# ...
ContainerDefinitions:
- Name: backend
Image: XXXXXX:YYYYY:backend:latest # ← ECRにあるlatestのイメージを指定
# ...
- Name: backend-ngnix
Image: XXXXXX:YYYYY:backend-ngnix:latest # ← ECRにあるlatestのイメージを指定
# ...
さて、ここまできて、最初の更新を再び適用すれば、きれい更新されるはずです!!!
が。
まだ起動に失敗します。
3-3. 副作用との遭遇:失われたDB_SECRET
調査を進めるうち、さらに根深い問題に気づきました。実はこのスタック、以前にも更新に失敗したことがあり、その際に特定のDBリソースをスキップしてロールバックを完了させていたのです。その副作用で、DBリソースから出力(Outputs)されるはずのシークレットARNが、スタックの内部で失われた状態になっていました。新しいタスクは、存在しないシークレットにアクセスしようとして起動に失敗していたのです。
ほかにもシークレットマネージャーからの出力が欠落していることがわかりました。
そこで、二つの出力で参照しているシークレットマネージャーの値ARNを手動で Secret 要素の下に作成します。
そして再デプロイ! この操作で、スタックはようやく UPDATE_ROLLBACK_COMPLETE という、更新可能な状態になりました。
STEP 4:そして、日常へ
この状態でスタックは
- 親スタック
- UPDATE_ROLLBACK_COMPLETE
- 子スタック
- UPDATE_ROLLBACK_FAILED
になりました。これは正しいのか? と思ったんですが、問題ないそうです。なので、旧作業の最後の仕上げとして、最初に失敗したいつも通りCI/CDパイプラインを再実行しました。クリーンな状態から始まったデプロイは、今度は何事もなかったかのように成功しました。
これでやっと正常な状態にもどりました。
最後に:沼から得た教訓
この一連のトラブルシューティングから、僕が学んだ教訓は以下の通りです。
- ECR の期限切れには気を付ける(世代管理がよい)
- コンテナを複数つくるときは、マイグレーションを起動時にやらせるのはよくない。マイグレーション用のコンテナを作り起動が終わってからメインのコンテナを起動するようにする。
-
UPDATE_ROLLBACK_FAILEDは絶望的な状態に見えるが、手順を踏めば必ず回復できる(スタックを削除しちゃダメ) - ECSのデプロイ失敗は、まず「停止したタスク」を見るが、すぐに失われるので急げ
- リソースをスキップしてのロールバックは、
Outputsが失われるなどの副作用を伴うことがある。 - インフラを「正常な状態」にリセットしてから上書きせよ。
この体験談が、未来のどこかで同じ沼にはまってしまった誰かの助けになることを願っています。