Amazon EC2のCloudFormationテンプレートを自動生成するスクリプトを作ってみた

記事タイトルとURLをコピーする

こんにちは!イーゴリです。

私の業務で、AWS環境にある既存のEC2をすぐに再構築したい/クローンしたい/一部のパラメーターだけ変えたい、などのパターンがよく出たので、既存のEC2を元にEC2のCloudFormationテンプレートを自動生成するスクリプトを作ってみましたので、このブログで紹介したいと思います。

どんな時に役立つ

本件のスクリプトは下記のパターンなどで役立つかと思います。

EC2の作り直し(EC2の切り戻し作業や障害時の緊急対応やEC2のクローンなど)

既存のENIを残す場合

1.この記事のスクリプトを実行し、CFnテンプレート(.yamlファイル)を生成する。

2.①AMIを取得する もしくは  ②元のAMI IDを残す(このステップをスキップする)  もしくは ③別の既存のAMIを使う(このステップをスキップする)。

3.下記の手順の通り、EC2のENIの[終了時の動作を変更]の画面で、[インスタンスの削除時に削除]の[有効化]のチェックを外し、[保存]をクリックする。下記の記事をご参考ください。

blog.serverworks.co.jp

4.既存のEC2を削除する。

5.[1]で生成したCFnテンプレート(.yamlファイル)に既存のENIが残っているため、AWS CloudFormationにCFnテンプレート(.yamlファイル)をアップロードし、必要に応じてAMIを変更し、CloudFormationを実行すると、数分で全く同じEC2(キーペア、インスタンスタイプ、タグなど)が復元される。

新規のENIを使う場合

1.この記事のスクリプトを実行し、CFnテンプレート(.yamlファイル)を生成する。

2.①AMIを取得する もしくは  ②元のAMI IDを残す(このステップをスキップする)  もしくは  ③別の既存のAMIを使う(このステップをスキップする)。

3.新規ENIを作成する。

4.[1]で生成されたCFnテンプレート(.yamlファイル)のENI IDを修正する。また、他に変えたいパラメーターがあったら変更する(例:Nameタグなど)。

5.[1]で生成したCFnテンプレート(.yamlファイル)を、AWS CloudFormationにアップロードし、必要に応じてAMIを変更し、CloudFormationを実行すると、数分で全く同じEC2(キーペア、インスタンスタイプ、タグなど)が復元される。

EC2のENI付きかえ/新規EC2の一部パラメーターの変更(EC2のリプレース作業など)

1.新規EC2を構築します。

2.旧EC2のAMI及び[1]で作った新EC2のAMIを取得する。

3.この記事のスクリプトを実行し、CFnテンプレート(.yamlファイル)を生成し、[2]の新EC2のAMI IDを入力します。

4.下記の手順の通り、EC2のENIの[終了時の動作を変更]の画面で、[インスタンスの削除時に削除]の[有効化]のチェックを外し、[保存]をクリックする。下記の記事をご参考ください。

blog.serverworks.co.jp

4.旧EC2を削除する。

5.[3]で生成したスクリプトで旧EC2のENIに変更する。

6.AWS CloudFormationにCFnテンプレート(.yamlファイル)をアップロードし、CloudFormationを実行すると、数分で全く同じEC2(キーペア、インスタンスタイプ、タグなど)が復元される。

注意事項

①このスクリプトについては、一切の責任を負えませんので、ご自身で十分にご検証した上で、このスクリプトを使ってもよいかご判断ください。

②下記のスクリプトは私にとって必要なEC2パラメーターのみ取得されているので、他のパラメーターが必要な場合、ご自身で修正してください。describe.pyのスクリプトの内容を参考にすると分かりやすいと思います。

CFnに反映されるパラメーター:

  • InstanceType
  • KeyName
  • NetworkInterfaceId
  • IamInstanceProfile
  • IMDSバージョン
  • EbsOptimized
  • Monitoring
  • EC2終了保護※

※ec2-describe.jsonにDisableApiTerminationが入っている場合(デフォルトで取得されないパラメーター)

スクリプト構成

スクリプトが実行される順番で下記の通りファイルが構成されます。

┏run-script.sh (下のスクリプトを順番に実行させるシェルスクリプト)
┣describe-instance-and-volume.sh (対象インスタンス及び対象インスタンスに紐づいているEBSの情報を取得するスクリプト)
┗describe.py (上記で取得したEC2及びEBS情報に基づいてEC2のCloudFormationテンプレートが自動生成されるPythonスクリプト)

スクリプトを3つに分けた理由は、場合によって下記のパターンがあるからです。

  • EC2及びEBSのDescribe情報のみを取得したい場合、「describe-instance-and-volume.sh」を実行すればよいです。
    →describe-instance-and-volume.shの簡易な使用例:sh describe-instance-and-volume.sh <インスタンスID>

  • すでにDescribe.json情報がある/自分でDescribe情報を修正した場合、describe.pyを実行すればよいです。
    →describe-instance-and-volume.shの簡易な使用例:python3 describe.py

  • run-script.shは最初にdescribe-instance-and-volume.shでEC2及びEBSのDescribe情報を取得し、その後、describe.pyのスクリプトはそのDescribe情報に基づいてEC2のCloudFormationテンプレート(yaml)が作成されます。
    →sh run-script.shの簡易な使用例:sh run-script.sh <インスタンスID>

スクリプトの作成及び実施方法

EC2及び関連するEBSボリューム情報を取得するスクリプトの作成

前提条件(実行時に必要):

  • AWS CLIがインストールされていること
  • AWS CLIのプロフィールが設定されていること
  • jqがインストールされていること

1.describe-instance-and-volume.shのファイルを作ります。
2.下記の内容を記載します。

#!/bin/bash

# 引数としてインスタンスIDが渡されているか確認します
if [ "$#" -ne 1 ]; then
    echo "使用法: $0 <instance-id>"
    exit 1
fi

INSTANCE_ID=$1

# EC2インスタンスの情報を取得し、ファイルに保存します
aws ec2 describe-instances --instance-ids $INSTANCE_ID >ec2-describe.json

# jqを使用してVolume IDを抽出します
VOLUME_ID=$(jq -r '.Reservations[].Instances[].BlockDeviceMappings[].Ebs.VolumeId' ec2-describe.json)

# Volume IDが取得できたか確認します
if [ -z "$VOLUME_ID" ]; then
    echo "インスタンスに対するVolume IDが見つかりません: $INSTANCE_ID"
    exit 1
fi

# EBSボリュームの情報を取得し、ファイルに保存します
aws ec2 describe-volumes --volume-ids $VOLUME_ID >ebs-describe.json

スクリプトの使い方

ROロール(AWS CLI)で対象AWSアカウントにログインする。
$ export AWS_DEFAULT_PROFILE=<AWSアカウントプロファイル名>

対象アカウントがROロールでログインされていることを確認する。
$ aws sts get-caller-identity


$ cd <スクリプトのディレクトリ>
$ sh describe-instance-and-volume.sh <インスタンスID>

結果としてスクリプトのディレクトリで「ec2-describe.json」及び「ebs-describe.json」のファイルが出力されます。

EC2のCloudFormationテンプレートが自動生成されるPythonスクリプトの作成

前提条件(実行時に必要):

  • python3がインストールされていること(Python 3.10.4で検証済み)
  • pyyamlがインストールされていること

1.describe.pyのファイルを作ります。
2.下記の内容を記載します。

UPD: 最初はEBSタグがないとNGというバージョンでしたが、EBSタグがなくてもCFn生成できるバージョン(v2)を共有します。

import json
import yaml
import os
import copy

# ディレクトリのパスを指定
current_dir = os.path.dirname(os.path.abspath(__file__))
# ec2-describe.jsonのパスを指定
json_file_path = os.path.join(current_dir, 'ec2-describe.json')
# ebs-describe.jsonのパスを指定
ebs_file_path = os.path.join(current_dir, 'ebs-describe.json')

# Load EC2 and EBS description from files
with open(json_file_path, 'r') as ec2_file:
    ec2_description = json.load(ec2_file)
# EC2 description
instance = ec2_description['Reservations'][0]['Instances'][0]

# AMI IDをリクエストする
user_ami_id = input("AMI ID: ").strip()
# AMI IDを入力しない場合
ami_id = user_ami_id if user_ami_id else instance.get('ImageId', '')

# サブネット、セキュリティグループ、ストレージ情報の入力をリクエスト
subnet_id = input("Subnet ID: ").strip() or instance['NetworkInterfaces'][0]['SubnetId']
security_group_ids_input = input("Security Group IDs (comma-separated): ").strip()
security_group_ids = security_group_ids_input.split(',') if security_group_ids_input else [sg['GroupId'] for sg in instance['NetworkInterfaces'][0]['Groups']]

# EBSボリュームタイプ、サイズ、スループットの入力をリクエスト
volume_type = input("EBS Volume Type (gp2, gp3): ").strip() or instance['BlockDeviceMappings'][0]['Ebs']['VolumeType']
volume_size_input = input("EBS Volume Size (GiB): ").strip()
volume_size = int(volume_size_input) if volume_size_input else instance['BlockDeviceMappings'][0]['Ebs']['VolumeSize']
throughput_input = input("EBS Throughput (only for gp3, otherwise leave as 0): ").strip()
throughput = int(throughput_input) if throughput_input else None

# インスタンスタグ取得
ec2_tags = instance.get('Tags', [])
exclude_prefixes = [
    'aws:ec2launchtemplate:',
    'aws:cloudformation:'
]

# 上記の不要なタグは反映されない
def should_exclude_tag(tag_key):
    return any(tag_key.startswith(prefix) for prefix in exclude_prefixes)

ec2_tags_cf = [
    {'Key': tag['Key'], 'Value': tag['Value']}
    for tag in ec2_tags
    if not should_exclude_tag(tag['Key'])
]

# EBS取得
with open(ebs_file_path, 'r') as ebs_file:
    ebs_description = json.load(ebs_file)
ebs_tags = [
    {'Key': tag['Key'], 'Value': tag['Value']}
    for tag in ebs_description['Volumes'][0].get('Tags', [])
    if not should_exclude_tag(tag['Key'])
]

# CFnの設定値を取得
instance_type = instance.get('InstanceType', '')
key_name = instance.get('KeyName', '')
iam_instance_profile = instance.get('IamInstanceProfile', {}).get('Arn', '').split('/')[-1] if instance.get('IamInstanceProfile') else ''
http_tokens = instance.get('MetadataOptions', {}).get('HttpTokens', 'optional')  # Automatically extracted
ebs_optimized = instance.get('EbsOptimized', False)
monitoring_state = instance.get('Monitoring', {}).get('State', 'disabled')
monitoring = monitoring_state == 'enabled'
disable_api_termination = instance.get('DisableApiTermination', False)

# CloudFormationテンプレートの生成
cloudformation_template = {
    'AWSTemplateFormatVersion': '2010-09-09',
    'Description': 'Generated CloudFormation Template',
    'Parameters': {
        'OS': {
            'Type': 'String',
            'Default': ami_id,
            'Description': 'The ID of the AMI to use for EC2 restoration'
        }
    },
    'Resources': {
        'EC2Instance': {
            'Type': 'AWS::EC2::Instance',
            'Properties': {
                'ImageId': {'Ref': 'OS'},
                'InstanceType': instance_type,
                'KeyName': key_name,
                'SubnetId': subnet_id,
                'SecurityGroupIds': security_group_ids,
                'EbsOptimized': ebs_optimized,
                'DisableApiTermination': disable_api_termination,
                'IamInstanceProfile': iam_instance_profile,
                'Monitoring': monitoring,
            }
        },
        'EBSVolume': {
            'Type': 'AWS::EC2::Volume',
            'Properties': {
                'AvailabilityZone': instance.get('Placement', {}).get('AvailabilityZone', ''),
                'VolumeType': volume_type,
                'Size': volume_size,
            }
        },
        'LaunchTemplate01': {
            'Type': 'AWS::EC2::LaunchTemplate',
            'Properties': {
                'LaunchTemplateData': {
                    'MetadataOptions': {
                        'HttpTokens': http_tokens
                    },
                    'TagSpecifications': [
                        {
                            'ResourceType': 'volume',
                            'Tags': copy.deepcopy(ebs_tags)
                        }
                    ]
                }
            }
        }
    }
}

# Add throughput to EBSVolume if specified
if throughput is not None and volume_type == 'gp3':
    cloudformation_template['Resources']['EBSVolume']['Properties']['Throughput'] = throughput

# Add tags to EC2Instance if present
if ec2_tags_cf:
    cloudformation_template['Resources']['EC2Instance']['Properties']['Tags'] = ec2_tags_cf
23920223920223
# Add tags to EBSVolume if present
if ebs_tags:
    cloudformation_template['Resources']['LaunchTemplate01']['Properties']['LaunchTemplateData']['TagSpecifications'][0]['Tags'] = ebs_tags

# Add LaunchTemplate to EC2Instance
cloudformation_template['Resources']['EC2Instance']['Properties']['LaunchTemplate'] = {
    'LaunchTemplateId': {'Ref': 'LaunchTemplate01'},
    'Version': '1'
}

# CloudFormationアウトプット
yaml_output_path = os.path.join(current_dir, 'cloudformation-template.yaml')
with open(yaml_output_path, 'w') as outfile:
    yaml.dump(cloudformation_template, outfile, default_flow_style=False)
print("CloudFormation template generated successfully.")

バージョンv1(EBSタグがないとダメというバージョン)

import json
import yaml
import os

# ディレクトリのパスを指定
current_dir = os.path.dirname(os.path.abspath(__file__))

# ec2-describe.jsonのパスを指定
json_file_path = os.path.join(current_dir, 'ec2-describe.json')

# ebs-describe.jsonのパスを指定
ebs_file_path = os.path.join(current_dir, 'ebs-describe.json')

# Load EC2 and EBS description from files
with open(json_file_path, 'r') as ec2_file:
    ec2_description = json.load(ec2_file)

# EC2 description
instance = ec2_description['Reservations'][0]['Instances'][0]

# AMI IDをリクエストする
user_ami_id = input("AMI ID: ").strip()

# AMI IDを入力しない場合
ami_id = user_ami_id if user_ami_id else instance.get('ImageId', '')

# インスタンスタグ取得
ec2_tags = ec2_description['Reservations'][0]['Instances'][0].get('Tags', [])

exclude_prefixes = [
    'aws:ec2launchtemplate:',
    'aws:cloudformation:'
]

# 上記の不要なタグは反映されない
def should_exclude_tag(tag_key):
    return any(tag_key.startswith(prefix) for prefix in exclude_prefixes)

ec2_tags_cf = [
    {'Key': tag['Key'], 'Value': tag['Value']} 
    for tag in ec2_tags 
    if not should_exclude_tag(tag['Key'])
]

#EBS取得
with open(ebs_file_path, 'r') as ebs_file:
    ebs_description = json.load(ebs_file)

ebs_tags = [
    {'Key': tag['Key'], 'Value': tag['Value']}
    for tag in ebs_description['Volumes'][0].get('Tags', [])
    if not should_exclude_tag(tag['Key'])
]

# CFnの設定値を取得
instance_type = instance.get('InstanceType', '')
key_name = instance.get('KeyName', '')
network_interface_id = instance['NetworkInterfaces'][0].get('NetworkInterfaceId', '') if instance.get('NetworkInterfaces') else ''
iam_instance_profile = instance.get('IamInstanceProfile', {}).get('Arn', '').split('/')[-1] if instance.get('IamInstanceProfile') else ''
http_tokens = instance.get('MetadataOptions', {}).get('HttpTokens', 'optional')  # Automatically extracted
ebs_optimized = instance.get('EbsOptimized', False)
monitoring_state = instance.get('Monitoring', {}).get('State', 'disabled')
monitoring = monitoring_state == 'enabled'
disable_api_termination = instance.get('DisableApiTermination', False)

cloudformation_template = {
    'AWSTemplateFormatVersion': '2010-09-09',
    'Description': 'Generated CloudFormation Template',
    'Parameters': {
        'OS': {
            'Type': 'String',
            'Default': ami_id,
            'Description': 'The ID of the AMI to use for EC2 restoration'
        }
    },
    'Resources': {
        'EC2Instance': {
            'Type': 'AWS::EC2::Instance',
            'Properties': {
                'ImageId': {'Ref': 'OS'},
                'InstanceType': instance_type,
                'KeyName': key_name,
                'NetworkInterfaces': [{
                    'DeviceIndex': 0,
                    'NetworkInterfaceId': network_interface_id
                }],
                'EbsOptimized': ebs_optimized,
                'DisableApiTermination': disable_api_termination,
                'IamInstanceProfile': iam_instance_profile,
                'Monitoring': monitoring,
                'Tags': ec2_tags_cf  # Add dynamic EC2 tags
            }
        }
    }
}
#########LaunchTemplate###########
cloudformation_template['Resources']['LaunchTemplate01'] = {
    'Type': 'AWS::EC2::LaunchTemplate',
    'Properties': {
        'LaunchTemplateData': {
            'MetadataOptions': {
                'HttpTokens': http_tokens
            },
            'TagSpecifications': [
                {
                    'ResourceType': 'volume',
                    'Tags': ebs_tags  # Add dynamic EBS tags
                }
            ]
        }
    }
}

cloudformation_template['Resources']['EC2Instance']['Properties']['LaunchTemplate'] = {
    'LaunchTemplateId': {'Ref': 'LaunchTemplate01'},
    'Version': '1'
}

#########END###########

# CloudFormationアウトプット
yaml_output_path = os.path.join(current_dir, 'cloudformation-template.yaml')
with open(yaml_output_path, 'w') as outfile:
    yaml.dump(cloudformation_template, outfile, default_flow_style=False)

print("CloudFormation template generated successfully.")

スクリプトの使い方

1.ec2-describe.json及びebs-describe.jsonのファイルを同じディレクトリに置きます。
注意:describe-instance-and-volume.shとは別の方法でDescribeファイルを取得する場合、必ずEC2のDescribe.jsonファイルの名前はec2-describe.jsonにして、EBSのDescribe.jsonファイルの名前はebs-describe.jsonにしてください。

2.下記のコマンドを実行します。

$ cd <スクリプトのディレクトリ>
$ python3 describe.py

スクリプト実施中に、「AMI ID:」というメッセージが表示されます。

  • 別のAMI IDを使いたい場合、別のAMI IDを入力し、[Enter]キーを押します。

「CloudFormation template generated successfully.」というメッセージが表示されたら、同じディレクトリに「cloudformation-template.yaml」が生成されます。

CloudFormationテンプレートに、上記にて入力したAMI ID値が入っています。

  • ec2-describe.jsonのAMI IDのまま残したい場合、[Enter]キーを押します。

まだAMIを取得していない状態で、事前にCFnテンプレートを作成したいパターンがよくあるので、その場合、[Enter]キーを押し、AMIを取得した後、AMI IDを手動で追記するか、AWS CloudFormationでこのテンプレートを流す時に、OSの項目で必要なAMI IDに書き換えたらよいです。

AWS CloudFormation上での修正

もしくは

CFnテンプレートの修正

なお、ENI IDや他のパラメーターを変更したい場合、yamlファイルを手動で修正する必要があります。

AWS CloudFormationでの実施の後片付け

現時点で、AWSの仕様上CloudFormationはEBSのタグ及びIMDSバージョンは未サポートのため、EBSのタグ及びIMDSバージョンの設定値をLaunch Templateに移動しました。

上記のため、EC2以外に、「LaunchTemplate01」というリソースが作成されます。リソースの作成後、Launch Templateは不要になるので、削除します。

Launch Templateを残しても問題ないですが、ゴミが増えるので、削除したほうがすっきりすると思います。

上記の2つのスクリプトをまとめて実施したい場合

前提条件(実行時に必要):

  • AWS CLIがインストールされていること
  • AWS CLIのプロフィールが設定されていること

describe-instance-and-volume.sh及びdescribe.pyのスクリプトを一括で動かしたい場合、run-script.shをdescribe-instance-and-volume.sh及びdescribe.pyのスクリプトと同じディレクトリに置く必要があります。

1.run-script.shのファイルを作ります。
2.下記の内容を記載します。

#!/bin/bash

# 引数としてインスタンスIDが渡されているか確認します
if [ "$#" -ne 1 ]; then
    echo "使用法: $0 <instance-id>"
    exit 1
fi

# インスタンスIDを変数に格納します
INSTANCE_ID=$1

# 最初のスクリプトを実行して、EC2インスタンスとEBSボリュームの情報をJSONファイルに保存します
./describe-instance-and-volume.sh $INSTANCE_ID

# 前のコマンドが成功したかどうか確認します
if [ $? -ne 0 ]; then
    echo "describe-instance-and-volume.shの実行中にエラーが発生しました"
    exit 1
fi

# Pythonスクリプトを実行して、収集されたデータを処理し、CloudFormationテンプレートを生成します
python3 describe.py

# Pythonスクリプトの実行が成功したかどうか確認します
if [ $? -ne 0 ]; then
    echo "describe.pyの実行中にエラーが発生しました"
    exit 1
fi

echo "処理が正常に完了しました。"

スクリプトの使い方

ROロール(AWS CLI)で対象AWSアカウントにログインする(Describe情報を取得するため)。
$ export AWS_DEFAULT_PROFILE=<AWSアカウントプロファイル名>

対象アカウントがROロールでログインされていることを確認する。
$ aws sts get-caller-identity

$ cd <スクリプトのディレクトリ>
実行権限を付与する(一回のみ)。
$ chmod +x run-script.sh describe-instance-and-volume.sh describe.py
下記のコマンドで上記の2つのスクリプトを実行する。
$ sh describe-instance-and-volume.sh <インスタンスID>

以上、御一読ありがとうございました。

本田 イーゴリ (記事一覧)

カスタマーサクセス部

・2024 Japan AWS Top Engineers (Security)
・AWS SAP, DOP, SCS, DBS, SAA, DVA, CLF
・Azure AZ-900
・EC-Council CCSE

趣味:日本国内旅行(47都道府県制覇)・ドライブ・音楽