CloudFormationで構築したAutoHealing構成で起動テンプレートのAMI設定更新をドリフトさせずに運用する

# はじめに
ほうき星です。
AutoScalingGroupを使用し最小：1、最大：1のAutoHealingを構成する場合、起動中のインスタンスからAMIを取得し起動テンプレートやAutoScalingGroupの設定を更新する運用を行うことがあります。
この更新作業はLambda関数等を使用し自動化されることが多いですが、CloudFormationを使用してリソースを定義している場合は少し工夫しないと**ドリフト**が発生します。
本記事ではドリフトを回避しつつAMIを更新する運用方法を紹介します。

# AutoHealing構成時のAMI設定の更新運用
AutoHealing構成(特に最小：1、最大：1)の時に新しく起動してくるインスタンスを最新の状態に保つため、起動中のインスタンスからAMIを取得し起動テンプレートやAutoScalingGroupの設定を更新する運用を行うことがあります。

![AutoHealingAMI更新作業.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/b4929168-9035-4188-a408-9cd5e38e1115.png)

この運用では、CloudFormationテンプレートでAutoHealing構成を定義している場合に、次の課題が発生します。

## 課題：AMIの更新によるCloudFormationスタックのドリフト
CloudFormationはAutoScalingGroupの起動テンプレートバージョンの指定において`$Latest`や`$Default`を使用することはできません。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-autoscaling-autoscalinggroup-launchtemplatespecification.html#cfn-autoscaling-autoscalinggroup-launchtemplatespecification-version

そのため前述の運用において取得したAMIで新しい起動テンプレートバージョンを作成するだけではなく、AutoScalingGroupが参照する起動テンプレートのバージョン指定も併せて変更する必要があり、これによりドリフトが発生します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/34259b33-5081-4375-b222-563df9a924c5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/d424c926-234b-49e1-b67f-cbe56ad7fab2.png)

>※起動テンプレートはCloudFormation外で新しいバージョンが作成されてもドリフトを検知しません

# 解決策
## パターン①：SSMパラメータを活用する
起動テンプレートのAMI指定ではAMIを直接指定する他にSSMパラメータを使用することができます。

https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/using-systems-manager-parameters.html#using-systems-manager-parameters-create-launch-template

これを利用しCloudFormationテンプレート**外**で定義したSSMパラメータを起動テンプレートで指定し、AMI取得後にSSMパラメータをLambda等を用いて更新することで、起動テンプレート及びAutoScalingGroupの設定を変更することなく(=ドリフトさせずに)運用することが可能です。

:::note warn
SSMパラメータ(AWS::SSM::Parameter)をCloudFormationテンプレート**内**で定義し、それをLambda等で更新してしまうとSSMパラメータ部分がドリフトしてしまいます。
必ずCloudFormationテンプレート**外**で事前に定義したものを更新します。
:::

### CloudFormationテンプレートサンプル
以下はSSMパラメータを使用するAuto Healing構成を定義するCloudFormationテンプレート及びAWSBackupの`COMPLETED`イベントをトリガーにSSMパラメータを作成したAMIで更新するサンプルです。

```yml:solution1.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Stack of solution 1

Parameters:
  IamInstanceProfileArn:
    Type: String
    Description: Enter the arn of the instance profile to be used for the instance.

  ImageIdParameterName: # CloudFormationテンプレート外で定義したSSMパラメータ名を指定する
    Type: AWS::SSM::Parameter::Name
    Description: Enter the parameter name of the parameter store where you filled in the ImageId.

  SecurityGroupIds:
    Type: List<AWS::EC2::SecurityGroup::Id>
    Description: Enter the security group ids to be used for the instance.

  VPCZoneIdentifier:
    Type: List<AWS::EC2::Subnet::Id>
    Description: Enter the subnet ids to be used for the instance.

Resources:
  Solution1LaunchTemplate: # 起動テンプレート
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: solution1-launch-template
      LaunchTemplateData:
        IamInstanceProfile:
          Arn: !Ref IamInstanceProfileArn
        ImageId: !Sub resolve:ssm:${ImageIdParameterName} # SSMパラメータから取得
        InstanceType: t2.micro
        SecurityGroupIds:
          !Ref SecurityGroupIds
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: Name
                Value: solution1-instance
              - Key: ImageIdParameterName
                Value: !Ref ImageIdParameterName

  Solution1AutoScalingGroup: # AutoScalingGroup(Auto Healing)
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      AutoScalingGroupName: solution1-auto-scaling-group
      LaunchTemplate:
        LaunchTemplateId: !Ref Solution1LaunchTemplate
        Version: !GetAtt Solution1LaunchTemplate.LatestVersionNumber
      MaxSize: 1
      MinSize: 1
      VPCZoneIdentifier:
        !Ref VPCZoneIdentifier

```
* `solution1.yml`テンプレートは事前準備のSSMパラメータ名をパラメータ`ImageIdParameterName`として定義
* 起動テンプレートの`ImageId`プロパティでは`!Sub resolve:ssm:${ImageIdParameterName}`とすることでSSMパラメータを指定

### AWSBackupをトリガーにSSMパラメータを更新するServerlessFunctionサンプル
```yml:solution1-update-parameter.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Lambda function to update SSM Parameter Store with AMI ID after AWS Backup completes.

Resources:
  UpdateImageIdInParameterStoreFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: UpdateImageIdInParameterStore
      Runtime: python3.12
      Handler: lambda_function.lambda_handler
      MemorySize: 128
      Timeout: 30
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ec2:DescribeImages
                - ssm:PutParameter
              Resource: "*"
      Events:
        BackupCompletedEvent:
          Type: EventBridgeRule
          Properties:
            Pattern:
              source:
                - "aws.backup"
              detail-type:
                - "Backup Job State Change"
              detail:
                state:
                  - "COMPLETED"

```
```python:lambda_function.py
import boto3

def lambda_handler(event, context):
    print(f"{event=}")

    # AWSBackupイベントから、ImageIdを取得
    image_id: str = event["resources"][0].split("/")[-1]

    # Imageに付与された'ImageIdParameterName'タグを取得
    describe_images_response: dict = boto3.client("ec2").describe_images(ImageIds=[image_id])
    specific_tag: list[str] = [
        tag["Value"]
        for tag in describe_images_response["Images"][0]["Tags"]
        if tag["Key"] == "ImageIdParameterName"
    ]
    if not specific_tag:
        print("Tag: ImageIdParameterName is not found, Skipped.")
        return

    parameter_name: str = specific_tag[0]

    # 取得したタグを元に、SSMパラメータにImageIdを書き込む
    boto3.client("ssm").put_parameter(
        Name=parameter_name,
        Value=image_id,
        Overwrite=True
    )

    print(f"ImageId: {image_id} is written to SSM Parameter Store: {parameter_name}")

```
* AWSBackupの完了をトリガーにLambda関数を実行
* 作成されたAMIに特定のタグ：ImageIdParameterName が存在するかチェック、存在すれば値として設定されたSSMパラメータを作成されたAMIで更新

<details><summary>AWSBackup後の動作結果</summary>

![solution1_lambda.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/a98520fa-128b-4954-b120-b3bb8e7c3e1c.jpeg)
![solution1_update後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/1955aa74-d9bf-47c5-9abb-d0ab3f2668c1.png)
* AWSBackupの完了をトリガーにLambda関数が動作、SSMパラメータを更新

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/b9f73096-ab43-46cc-8f5b-6decab9facd0.png)
* CloudFormationテンプレート**外**で作成したSSMパラメータを更新しただけなので、ドリフト無し

</details>

## パターン②：AMIをCloudFormationのパラメータとして定義し、スタックの更新を行う
CloudFormationのパラメータとして`ImageId`を定義し、AMI取得後にLambda等を使用し`ImageId`パラメータに最新のAMIを指定しスタックを更新することで、こちらも(当たり前ですが)ドリフトなく運用することができます。

:::note warn
1つのテンプレートを使用して複数のAutoHealing構成を定義している場合、スタック更新のタイミング等を考慮・検討する必要があります。
:::

### CloudFormationテンプレートサンプル
```yml:solution2.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Stack of solution2

Parameters:
  IamInstanceProfileArn:
    Type: String
    Description: Enter the arn of the instance profile to be used for the instance.

  ImageId:
    Type: AWS::EC2::Image::Id
    Description: Enter the image id to be used for the instance.

  SecurityGroupIds:
    Type: List<AWS::EC2::SecurityGroup::Id>
    Description: Enter the security group ids to be used for the instance.

  VPCZoneIdentifier:
    Type: List<AWS::EC2::Subnet::Id>
    Description: Enter the subnet ids to be used for the instance.

Resources:
  Solution2LaunchTemplate: # 起動テンプレート
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: solution2-launch-template
      LaunchTemplateData:
        IamInstanceProfile:
          Arn: !Ref IamInstanceProfileArn
        ImageId: !Ref ImageId # CloudFormationパラメータを指定
        InstanceType: t2.small
        SecurityGroupIds:
          !Ref SecurityGroupIds
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: Name
                Value: solution1-instance

  Solution2AutoScalingGroup: # AutoScalingGroup(Auto Healing)
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      AutoScalingGroupName: solution2-auto-scaling-group
      LaunchTemplate:
        LaunchTemplateId: !Ref Solution2LaunchTemplate
        Version: !GetAtt Solution2LaunchTemplate.LatestVersionNumber
      MaxSize: 1
      MinSize: 1
      VPCZoneIdentifier:
        !Ref VPCZoneIdentifier

```
* `solution2.yml`テンプレートはパラメータ`ImageId`を定義
* 起動テンプレートの`ImageId`プロパティでは`!Ref ImageId`としてパラメータ`ImageId`を指定

### AWSBackupをトリガーにスタックを更新するServerlessFunctionサンプル
```yml:solution2-update-stack.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Lambda function to update CloudFormation stack's ImageId parameter after AWS Backup AMI completed.

Resources:
  UpdateStackImageIdFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: UpdateStackImageIdFunction
      Runtime: python3.12
      Handler: lambda_function.lambda_handler
      MemorySize: 128
      Timeout: 30
      Policies:
        - AWSLambdaBasicExecutionRole
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - ec2:DescribeImages
                - cloudformation:DescribeStacks
                - cloudformation:UpdateStack
              Resource: "*"
      Events:
        BackupCompletedEvent:
          Type: EventBridgeRule
          Properties:
            Pattern:
              source:
                - "aws.backup"
              detail-type:
                - "Backup Job State Change"
              detail:
                state:
                  - "COMPLETED"

```
```python:lambda_function.py
import boto3

def lambda_handler(event, context):
    print(f"{event=}")

    # AWSBackupイベントから、ImageIdを取得
    image_id: str = event["resources"][0].split("/")[-1]

    # Imageに付与された'StackName'タグを取得
    describe_images_response: dict = boto3.client("ec2").describe_images(ImageIds=[image_id])
    specific_tag: list[str] = [
        tag["Value"]
        for tag in describe_images_response["Images"][0]["Tags"]
        if tag["Key"] == "StackName"
    ]
    if not specific_tag:
        print("Tag: StackName is not found, Skipped.")
        return

    stack_name: str = specific_tag[0]

    # スタックの現在パラメータを取得
    cfn = boto3.client("cloudformation")
    stack: dict = cfn.describe_stacks(StackName=stack_name)["Stacks"][0]
    current_parameters: list[dict] = stack.get("Parameters", [])

    # パラメータを更新するための新しいリストを作成
    new_parameters: list[dict] = []
    image_id_updated: bool = False

    for param in current_parameters:
        if param["ParameterKey"] == "ImageId":
            new_parameters.append({
                "ParameterKey": "ImageId",
                "ParameterValue": image_id
            })
            image_id_updated = True
        else:
            # 変更しないパラメータは「UsePreviousValue: True」で指定
            new_parameters.append({
                "ParameterKey": param["ParameterKey"],
                "UsePreviousValue": True
            })

    if not image_id_updated:
        print(f"Parameter: ImageId not found in stack {stack_name}. Skipped update.")
        return

    # スタック更新を実行
    response: dict = cfn.update_stack(
        StackName=stack_name,
        UsePreviousTemplate=True,
        Parameters=new_parameters,
        Capabilities=["CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
    )
    print(f"Stack update started: {response["StackId"]}")

```
* AWSBackupの完了をトリガーにLambda関数を実行
* 作成されたAMIに特定のタグ：StackName が存在するかチェック、存在すれば値として設定されたスタックのImageIdパラメータを作成されたAMIでスタックを更新

<details><summary>AWSBackup後の動作結果</summary>

![update実施ログ.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/6b4cac29-6785-4aa4-901c-47c786c646db.jpeg)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/bcdc0a85-6a7d-48d3-94bb-4594c638c9f7.png)
* AWSBackupの完了をトリガーにLambda関数が動作、`ImageId`パラメータを変更しスタックの更新を実施

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3870591/01dd7283-83d8-4389-be74-cd82eb5212c3.png)
* 当然ドリフトもありません

</details>

# さいごに
この記事ではドリフトを回避しつつAMIを更新する方法を2つ紹介しました。
ドリフトを発生させることなく柔軟な運用を実現するためのテクニックの一つとして誰かの役に立てば幸いです。