📝

CloudFormationでEC2インスタンスの起動が完了したらスタックの更新を続行する方法

2021/06/23に公開

EC2インスタンスの起動が完了したらスタックの更新を続行する方法

結論
AWS::CloudFormation::Initを使いましょう!
ということなのですが、ここで出てくるcfn-initやらcfn-signalにかなりハマったので、調査した結果と実際の使用方法を紹介します。

きっかけ

とあるプロジェクトで、
「EC2がALBのヘルスチェックでhealthyになるまで更新を一時停止したい」
という目的がありました。

なぜ?

CloudFormationによるAuto Scalingグループの置換更新で起動したEC2インスタンスにアクセスできなかったためです。
問題は、
「ALBのヘルスチェックの結果に関わらず、CloudFormationの更新が完了する」
ということでした。
具体的には、新しく起動したEC2インスタンスに対するALBのヘルスチェックの結果がunhealthyなのに、古いAuto ScalingグループとEC2インスタンスがCloudFormationにより終了してしまうという挙動です。Auto Scalingグループ内のEC2インスタンスの数は常時1台という要件なので、複数台を1台ずつ更新するというローリングアップデートも使用できませんでした。

イメージ

ざっくりしたイメージですが、やっていることはこんなかんじです。

問題のパターン

  1. 更新リクエストを受けたCloudFormationは新しいAuto Scalingグループを起動し、Auto ScalingグループがEC2起動、ALBに登録を実施
  2. 新しいEC2が起動したら、ALBのヘルスチェックの結果にかかわらず、CloudFormationは古いAuto Scalingグループ削除を開始
  3. 古いグループの削除が完了したら、CloudFormationは更新完了通知をSNSからChatbot経由でSlackに送信

3の時点で新しいEC2がALBのヘルスチェックをクリアしていないと、Developerはアクセスできません。
また、Auto Scalingグループのヘルスチェックの猶予期間を超えてもヘルスチェックをクリアしない場合、EC2は異常と判断され、新しいEC2が起動します。2台目がヘルスチェックを猶予期間内にクリアできればアクセスできるようなりますが、何度やってもクリアできない場合は終了→起動の無限ループが発生します。

上記より、
「ALBのヘルスチェックの結果に関わらず、CloudFormationの更新が完了する」
ことが問題だと判断し、対応を考えました。

解決パターン

上記の問題を解決するために、以下の挙動を目指しました。

  • EC2がALBのヘルスチェックでhealthyになったらCloudFormationによる更新を続行
  • 一定時間内にhealthyにならなかったらCloudFormationによる更新を中断してロールバックする

これにより、

  • 更新完了通知が届いた時点で新しいEC2にアクセスできる
  • 更新が失敗した場合でも古いEC2は保持されているので、アクセスが失われることはなくなる

となり、新旧どちらにもアクセスできなくなるという事態は防げます。

しかし、1つだけ課題があります。
「一時的に2台のインスタンスにアクセスできる時間が発生する」
ということです。
ただし、これは後述するメンテナンス画面により解決しました。

解決手順

  • CloudFormationテンプレートファイルの修正
  • カスタムAMIからユーザーデータを実行する設定
  • ALBリスナールールの追加と自動切換え

CloudFormationテンプレートファイルの修正

以下の修正を行いました。

  1. Auto Scalingグループの更新方法の修正
  2. 起動テンプレートにユーザーデータを追記
  3. Auto ScalingグループにAWS::CloudFormation::Initを追記

1. Auto Scalingグループの更新方法の修正

もともとAuto Scalingグループの更新は以下のような設定でした。

"UpdatePolicy": {
    "AutoScalingReplacingUpdate": {
      "WillReplace": "true"
    }
  },
  "CreationPolicy": {
    "ResourceSignal": {
      "Count": 0
    }
  }

変更後は以下のようになりました。

"UpdatePolicy": {
    "AutoScalingReplacingUpdate": {
      "WillReplace": "true"
    }
  },
  "CreationPolicy": {
    "ResourceSignal": {
      "Count": 1, //変更
      "Timeout": "PT15M" //追記
    }
  }

この変更により、起動したEC2から成功シグナルが15分以内に送信されなければロールバックするという挙動になります。
詳しくはUpdatePolicy 属性 - AWS CloudFormationCreationPolicy 属性 - AWS CloudFormationをご覧ください。

2. 起動テンプレートにユーザーデータを追記

こちらを参考に"Type": "AWS::EC2::LaunchTemplate"に以下を追記しました。

"UserData": {
    "Fn::Base64": {
      "Fn::Join": [
        "",
        [
          "<script>\n",
          "cfn-init.exe -v -s ",
          {
            "Ref": "AWS::StackId"
          },
          " -r <Auto Scalingグループの論理ID>",
          " --configsets <cfn-initで実行するconfigSets名>",
          " --region ",
          {
            "Ref": "AWS::Region"
          },
          "\n",
          "cfn-signal.exe -e %ERRORLEVEL% --stack ",
          {
            "Ref": "AWS::StackId"
          },
          " --resource <Auto Scalingグループの論理ID> --region ",
          {
            "Ref": "AWS::Region"
          },
          " --success true",
          "\n",
          "</script>",
          "<persist>true</persist>"
        ]
      ]
    }
  },

上記により以下のような挙動になります。

  • UserDatacfn-init.exeを呼び出す
  • configsetsで後述のAWS::CloudFormation::Init内の処理を実行する
  • 処理が完了したらcfn-signal.exeで成功シグナルを送信する
  • CloudFormationは成功シグナルを受け取ったら、Auto Scalingグループは更新完了と判断して後続の更新を行う。

3. Auto ScalingグループにAWS::CloudFormation::Initを追記

こちらを参考に、"Type": "AWS::AutoScaling::AutoScalingGroup"に以下を追記しました。

"Metadata": {
    "AWS::CloudFormation::Init": {
      "configSets": {
        "HealthCheck": ["verify_instance_health"]
      },
      "verify_instance_health": {
        "files": {
          "C:\\cfn\\healthcheck.ps1": {
            "content": {
              "Fn::Join": [
                "",
                [
                  "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
                  "\n",
                  "$data = Invoke-WebRequest http://169.254.169.254/latest/meta-data/instance-id -UseBasicParsing",
                  "\n",
                  "$id = $data.Content",
                  "\n",
                  "$state = Get-ELB2TargetHealth -<ターゲットグループのARN> -Target @{Id=$id} -Select TargetHealthDescriptions.TargetHealth.State",
                  "\n",
                  "echo $state >> C:\\cfn\\state.log",
                  "\n",
                  "while ($state -ne \"healthy\") {",
                  "$state = Get-ELB2TargetHealth -<ターゲットグループのARN> -Target @{Id=$id} -Select TargetHealthDescriptions.TargetHealth.State",
                  "\n",
                  "echo $state >> C:\\cfn\\state.log",
                  "\n",
                  "Start-Sleep -s 10",
                  "\n",
                  "}",
                  "\n",
                  "echo \"done\" >> C:\\cfn\\state.log"
                ]
              ]
            }
          }
        },
        "commands": {
          "ELBHealthCheck": {
            "command": "powershell.exe -ExecutionPolicy Bypass C:\\cfn\\healthcheck.ps1",
            "waitAfterCompletion": 0
          }
        }
      }
    }
  },

上記により以下のような挙動になります。

  • 先述のcfn-init.exeにより実行される
  • C:\cfn\healthcheck.ps1というPowerShellファイルを作成
  • ファイルには以下のスクリプトが記述される
    • スクリプトについて詳しくはこちらをご覧ください。
# UTF8にエンコード
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# 起動したEC2インスタンス自身のIDを取得
$data = Invoke-WebRequest http://169.254.169.254/latest/meta-data/instance-id -UseBasicParsing
$id = $data.Content

# ALBによるヘルスチェックの結果を取得
$state = Get-ELB2TargetHealth -TargetGroupArn arn:aws:elasticloadbalancing:ap-northeast-1:467964259659:targetgroup/Study-alb-tg/dded41d146814194 -Target @{Id = $id} -Select TargetHealthDescriptions.TargetHealth.State
echo $state >> C:\cfn\state.log

# ヘルスチェックをクリアするまで10秒ごとに結果を取得
while ($state -ne "healthy") {
    $state = Get-ELB2TargetHealth -TargetGroupArn arn:aws:elasticloadbalancing:ap-northeast-1:467964259659:targetgroup/Study-alb-tg/dded41d146814194 -Target @{Id = $id } -Select TargetHealthDescriptions.TargetHealth.State
    Start-Sleep -s 10
    echo $state >> C:\cfn\state.log
}
echo "done" >> C:\cfn\state.log
  • healthcheck.ps1が実行される

カスタムAMIからユーザーデータを実行する設定

詳しくはこちらをご覧ください。

ALBリスナールールの追加と自動切換え

詳しくはこちらにまとめましたが、以下の手順を実施しました。

  1. ALBのリスナールールにメンテナンスページを追加
  2. リスナールールを切り替えるLambda関数を作成
  3. CloudFormation → SNS → Lambda → ALBの経路で自動切換え

まとめ

今回はEC2インスタンスの起動が完了したらスタックの更新を続行する方法を紹介しました。
cfn-initcfn-signalだけでなくPowerShellスクリプトの作成やカスタムAMIからユーザーデータを実行する方法など、ハマりまくりました。改めて以下にポイントをまとめておきます。

  • CreationPolicyCountTimeoutを指定する
  • ユーザーデータからcfn-init.exeを呼び出す
  • cfn-init.exeからAWS::CloudFormation::Initの処理を実行する
  • AWS::CloudFormation::Initの処理が終了したらcfn-signalを送信する
  • スクリプトは頑張って作る
  • カスタムAMIからユーザーデータを実行できるように設定する
  • ALBのリスナールールでメンテナンス画面を表示する

今後何かの参考になれば幸いです。

参考資料

AWS::CloudFormation::Init - AWS CloudFormation
Auto Scaling インスタンスのヘルスチェック - Amazon EC2 Auto Scaling (日本語)
UpdatePolicy 属性 - AWS CloudFormation
CreationPolicy 属性 - AWS CloudFormation
AWS CloudFormation の Amazon EC2 Windows インスタンスから送信される CREATE_COMPLETE シグナルのトラブルシューティング
aws-cloudformation-templates/AutoScalingRollingUpdates.yaml at master · awslabs/aws-cloudformation-templates · GitHub

Discussion