EC2へのOSタグ付与の自動化(AWS boto3, python, ssm)

# 
## はじめに
### 概要

Boto3を用いてAWSのEC2インスタンスに一括してosタグキーを付与し、その値としてOSバージョン情報を格納します。前提としてインスタンスはssm管理されていることが必要です。

### 背景とゴール

AWSの運用をしていてEC2のインスタンスが増えてきたり、複数のユーザやチームが共用する環境だとどのインスタンスが何のOS(OperatingSystem)かパッと分からなくなってきます。EC2一覧のコンソール上では「プラットフォームの詳細」という項目でOSは記述されますが、"Linux/Unix"や"Winodws"とかなりざっくりした書き方です。インスタンスの詳細を開いても詳しいバージョンは分かりません。

一方、IT管理者・運用者としては以下のような要件を満たしたい場合があります。
- 環境に何のOSが何台いるか管理したい
- コンソールでインスタンスの一覧性や他項目を保持しつつOSを把握したい
- OS情報を自動作業の判断ロジックやソートキーに組み込みたい

これらを満たすためにosタグで情報管理していきます。ssmで管理されていればSystemManagerコンソールでOSバージョンの一覧は見られるのですが、EC2コンソール側からは見れません。よって、人の見やすさ・プログラムでの扱いやすさ・アクセス性を考え各インスタンスの"os"タグに当該OS情報を記述することをゴールとします。

## osタグ自動付与スクリプト
### 機能概要
このスクリプトではAWS EC2インスタンスに対してOS情報を取得し、その情報を 'os' タグとしてインスタンスに設定します。
1. EC2インスタンスの一覧を取得。
2. インスタンスごとにOS情報を取得。
   - Windowsの場合: PowerShellコマンドを使用してOS名を取得。
   - Linuxの場合: `/etc/os-release` を解析してOS名とバージョンを取得。
3. 取得したOS情報を整形修正し 'os' タグとしてインスタンスに設定。
   - すでに同じ値の 'os' タグが設定されている場合はスキップ。アップグレード等更新で変わった場合更新される
4. ログを 'logs/ec2_os_tagging.log' に保存。

### 前提条件
#### EC2インスタンスのOS種類
WindowsかAmazon Linux, Ubuntu, RHELで動作確認しています。
MacOSインスタンスは未対応です("unknown"という値が入ると思います)。

#### python環境
Python実行環境およびboto3のインストールが必要です。2025年5月現在、要求バージョンはPython 3.9 or laterとなっています。[Boto3ドキュメント](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html)。
```
pip install boto3
```

#### 実行環境のAWS認証・権限
boto3 を使ってAWSを操作するには権限が必要です。ローカル環境でboto3を実行するなら~/.aws/credentialsから認証情報を読み取ります。AWS CLIのアクセスキーとシークレットキーの設定と一緒です。認証されたプリンシパルで以下の権限が必要です。
  - `ssm:SendCommand`
  - `ssm:GetCommandInvocation`
  - `ec2:DescribeInstances`
  - `ec2:CreateTags`
  - `ec2:DescribeTags`

#### EC2にssm導入済み
各**EC2インスタンスはSystemMangaerAgent(ssm)が有効**でマネージドの状態になっているものとします。[AWS「SSM Agent」の使用](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ssm-agent.html)。ssmが入っていないインスタンスはOS情報が取得できないので、osタグには”unknown”という値が入ります。

### Pythonコード

```ec2_os_tagging.py
import boto3
import time
import re
import logging
import os
from botocore.exceptions import ClientError
# ログフォルダのパスを指定
log_folder = "logs"
os.makedirs(log_folder, exist_ok=True)  # フォルダが存在しない場合は作成
log_file = os.path.join(log_folder, "ec2_os_tagging.log")
# ログ設定
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    filename=log_file,  # ログファイルのパスを指定
    filemode='a'  # 'a'は追記モード、'w'は上書きモード
)
# ログ出力
logging.info("Logging setup complete. Logs will be saved to the 'logs' folder.")
# 環境変数からリージョンを取得
region = os.getenv("AWS_REGION", "ap-northeast-1")  # デフォルトは ap-northeast-1
# boto3クライアントの作成
ssm = boto3.client('ssm', region_name=region)
ec2 = boto3.client('ec2', region_name=region)

def get_os_info(instance_id, platform):
    try:
        if platform == "windows":
            # Windows用のコマンド
            response = ssm.send_command(
                InstanceIds=[instance_id],
                DocumentName="AWS-RunPowerShellScript",
                Parameters={'commands': ["(Get-WmiObject Win32_OperatingSystem).Caption"]}
            )
        else:
            # Linux用のコマンド
            response = ssm.send_command(
                InstanceIds=[instance_id],
                DocumentName="AWS-RunShellScript",
                Parameters={'commands': ["cat /etc/os-release"]}
            )
    except ClientError as e:
        if e.response['Error']['Code'] in ['InvalidInstanceId', 'AccessDeniedException']:
            logging.warning(f"SSM is not enabled or accessible for instance {instance_id}: {e}")
            return "unknown"
        raise
    command_id = response['Command']['CommandId']
    # コマンドの完了をポーリングで確認
    for _ in range(10):
        time.sleep(2)
        output = ssm.get_command_invocation(CommandId=command_id, InstanceId=instance_id)
        if output['Status'] in ['Success', 'Failed']:
            break
    else:
        raise TimeoutError(f"Command did not complete for instance {instance_id}")
    if output['Status'] != 'Success':
        raise RuntimeError(f"Command failed for instance {instance_id}: {output['StatusDetails']}")
    stdout = output.get('StandardOutputContent', '')
    if platform == "windows":
        # Windowsの場合、OS名から空白を削除しそのまま返す
        return stdout.strip().replace(" ", "")
    else:
        # Linuxの場合、”os-release”から"NAME="と"VERSION_ID="を抽出し整形
        distro = "unknown"
        version = "unknown"
        name_match = re.search(r'^NAME="?([^"\n]+)"?', stdout,re.MULTILINE)
        version_match = re.search(r'^VERSION_ID="?([^"\n]+)"?', stdout,re.MULTILINE)
        if name_match:
            distro = name_match.group(1).replace(" ", "")
        if version_match:
            version = version_match.group(1).split('.')[0]
        return f"{distro}{version}"
        
def tag_instance_with_os(instance_id, os_info):
    # 既存のタグを取得
    existing_tags = ec2.describe_tags(Filters=[
        {'Name': 'resource-id', 'Values': [instance_id]},
        {'Name': 'key', 'Values': ['os']}
    ])['Tags']
    if existing_tags and existing_tags[0]['Value'] == os_info:
        logging.info(f"  Tag 'os' already set to '{os_info}' for {instance_id}, skipping.")
        return
    ec2.create_tags(
        Resources=[instance_id],
        Tags=[{'Key': 'os', 'Value': os_info}]
    )
    logging.info(f"  Tag 'os' set to '{os_info}' for {instance_id}.")
    
def main():
    reservations = ec2.describe_instances(Filters=[
        {'Name': 'instance-state-name', 'Values': ['running']}
    ])['Reservations']
    for reservation in reservations:
        for instance in reservation['Instances']:
            instance_id = instance['InstanceId']
            platform = instance.get('Platform', 'linux').lower()  # 小文字に変換
            logging.info(f"Processing {instance_id} (Platform: {platform})...")
            try:
                os_info = get_os_info(instance_id, platform)
                logging.info(f"  OS: {os_info}")
            except TimeoutError as te:
                logging.error(f"  Timeout error for {instance_id}: {te}")
                os_info = "unknown"
            except RuntimeError as re:
                logging.error(f"  Runtime error for {instance_id}: {re}")
                os_info = "unknown"
            except Exception as e:
                logging.error(f"  Unexpected error for {instance_id}: {e}")
                os_info = "unknown"
            # タグ付け処理
            tag_instance_with_os(instance_id, os_info)
if __name__ == "__main__":
    main()
```

- 実行
単純に上記のpyファイルを実行するだけです。前提で記述したように適切なAWS認証認可が行われていることは確認しましょう。
```
> py .\ec2_os_tagging.py
```

### その他備考
#### 単一リージョン処理
対象とするEC2インスタンスのリージョンはスクリプト内のboto3クライアントで指定しています。設定された環境変数を参照して単一リージョン内で処理されます。多数のリージョンで回す場合は"region"変数で適宜ループさせるなど修正が必要です。

#### OS情報どこまで入れるか問題
このスクリプトではマシンの出力OS情報から空白スペースは除去しさらにLinuxでは正規表現でメジャーバージョンのみを抽出しています。ヒト目線としてはメジャーバージョンまでで切る方が視認性やコーディング、グルーピングで便利かと思います。ただし、この処理を入れるとコードの複雑度や情報の抽象度・表記ゆれ度が上がるので注意が必要です。

:::note info
例　"AmazonLinux2023", "Ubuntu22", "MicrosoftWindowsServer2019Standard"などと設定されます。
⇒　空白は除去したほうがCSV出力してエクセルマンが使うときの利便性も上がります。メジャー/マイナーバージョンをどこまで入れるかは、OS情報に求める要件や各種Linuxディストリビューションに対する正規表現でのマッチング網羅性次第です。好みに応じて修正してください（丸投げ）。
:::

- 別方式
os-releaseのPRETTY_NAME行をそのままタグ値に入れる方がシンプルでより一意的かもですが、マイナーバージョンなどの数値も含みます。"AmazonLinux2023.7.20250428", "Ubuntu22.04.5LTS"等。
> PRETTY_NAME: A pretty operating system name in a format suitable for presentation to the user.

どうせ手を加えるならWindowsの前の"Micorosft"とかも削ったほうがいいのかも知らんが。あなたの要件次第です。

#### 実行契機について
既にosタグキーに同じ情報が付与されていればタグ付け処理はスキップします。osがアップデートして変更されていたら新しいものに上書きされます。よって冪等性はあるので、定期実行したりOS Updateやインスタンス追加の契機で実行するでもいいと思います。

#### ssmコマンド送信時のスリープについて
SSM 経由で送信したコマンドの結果を 最大20秒（2秒 × 10回）待ちながら監視しています：
```
    for _ in range(10):
        time.sleep(2)
```

まあ、OS情報をcatするぐらいなら通常は即結果が出るので待機したり再試行組み込んだりする必要もないのですが。yumとかバッチとかssmで時間のかかる見込みのあるコマンド送信処理をする名残です。
なのでインスタンス数×2秒はスクリプトが動き続けるので大きい環境では待つかスリープを消してください。