EC2 Auto Scalingグループ 起動設定の自動更新

前提: 起動設定よりも起動テンプレートを使った方が良いです。

一部の古いシステム等、起動設定を使っているまま起動テンプレートに移行ができていないものが、あったりなかったりすると思います。

Auto Scalingグループに紐付いた起動設定の更新作業って、デプロイ後とかに毎回やっていたのですが、手作業でやると地味に面倒です。
 AMI取得
  ↓
 起動設定を作成(既存のものからコピーしてName等変更)
  ↓
 Auto Scalingグループの設定更新
この3ステップめんどくないですか?めんどいですよね?自動化しませんか?します(しました)。

※AWSの既存サービスを組み合わせて、本記事の内容が全て実装できるかもしれません。無知を晒しているな~と思ったらコメント等お願いいたします。救われます。

※同様の流れで起動テンプレートを自動更新する記事も書きました

Lambda関数による実装

今回はAuto Scalingグループについて起動設定の更新を行いたいので、AMIの取得元は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')
autoscaling = boto3.client('autoscaling')


def get_instance(name_tag_value):
    try:
        filter_key = 'tag:aws:autoscaling:groupName'
        response = ec2.describe_instances(
                Filters=[{'Name':filter_key,'Values':[name_tag_value]}])
        return response['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: hogehoge-20210611-1530
    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 get_launch_conf_name(auto_scaling_group_name):
    # Get Launch Configuration Name of Auto Scaling Group.
    try:
        response = autoscaling.describe_auto_scaling_groups(
                AutoScalingGroupNames=[auto_scaling_group_name])
        return response['AutoScalingGroups'][0]['LaunchConfigurationName']
    except ClientError as e:
        print('#ClientError!! at get_launch_conf_name()')
        raise e


def get_launch_conf(launch_conf_name):
    # Get Launch Configuration of Auto Scaling Group.
    try:
        response = autoscaling.describe_launch_configurations(
                LaunchConfigurationNames=[launch_conf_name])
        return response['LaunchConfigurations'][0]
    except ClientError as e:
        print('#ClientError!! at get_launch_conf()')
        raise e


def create_launch_conf(old_launch_conf, launch_conf_name, ami_id):
    # Create NEW Launch Configuration.
    # Get target snapshots.
    try:
        response = ec2.describe_snapshots(
            Filters=[
                {
                    'Name': 'description',
                    'Values': ['Created by CreateImage(*) for ' + ami_id + '*',]
                }
            ]
        )
    except ClientError as e:
        print(e.response['Error']['Code'])
        print(e.response['Error']['Message'])
        raise e
    
    snapshot_id = response['Snapshots'][0]['SnapshotId']
    print(snapshot_id)

    # Create Launch Configuration.
    old_launch_conf['BlockDeviceMappings'][0]['Ebs']['SnapshotId'] = snapshot_id
    try:
        autoscaling.create_launch_configuration(
                LaunchConfigurationName=launch_conf_name,
                ImageId=ami_id,
                KeyName=old_launch_conf['KeyName'],
                SecurityGroups=old_launch_conf['SecurityGroups'],
                UserData="#!/bin/bash\nrm -f /var/tmp/aws-mon/instance-id",
                InstanceType=old_launch_conf['InstanceType'],
                BlockDeviceMappings=old_launch_conf['BlockDeviceMappings'],
                InstanceMonitoring=old_launch_conf['InstanceMonitoring'],
                IamInstanceProfile=old_launch_conf['IamInstanceProfile'],
                EbsOptimized=old_launch_conf['EbsOptimized'])
    except ClientError as e:
        print('#ClientError!! at create_launch_conf()')
        raise e
    return


def update_auto_scaling_group(auto_scaling_group_name, new_launch_conf_name):
    # Update Launch Configuration of Auto Scaling Group.
    autoscaling.update_auto_scaling_group(
            AutoScalingGroupName=auto_scaling_group_name,
            LaunchConfigurationName=new_launch_conf_name)
    return


def main_process(auto_scaling_group_name, description=''):
    try:
        # Get Original(Old) Launcn Configuration.
        old_launch_conf_name = get_launch_conf_name(auto_scaling_group_name)
        print(old_launch_conf_name)
        old_launch_conf = get_launch_conf(old_launch_conf_name)
        # print(old_launch_conf)
        
        # Make AMI name and Launcn Configuration name.
        image_name = make_image_name(auto_scaling_group_name)
        launch_conf_name = image_name
        print(image_name)
    
        # Create AMI from target instance.
        target_instance = get_instance(auto_scaling_group_name)
        instance_id = target_instance['InstanceId']
        print(instance_id)
        if not description:
            description = f'Lambda create. id:{instance_id}'
        ami_id = create_ami(image_name, instance_id, description)
        print(ami_id)
        
        # Create Launch Configuration.
        create_launch_conf(old_launch_conf, launch_conf_name, ami_id)
    
        # Update Auto Scaling Group.
        update_auto_scaling_group(auto_scaling_group_name, launch_conf_name)
    except ClientError as e:
        print(e)


def lambda_handler(event, context):
    targets = event['targets']
    
    for target in targets:
        auto_scaling_group_name = target['auto_scaling_group_name']
        description = target['description']
        
        print(auto_scaling_group_name)

        main_process(auto_scaling_group_name, description)
        
    return

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

  1. 現在の(古い)起動設定を取得(現在のものから設定を引き継ぐため)
    1. Auto Scalingグループから現在の(古い)起動設定のNameを取得
    2. 現在の(古い)起動設定を取得
  2. 新しく作成するAMIのNameを決定
  3. 新しい起動設定のNameを決定(便宜上、AMIのNameと同名とする)
  4. 新しい起動設定で使うAMIを作成
    1. Auto Scalingグループから適当なインスタンス1台を取得してインスタンスIDを取得
    2. 対象のインスタンスから、AMIを作成
  5. 新しい起動設定を作成
  6. 新しい起動設定をAuto Scalingグループに割り当て(設定の更新)

Lambda関数には、下記のような権限を付与しておきます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:PassRole",
                "ec2:DescribeSnapshots",
                "ec2:DescribeInstances",
                "ec2:CreateImage",
                "autoscaling:DescribeAutoScalingGroups",
                "autoscaling:UpdateAutoScalingGroup",
                "autoscaling:DescribeLaunchConfigurations",
                "autoscaling:CreateLaunchConfiguration"
            ],
            "Resource": "*"
        }
    ]
}

手動で実行する際には、テストイベントに下記のようなデータを入れてテストを走らせます。(本来の用途とは異なるけどお手軽なのでやりがち)

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

{
  "targets": [
    {
      "auto_scaling_group_name": "example-group-name",
      "description": ""
    },
    {
      "auto_scaling_group_name": "awesome-group-name",
      "description": "The bug was gone with a great fix."
    }
  ]
}

これにより、デプロイ後に行う作業が
 Auto Scalingグループの名前と説明文を書き換える
  ↓
 テストを走らせる
だけになりました(エラーが無ければ)。

手動で3ステップの作業を行う手間が省け、作業ミスが入り込む余地が削減でき、詳細なノウハウが無くても作業が実施可能になってしまいました。すごい。

cronによる定期更新

デプロイ後に手動実行するのではなくて、定期的にバックアップを取りつつ、起動設定の更新をしてAuto Scalingに対応したいというニーズもあるかもしれません。WordPressのサイトをEC2で動かしているとかね。

Lambdaにはcronで定期実行する仕組みが組み込まれています。天才。

Lambda > 関数 > トリガーを追加 > トリガーの設定 でEventBridgeを使います。


cronの時刻がUTCなこと、見落としがち

プログラムの方は現在テストイベントから引数(対象のAuto Scalingグループ名)を受け取っていますが、こちらをコード内で決め打ちにしてしまえばOKです。非常に簡単。

targets = event['targets']

targets = [
    {
        "auto_scaling_group_name": "example-group-name",
        "description": "Daily buckup."
    }
]

CodePipelineから呼び出す場合

デプロイ後にPipelineから自動で呼び出してやって欲しいことの方が多そうですね。こちらも簡単ですが、コードの作りが多少変わります。

だいぶ大雑把ですが……下記のような変更があります。

  • main_process()をtry-exceptでくくり、Pipelineに成功/失敗を返すような処理にします。
    • 下記の書き方の場合、main_process()内のtry-exceptでは例外発生時にエラーをraiseし、lambda_handler()で受け取れるようにする必要があります。
  • Pipelineにレスポンスを返すため、PipelineのJob IDなどの情報もあわせて受け取っています。
  • descriptionには、例えばデプロイしたGitHubのブランチ名などを含めると良いかもしれません。
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)
        auto_scaling_group_name = params['auto_scaling_group_name']
        github_branch = params['branch']
        print(auto_scaling_group_name)
        print(github_branch)

        description = f'Lambda create. branch:{github_branch}'

        # update Launch Configuration and Auto Scaling Group.
        main_process(auto_scaling_group_name, description)
        
        # 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)
                }
            )

Pipelineから受け取るパラメータを処理する get_user_params() は下記のような実装です。
PipelineのLambda関数を呼び出すステージで、下記のように値を指定します。
UserParameters: {"branch":"master","auto_scaling_group_name":"example-group-name"}

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

Lambda関数には、CodePipelineに結果を返す権限も付与する必要があります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "codepipeline:PutJobSuccessResult",
                "codepipeline:PutJobFailureResult"
            ],
            "Resource": "*"
        }
    ]
}

これで、Pipelineの進行にあわせて「デプロイ後に自動で”AMI取得→起動設定作成→Auto Scalingグループ更新”」ができるようになりました。

参考

boto3のAPIリファレンス

コメントを残す

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