EC2 Auto Scalingグループ 起動テンプレートの自動更新

起動テンプレート、便利ですよね。Auto Scalingグループで参照するものを「Latest」にしておけば、デプロイのたびにAuto Scalingグループの設定を変更する必要がありません。

起動テンプレートの更新作業をデプロイ後とかに毎回やっていたのですが、
 AMI取得
  ↓
 起動テンプレートを更新(新しいバージョンを作成)
たったこれだけの作業でも、手作業でやると地味に面倒だったり、作業ミスや作業漏れが発生しがちで残念な感じです。
起動設定の更新同様、自動化してしまいます。

※レガシーですが、レガシーゆえに最近の情報が少なかったので、起動設定の自動更新についても記事を書きました

Lambda関数による実装

起動テンプレートを使っている場合、Auto Scalingグループの更新は不要なため、AMI取得元のインスタンスのNameを指定して、そこから必要な情報を取得していきます。
(Auto Scalingグループに属するインスタンスには、共通のNameタグを付けています)

import boto3
from botocore.exceptions import ClientError
import datetime as dt


timestamp = (dt.datetime.now() + dt.timedelta(hours=9)).strftime('%Y%m%d-%H%M')

ec2 = boto3.client('ec2')


def get_instance(name_tag_value):
    try:
        filter_key = 'tag:aws:autoscaling:groupName'
        reservations = ec2.describe_instances(
                Filters=[{'Name':filter_key,'Values':[name_tag_value]}])
        return reservations['Reservations'][0]['Instances'][0]
    except ClientError as e:
        print('#ClientError!! at get_instance()')
        raise e


def make_image_name(instance_name):
    # Make image name like: test-ec2-instance-20210614-1300
    try:
        return instance_name + '-' + timestamp
    except ClientError as e:
        print('#ClientError!! at make_image_name()')
        raise e


def create_ami(image_name, instance_id, description):
    # Create image NO-reboot.
    try:
        image = ec2.create_image(
                InstanceId=instance_id,
                # DryRun=True,  # For test.
                Name=image_name,
                Description=description,
                NoReboot=True,
            )
        return image['ImageId']
    except ClientError as e:
        print('#ClientError!! at create_ami()')
        raise e


def update_launch_template(target_launch_template_id, ami_id, description):
    # Update target launch template.
    try:
        response = ec2.create_launch_template_version(
                LaunchTemplateId=target_launch_template_id,
                VersionDescription=description,
                SourceVersion="$Latest",
                LaunchTemplateData={
                    "ImageId": ami_id
                }
            )
        print(f"New launch template created with AMI {ami_id}")
    except ClientError as e:
        print('#ClientError!! at make_image_name()')
        raise e


def set_launch_template_default_version(target_launch_template_id):
    try:
        response = ec2.modify_launch_template(
                LaunchTemplateId=target_launch_template_id,
                DefaultVersion="$Latest"
            )
        print("Default launch template set to $Latest.")
    except ClientError as e:
        print('#ClientError!! at set_launch_template_default_version()')
        raise e


def main_process(name_tag_value, description=''):
    try:
        # Get target instance id.
        target_instance = get_instance(name_tag_value)
        instance_id = target_instance['InstanceId']
        print(instance_id)
    
        # Get target launch template id.
        for tag in target_instance['Tags']:
            if tag['Key'] == 'aws:ec2launchtemplate:id':
                target_launch_template_id = tag['Value']
                break
        print(target_launch_template_id)
    
        # Make AMI name.
        image_name = make_image_name(name_tag_value)
        print(image_name)
    
        # Create AMI from target instance.
        if not description:
            description = f'Lambda create. id:{instance_id}'
        ami_id = create_ami(image_name, instance_id, description)
        print(ami_id)
    
        # Update Launch Template
        update_launch_template(target_launch_template_id, ami_id, description)
    
        # Update Launch Template default version.
        set_launch_template_default_version(target_launch_template_id)
    except ClientError as e:
        print(e)


def lambda_handler(event, context):
    target_instances = event['target_instances']
    
    for target_instance in target_instances:
        name_tag_value = target_instance['tag_name']
        description = target_instance['description']
        
        print(name_tag_value)

        main_process(name_tag_value, description)
        
    return

大まかな処理の流れは下記のとおりです。

  1. 指定のインスタンスNameを持つインスタンスを適当に1つ取得
  2. 対象のインスタンスに紐付いた起動テンプレートのIDを取得
  3. 対象のインスタンスからAMIを作成
  4. 取得したAMIを元に起動テンプレートを更新(新しいバージョンを作成)
    1. ソースとして$Latestを指定
    2. ImageIdとして今取得したAMIを指定
  5. 作成した起動テンプレートのバージョンを、デフォルトに指定

また、今回のコードには含めていないものの、古い起動テンプレートを削除する処理も可能です。

例えば、下記のようなdelete_previous_version()を作成し、set_launch_template_default_version()から呼び出すようにしてみると、起動テンプレートの更新後に、3つ前のバージョンが自動で削除されるようになります。

def delete_previous_version(target_launch_template_id, previous_version):
    try:
        response = ec2.delete_launch_template_versions(
                LaunchTemplateId=target_launch_template_id,
                Versions=[
                    previous_version,
                ]
            )
        print(f"Old launch template {previous_version} deleted.")
    except ClientError as e:
        print('#ClientError!! at delete_previous_version()')
        print(e)

def set_launch_template_default_version(target_launch_template_id):
    try:
        response = ec2.modify_launch_template(
                LaunchTemplateId=target_launch_template_id,
                DefaultVersion="$Latest"
            )
        print("Default launch template set to $Latest.")

        previous_version = str(
                int(response["LaunchTemplate"]["LatestVersionNumber"]) - 3)
        print(previous_version)
        delete_previous_version(target_launch_template_id, previous_version)
    except ClientError as e:
        print('#ClientError!! at set_launch_template_default_version()')
        raise e

Lambda関数には、通常のログ作成関連の権限の他に、下記のような権限を与えておきます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateImage",
                "ec2:DescribeInstances"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": "ec2:DescribeLaunchTemplateVersions",
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:ModifyLaunchTemplate",
                "ec2:DeleteLaunchTemplateVersions",
                "ec2:CreateLaunchTemplateVersion"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

起動設定のときと同様、手動で実行する際には、テストイベントに下記のようなデータを入れてテストを走らせます。(いつもの)

※descriptionを空にした場合、「Lambda create. id:{instance_id}」という定型文が入るようにしています。

{
  "target_instances": [
    {
      "tag_name": "test-ec2-instance",
      "description": "hogehoge"
    }
  ]
}

これにより、起動テンプレートの更新作業が
 対象とするEC2インスタンスのNamedescriptionを書き換える
  ↓
 テストを走らせる
だけになりました(エラーが無ければ)。

だいぶお手軽ですね。

cronによる定期更新

cronによる定期更新の設定方法は、「EC2 Auto Scalingグループ 起動設定の自動更新」と同様です。「cronによる定期更新」の項を参照してください。

CodePipelineから呼び出す場合

CodePipelineから呼び出す場合の設定方法についても、やることは「EC2 Auto Scalingグループ 起動設定の自動更新」の「CodePipelineから呼び出す場合」と同様です。

Pipelineから与えられる引数を処理するために、下記の関数を追記します。
PipelineのLambda関数を呼び出すステージで、下記のように値を指定します。
UserParameters: {"branch":"master","instance_name":"test-ec2-instance"}

import json


def get_user_params(job_data):
    try:
        user_parameters = job_data['actionConfiguration']['configuration']['UserParameters']
        decoded_parameters = json.loads(user_parameters)
    except Exception as e:
        raise Exception('UserParameters could not be decoded as JSON')
    
    if 'branch' not in decoded_parameters:
        raise Exception('UserParameters JSON must include the "branch"')
    
    if 'instance_name' not in decoded_parameters:
        raise Exception('UserParameters JSON must include the "instance_name"')

    return decoded_parameters

メインの処理では、CodePipelineにレスポンスを返すようにします。

def lambda_handler(event, context):
    codepipeline = boto3.client('codepipeline')
    try:
        # Get CodePipeline user params.
        job_data = event['CodePipeline.job']['data']
        params = get_user_params(job_data)
        name_tag_value = params['instance_name']
        github_branch = params['branch']
        print(name_tag_value)
        print(github_branch)

        # Get target instance id.
        target_instance = get_instance(name_tag_value)
        instance_id = target_instance['InstanceId']
        print(instance_id)

        # Get target launch template id.
        for tag in target_instance['Tags']:
            if tag['Key'] == 'aws:ec2launchtemplate:id':
                target_launch_template_id = tag['Value']
                break
        print(target_launch_template_id)

        # Make AMI name.
        image_name = make_image_name(name_tag_value)
        print(image_name)

        # Create AMI from target instance.
        description = f'Lambda create. branch:{github_branch} id:{instance_id}'
        ami_id = create_ami(image_name, instance_id, description)
        print(ami_id)

        # Update Launch Template
        update_launch_template(target_launch_template_id, ami_id, description)

        # Update Launch Template default version.
        set_launch_template_default_version(target_launch_template_id)
        
        # Return Success to CodePipeline.
        codepipeline.put_job_success_result(
                jobId = event['CodePipeline.job']['id'])
    except ClientError as e:
        print(e)
        # Return Failure to CodePipeline.
        codepipeline.put_job_failure_result(
                jobId = event['CodePipeline.job']['id'],
                failureDetails={
                    'type': 'JobFailed',
                    'message': str(e)
                }
            )

Lambda関数の権限は下記のとおりです。logs関連のResourceは伏せていますが、関数作成時に自動作成される箇所なので問題ないはずです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": (略)
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                (略)
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateImage",
                "ec2:DescribeInstances"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "codepipeline:PutJobSuccessResult",
                "codepipeline:PutJobFailureResult"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": "ec2:DescribeLaunchTemplateVersions",
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:ModifyLaunchTemplate",
                "ec2:DeleteLaunchTemplateVersions",
                "ec2:CreateLaunchTemplateVersion"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

これで、Pipelineの進行にあわせて「デプロイ後に自動で”AMI取得→起動テンプレート更新”」ができるようになりました。

参考

boto3のAPIリファレンス

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です