ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

AMI からインスタンスを起動して、処理が終わったら終了する

こんにちは、AWS チームの尾谷です。

先週、前職の後輩と LINE で以下のようなやり取りをしました。

後輩「お疲れさまです。
 ロースペックの EC2 の AMI → ハイスペックの EC2 を
 4 台起動する方法はありますか?
 ハイスペックの EC2 は使い終わったら削除したいんです。」

僕は、上記のような構成をイメージしました。

尾谷「一時的に起動したい感じやね。
 EventBridge スケジューラ がいいんじゃないかな。」

全体図と結論

LINE のやりとりの中で、後輩は Graviton インスタンスを使いたがっているように見受けられたので、t4g.nano インスタンスからゴールデンイメージを作成して、c6g.large のスポットインスタンスを 4 台起動する手法を検証してみました。

実際にやってみると完全に自動化するには色々考慮する部分が漏れていて、結論、Lambda だけでよかった印象です。ただ、Amazon EventBridge Scheduler を推してしまった手前、後に引けず、少し強引なビルディングブロックを考察しましたのでブログとして公開します。

僕が考えた構成の、全体的な流れは以下のような形です。

ロースペック EC2 インスタンスを用意

複製元の EC2 インスタンスを Amazon Linux 2023 で作りました。
Amazon Linux 2023 は、Amazon Linux、Amazon Linux 2 に続く、新世代のディストリビューションです。
Fedora ベースの OS で、Amazon Linux Extras という使い勝手の良かったレポジトリ機能がなく、8 系から登場した dnf コマンド (ダンディな Yum) でインストールしていくため、若干の慣れが必要だったりします。
Amazon Linux 2022 のときに有効だった SELinux は enforcing モードではなく、permissive モードに緩和されていました。

Graviton インスタンスとして使うために arm 版の AMI で起動しました。

x86_64 ではなく、arm で起動しました

workload.sh

一連の処理を行った後に、自分を終了させる Shell スクリプトを作りました。

#!/bin/sh

INSTANCE_ID="$(
TOKEN=`curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600"`
curl -H "X-aws-ec2-metadata-token: $TOKEN" -v http://169.254.169.254/latest/meta-data/instance-id
)"
aws sns publish --topic-arn arn:aws:sns:ap-northeast-1:SNSトピックARN --message "処理完了しました。インスタンス ${INSTANCE_ID} を終了します。"
terminate-instances --instance-ids ${INSTANCE_ID} 

本スクリプトを実行すると、 メッセージが通知され、インスタンスが終了しました。

SNS 通知は AWC CLI の リファレンス を参照しました。
EC2 の終了は AWC CLI の リファレンスを参照しました。
インスタンスプロフィールロールには、sns:Publishec2:TerminateInstances を許可するポリシーを付与しました。

AMI を取得する

インスタンスができたので、AMI を作ります。

AMI の取得は、AWS Backup か、Amazon EventBridge Scheduler のどちらかが良いのではないかと思いました。 AWS Backup だと作成したイメージの削除まで行ってくれるので、AWS Backup で i-0a6d062dbf0d2b375 の AMI を取得しました。

バックアッププランを作成する

まずバックアッププランを作成します。

  1. [バックアッププランを作成] ボタンをクリックして、プランの作成画面に移動します。
    バックアッププランを作成 ボタンをクリックする
  2. 任意のバックアッププラン名を指定して、
  3. バックアップルール名を入力します。オプションはデフォルトのままで一点だけ、保持期間を 1 日にしておきます。
    これを設定しておかないと、いつまでもバックアップが消えずに増えていってしまいます。
  4. [プランを作成] ボタンをクリックします。これで、毎日バックアップを作成して 1 日後に削除するプランができました。 バックアップ期間をカスタマイズ を設定すると、任意の時間にバックアップが取得できます。デフォルトだと、日本時間の午前 2 時から 5 時の間によしなに取得してくれます。
  5. タグをしていておくと見分けがつきやすいです。
    後ほど Lambda で使うので、Name タグを指定しておきました。

リソースの割り当て

続けて、Backup プランにリソースを割り当てます。

  1. 任意の名前をつけます。ロースはデフォルトのままにしておくと、サービスロールが自動で作成されます。
  2. ここでは、EC2 インスタンスのインスタンス ID 指定でバックアップを取得するようにしますが、インスタンスを作り直すと (インスタンス ID が変わると)、バックアップ取得に失敗するので、タグで指定した方が良いかも知れません。
    なお、AWS Backup のバックアップ取得失敗は CloudWatch Alarm を使うと通知ができます。

それから一日

AMI が作成されるまで 1 日待ったテイでブログを進めます。

一日が経過しました。

AMI からインスタンスを起動

「ami-0405bf526a541fe43」という AMI ができたので、

Amazon EventBridge Scheduler を使って、インスタンスを起動します。
任意のスケジュール名を指定して、

Cron 式を指定します。ここでは毎日 9 時に実行されるようにしました。

パラメータは こちらのドキュメント を参考にしながら設定しました。

{ 
    "MaxCount": 4,
    "MinCount": 4,
    "ImageId": "ami-0405bf526a541fe43",
    "InstanceType": "m6g.large",
    "UserData": "IyEvYmluL2Jhc2gNCnN1ZG8gL2hvbWUvZWMyLXVzZXIvd29ya2xvYWQuc2g="
    "IamInstanceProfile": {
        "Arn": "arn:aws:iam::アカウント名:instance-profile/ロール名"
    }
}

ユーザーデータはこちらのサイト を利用させていただき Base64 変換しました。

#!/bin/bash
sudo /home/ec2-user/workload.sh

Amazon EventBridge Scheduler の詳しい使い方は以下のブログで詳しく紹介していますのでご確認ください。

techblog.forgevision.com

あと、ハマったポイントとして EC2 インスタンスプロフィールロールをアタッチするため、Amazon EventBridge Scheduler に PassRole を許可しておかないと起動が失敗しました。

AMI ID を動的に変更する

午前 9 時過ぎてコンソールを確認すると m6g.large インスタンスが 4 台起動してきました。

ここまでで、だいたい組み上げることができましたが、現状だと Amazon EventBridge Scheduler に指定したパラメータの AMI ID が固定になっていて、毎日書き換えが必要なので、AWS Lambda を使って Amazon EventBridge Scheduler のパラメータを変更することにしました。

boto3 のドキュメント を参考に以下の Lambda を書きました。
読みやすいように例外処理などのルーチンを省略しています。

import datetime
import boto3

eb_sche = boto3.client('scheduler')
ec2 = boto3.client('ec2')

def lambda_handler(event, context):
   date = datetime.datetime.now()
    ami_ids = ec2.describe_images(
        Filters=[
            {
                'Name': 'tag:Name',
                'Values': [
                    'otani-test-golden-image',
                ]
            },
        ],
        Owners=[
            'self',
        ],
        MaxResults=10
    )
    
    list = []
    for ami in ami_ids["Images"]:
        ami_id = ami["ImageId"]
        create_date = ami['CreationDate']
        list.append([create_date, ami_id])
    new_list = sorted(list, key=lambda x: x[0])

    response = eb_sche.update_schedule(
        Description=str(date.strftime('%Y/%m/%d %H:%M')) + ' 更新',
        FlexibleTimeWindow={
            'Mode': 'OFF'
        },
        Name='otani-test-create-instances',
        ScheduleExpression='cron(0 9 * * ? *)',
        ScheduleExpressionTimezone='Asia/Tokyo',
        State='ENABLED',
        Target={
            'Arn': 'arn:aws:scheduler:::aws-sdk:ec2:runInstances',
                        'Input': "{ \"MaxCount\": 4, \"MinCount\": 4, \"ImageId\": \"" + new_list[len(new_list) - 1][1] +"\", \"InstanceType\": \"m6g.large\", \"UserData\": \"IyEvYmluL2Jhc2gNCnN1ZG8gL2hvbWUvZWMyLXVzZXIvd29ya2xvYWQuc2g=\", \"IamInstanceProfile\": { \"Arn\": \"arn:aws:iam::アカウント名:instance-profile/ロール名\" }}",
            'RetryPolicy': {
                'MaximumEventAgeInSeconds': 86400,
                'MaximumRetryAttempts': 185
            },
            'RoleArn': 'arn:aws:iam::アカウントID:role/otani-test-EC2-RunInstances'
        }
    )

適宜、環境変数を設定すべきですが、最低限タイムゾーンを指定しておきます。

以下は、Lambda に指定したロールです。
AWSLambdaBasicExecutionRole と以下のポリシーをインラインで追加しました。
PassRole はざっくり設定しましたが、ロールのみに絞った方が良いかと思います。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllResources",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeImages",
                "iam:PassRole"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Schedule",
            "Effect": "Allow",
            "Action": [
                "scheduler:GetSchedule",
                "scheduler:UpdateSchedule"
            ],
            "Resource": "arn:aws:scheduler:ap-northeast-1:アカウントID:schedule/default/otani-test-create-instances"
        }
    ]
}

ユニバーサルターゲットは こちらのドキュメント を参考に指定しました。 EC2 DescribeInstances は AWC CLI の リファレンス を参照しました。

降順で並び替えをして AMI ID が複数できても常に新しい ID を掴むようにしています。

    str_amiid.sort(reverse=True)

まとめ

Amazon EventBridge Scheduler を利用すれば、ローコードで設定できると思っていましたが、いざ完全自動化をしようとすると、動的にイメージ ID を変更する部分が難しく Lambda の方が処理が楽だと感じました。

繰り返しになりますが、Amazon EventBridge Scheduler は使わず Lambda だけで良いと思います。
CloudFormation を使うのも良いかと思いました。