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リファレンス

4 Replies to “EC2 Auto Scalingグループ 起動テンプレートの自動更新”

  1. はじめまして。参考にさせてもらっています。
    一番上のコードをベタでコピペして試しているのですが、
    関数の引数のエラーが発生します。環境変数などで引数を指定したりするのでしょうか?

    またLambdaのアクセス権はLambdaのロールにインラインで追加しても問題ないでしょうか?

    1. Lambdaビギナー様

      コメントいただきありがとうございます。
      ご質問いただいた内容に回答いたします。

      > 関数の引数のエラーが発生します。環境変数などで引数を指定したりするのでしょうか?

      環境変数は設定しておりません。該当のLambda関数を動かすとき(Lambda関数ハンドラー)に以下のようなJSONデータが必要です。
      https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-handler.html

      サンプルJSON

      {
        "target_instances": [
          {
            "tag_name": "EC2インスタンスに紐づけられたタグ名1",
            "description": "test"
          },
          {
            "tag_name": "EC2インスタンスに紐づけられたタグ名2",
            "description": "test"
          }
        ]
      }
      

      > またLambdaのアクセス権はLambdaのロールにインラインで追加しても問題ないでしょうか?

      こちらに関しては、Lambdaビギナー様が運用されているAWSアカウントの管理者に確認したほうが適切かと存じます。
      基本的にはインラインポリシーで管理するよりも、カスタマー管理ポリシーで管理したほうが良いとされています。
      詳細については以下を参照ください。
      https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/access_policies_managed-vs-inline.html#choosing-managed-or-inline

  2. NaoiSota様
    早速のご返信ありがとうございます。
    “tag_name”: “EC2インスタンスに紐づけられたタグ名1”,というのは
    インスタンスのNameタグの値ということでよろしいでしょうか?

    JSONで存在するNameの値を入れても「インデックスの範囲外」となります。
    main_process(name_tag_value, description)を print(name_tag_value,description)
    に置き換えると ホスト名 TEST は表示されるので、
    JSONデータの引数は取れていると思うのですが、他のAWSリソース(ASG、AMI等)のタグや設定漏れでしょうか?

    以下エラーです。
    Response
    {
    “errorMessage”: “list index out of range”,
    “errorType”: “IndexError”,
    “requestId”: “dummy11111”,
    “stackTrace”: [
    ” File \”/var/task/lambda_function.py\”, line 118, in lambda_handler\n main_process(name_tag_value, description)\n”,
    ” File \”/var/task/lambda_function.py\”, line 79, in main_process\n target_instance = get_instance(name_tag_value)\n”,
    ” File \”/var/task/lambda_function.py\”, line 16, in get_instance\n return reservations[‘Reservations’][0][‘Instances’][0]\n”
    ]
    }

    1. Lambdaビギナー様
      ご確認いただきありがとうございます。

      ご質問いただいた内容に回答いたします。

      > “tag_name”: “EC2インスタンスに紐づけられたタグ名1”,というのは
      > インスタンスのNameタグの値ということでよろしいでしょうか?

      正確には、Auto Scaling グループ名になります。
      以下リンクの「EC2 インスタンスのタグ付けライフサイクル」の項目に詳細がございますので、ご確認ください。
      https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/autoscaling-tagging.html

      また、動かない場合やご不明な点がございましたら、お手数ですが、ご連絡いただければ幸いです。

コメントを残す

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