実在するインスタンスのタグを確認してから SSM Run Command を実行したい

実在するインスタンスのタグを確認してから SSM Run Command を実行したい

Clock Icon2025.03.13

はじめに

皆様こんにちは、あかいけです。
突然ですが、Systems Manager Run Command 使っていますか??

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/run-command.html

Systems Managerは痒いところに手が届くサービスの集合体ですが、
その中でもRun Commandは、意外と利用頻度が高いのではないでしょうか。
私の場合はPoCや初期構築など、ガッチリAnsibleを組むほどの工数はかけられないけどコマンドを一括で実行したい、みたいな場合に利用することが多いです。

ただ、個人的に一つだけ不満な点があります。
それはマネジメントコンソールから実行する際、
ターゲットとしてインスタンスのタグを指定した場合、実際にどのインスタンスが該当するか事前に確認できないことです。

例として以下のようにNameタグを指定した場合、
対象となるインスタンスを確認できず、そのまま実行することになります。

スクリーンショット 2025-03-04 14.12.08

そのため、タグ指定の誤りによって、誤ったインスタンスを対象としてしまう可能性があります。
これは非常に危険ですね…。

またこのような仕様のため、指定したタグが付与されているインスタンスが存在しない場合も実行でき、その場合は以下の実行結果となります。

スクリーンショット 2025-03-04 14.13.12

そこで今回はAWS CLIを利用して、
実在するインスタンスのタグを確認した上でRun Commandを実行するシェルスクリプトを作ってみました。

なお、Run Commandを利用するにあたり、前提条件としてマネージドノードのセットアップが必要になります。
詳しい手順は本記事には含めないため、詳細は以下をご参照ください。

また、ローカル環境でAWS CLIが必要になるため、各自インストールしてください。

シェルスクリプト

まずシェルスクリプトの全量は以下の通りです。
軽い気持ちで作り始めましたが、思いのほか行数が多くなったため折りたたんでおきます。

シェルスクリプト 全量

シェルスクリプト 全量
ssm-run-cmd.sh
#!/bin/bash
##########################################################################
# 利用例:
# ssm-run-command.sh
# ssm-run-command.sh -t env:tes -d AWS-RunShellScript -f parameters.json
#
# オプション:
#   -t <タグ>            # タグを指定 (key:value形式)
#   -d <ドキュメント名>    # 実行するSSM Run Commandドキュメント名を指定
#   -f <パラメータファイル> # SSM Run CommandドキュメントのパラメータをJSONファイルで指定
##########################################################################

TAG=""
DOCUMENT_NAME=""
PARAMETERS_FILE=""

while getopts "t:d:f:" opt; do
  case $opt in
    t)
      TAG="$OPTARG"
      ;;
    d)
      DOCUMENT_NAME="$OPTARG"
      ;;
    f)
      PARAMETERS_FILE="$OPTARG"
      ;;
    \?)
      echo "無効なオプション: -$OPTARG" >&2
      echo "Usage: $0 [-t タグ] [-d ドキュメント名] [-f パラメータファイル]"
      exit 1
      ;;
    :)
      echo "オプション -$OPTARG には引数が必要です。" >&2
      exit 1
      ;;
  esac
done

CACHE_DIR="/tmp/ssm_cache_$$"
mkdir -p "$CACHE_DIR"

cleanup() {
  if [[ -d "$CACHE_DIR" ]]; then
    rm "$CACHE_DIR"/*.json
    rmdir "$CACHE_DIR"
  fi
}
trap cleanup EXIT

# ssmが認識しているインスタンスのID一覧取得
get_ssm_instances() {
  aws ssm describe-instance-information --query "InstanceInformationList[*].InstanceId" --output text
}

# キャッシュファイル作成
cache_instance_info() {
  local instance_ids=("$@")

  aws ec2 describe-instances \
    --instance-ids "${instance_ids[@]}" \
    --query "Reservations[].Instances[].[InstanceId,Tags]" \
    --output json > "$CACHE_DIR/instance_data.json"

  jq -c '.[]' "$CACHE_DIR/instance_data.json" | while read -r line; do
    local instance_id
    instance_id=$(echo "$line" | jq -r '.[0]')
    echo "$line" | jq -r '.[1]' > "$CACHE_DIR/${instance_id}.json"
  done
}

# インスタンスIDからNameタグ取得
get_instance_name() {
  local instance_id="$1"
  local cache_file="$CACHE_DIR/${instance_id}.json"

  if [[ -f "$cache_file" ]]; then
    jq -r 'map(select(.Key == "Name") | .Value) | .[0] // "N/A"' "$cache_file"
  else
    echo "N/A"
  fi
}

# インスタンスIDからタグ一覧取得
get_instance_tags() {
  local instance_id="$1"
  local cache_file="$CACHE_DIR/${instance_id}.json"

  if [[ -f "$cache_file" ]]; then
    jq -r 'if . == null then "" else (map("\(.Key):\(.Value)") | join(" ")) end' "$cache_file"
  else
    echo ""
  fi
}

# ユーザー入力確認プロンプト
prompt_confirmation() {
  local prompt_message="$1"
  read -rp "$prompt_message (y/n): " answer
  if [[ "$answer" != "y" && "$answer" != "Y" ]]; then
    echo "キャンセルされました。"
    exit 0
  fi
}

# インスタンス一覧表示
display_instance_list() {
  local instances=("$@")
  local index=1

  for inst in "${instances[@]}"; do
    name=$(get_instance_name "$inst")
    tags=$(get_instance_tags "$inst")

    echo "$index) Name: ${name} / InstanceID: ${inst} / Tags: ${tags}"
    ((index++))
  done
}

# 指定されたタグを含むインスタンスを検索
filter_instances_by_tag() {
  local instances=("$@")
  local filtered_instances=()

  for inst in "${instances[@]}"; do
    tags=$(get_instance_tags "$inst")
    if [[ "$tags" == *"$TAG"* ]]; then
      filtered_instances+=("$inst")
    fi
  done

  echo "${filtered_instances[@]}"
}

run_ssm_command() {
  local instance_ids=("$@")

  cmd="aws ssm send-command \
    --instance-ids ${instance_ids[*]} \
    --document-name \"$DOCUMENT_NAME\" \
    --cli-input-json file://$PARAMETERS_FILE \
    --query \"Command.CommandId\" --output text"

  command_id=$(eval "$cmd")
  if [[ $? -ne 0 ]];then
    exit
  fi

  echo ""
  echo "Command ID: ${command_id}"
  echo "コマンドを実行中..."

  local remaining_instances=("${instance_ids[@]}")

  while [[ ${#remaining_instances[@]} -gt 0 ]]; do
    local next_round_instances=()

    for instance in "${remaining_instances[@]}"; do
      status=$(aws ssm list-command-invocations \
        --command-id "$command_id" \
        --instance-id "$instance" \
        --query "CommandInvocations[0].Status" --output text 2>/dev/null)

      if [[ "$status" == "Success" || "$status" == "Failed" || "$status" == "Cancelled" || "$status" == "TimedOut" ]]; then
        instance_name=$(get_instance_name "$instance")
        echo "----------------------------------------"
        echo "Name: ${instance_name} / Instance: ${instance} / Status: ${status}"
        echo ""
        echo "Log:"
        output=$(aws ssm list-command-invocations \
          --command-id "$command_id" \
          --instance-id "$instance" \
          --details \
          --query "CommandInvocations[0].CommandPlugins[0].Output" --output text)
        echo "$output"
        echo ""
      else
        next_round_instances+=("$instance")
      fi
    done

    if [[ ${#next_round_instances[@]} -gt 0 ]]; then
      echo "----------------------------------------"
      echo "未完了のインスタンスあり。5秒待機して再確認..."
      sleep 5
    fi

    remaining_instances=("${next_round_instances[@]}")
  done

  echo "----------------------------------------"
  echo "すべてのインスタンスでコマンドが完了しました。"
}

main() {
  echo ""
  full_instances=($(get_ssm_instances))
  if [[ ${#full_instances[@]} -eq 0 ]]; then
    echo "SSM に認識されているインスタンスがありません。"
    exit 1
  fi

  cache_instance_info "${full_instances[@]}"
  if [[ -z "$TAG" ]]; then
    echo "[SSM で認識されている全インスタンス]"
    display_instance_list "${full_instances[@]}"
    echo ""
    while [[ -z "$TAG" ]]; do
      read -rp "タグを入力して下さい。(例: Name:hogehoge): " TAG
    done;
  fi

  filtered_instances=($(filter_instances_by_tag "${full_instances[@]}"))
  if [[ ${#filtered_instances[@]} -eq 0 ]]; then
    echo "指定したタグが付与されたインスタンスがありません。"
    exit 1
  fi

  while [[ -z "$DOCUMENT_NAME" ]]; do
    read -rp "ドキュメント名を入力して下さい。(例: AWS-RunShellScript): " DOCUMENT_NAME
  done;

  while [[ -z "$PARAMETERS_FILE" ]]; do
    read -rp "パラメータファイルのパスを入力して下さい。(例: parameters.json): " PARAMETERS_FILE
  done;

  if [[ ! -f "$PARAMETERS_FILE" ]]; then
    echo "エラー: 指定されたパラメータファイル '$PARAMETERS_FILE' が存在しません。"
    exit 1
  fi

  echo ""
  echo "----------------------------------------"
  echo "[実行対象のインスタンス]"
  display_instance_list "${filtered_instances[@]}"
  echo ""
  echo "----------------------------------------"
  echo "[ドキュメント]: $DOCUMENT_NAME"
  echo ""
  echo "----------------------------------------"
  echo "[パラメータファイル]: $PARAMETERS_FILE"
  cat "$PARAMETERS_FILE"
  echo ""
  echo "----------------------------------------"
  echo ""
  prompt_confirmation "上記の内容で実行してもよろしいですか?"

  run_ssm_command "${filtered_instances[@]}"
}

main

実行イメージ

ドキュメントのパラメータは、パラメータを記述したJSONファイルにて指定しています。
具体的にはaws ssm send-commandコマンドの--cli-input-jsonオプションにJSONファイルのパスを渡しています。

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/walkthrough-cli.html#walkthrough-cli-example-3

以下はサンプルとして用意した、
AWS-RunShellScript用のhostnameを実行するだけのJSONファイルです。

parameter.json
{
    "Parameters": {
        "commands": [
            "hostname"
        ]
    }
}

パターン①:オプションを指定しない場合

SSMが認識しているインスタンスの一覧が表示されるので、実行対象とするインスタンスのタグを入力して下さい。
続いて実行対象のドキュメント名、JSONファイルのパスも入力して下さい。

オプション入力
% ssm-run-command.sh

[SSM で認識されている全インスタンス]
1) Name: web-server-01 / InstanceID: i-XXXXXXXXXXXXXXXXX / Tags: Name:web-server-01 env:tes
2) Name: web-server-02 / InstanceID: i-YYYYYYYYYYYYYYYYY / Tags: env:tes Name:web-server-02
3) Name: web-server-03 / InstanceID: i-ZZZZZZZZZZZZZZZZZ / Tags: env:stg Name:web-server-03

タグを入力して下さい。(例: Name:hogehoge): Name:web-server-02
ドキュメント名を入力して下さい。(例: AWS-RunShellScript): AWS-RunShellScript
パラメータファイルのパスを入力して下さい。(例: parameters.json): parameter.json

入力したタグが付与されているインスタンス、またドキュメント名とJSONファイルのパスと内容が表示されるので、
問題なければyで実行します。

実行対象確認
----------------------------------------
[実行対象のインスタンス]
1) Name: web-server-02 / InstanceID: i-YYYYYYYYYYYYYYYYY / Tags: env:tes Name:web-server-02

----------------------------------------
[ドキュメント]: AWS-RunShellScript

----------------------------------------
[パラメータファイル]: parameter.json
{
    "Parameters": {
        "commands": [
            "hostname"
        ]
    }
}
----------------------------------------

上記の内容で実行してもよろしいですか? (y/n): y

あとは実行完了したものから順々に結果が出力されます。

SSM Run Command 実行
Command ID: 5ef89bb0-df30-4aa3-9981-3a504f125c7b
コマンドを実行中...
----------------------------------------
Name: web-server-02 / Instance: i-YYYYYYYYYYYYYYYYY / Status: Success

Log:
ip-10-0-1-116.ap-northeast-1.compute.internal

----------------------------------------

すべてのインスタンスでコマンドが完了しました。

パターン②:オプションを指定する場合

オプションで指定したタグが付与されているインスタンス、またドキュメント名とJSONファイルのパスと内容が表示されるので、
問題なければyで実行します。

実行対象確認
% ssm-run-command.sh -t env:tes -d AWS-RunShellScript -f parameter.json

----------------------------------------
[実行対象のインスタンス]
1) Name: web-server-01 / InstanceID: i-XXXXXXXXXXXXXXXXX / Tags: Name:web-server-01 env:tes
2) Name: web-server-02 / InstanceID: i-YYYYYYYYYYYYYYYYY / Tags: env:tes Name:web-server-02

----------------------------------------
[ドキュメント]: AWS-RunShellScript

----------------------------------------
[パラメータファイル]: parameter.json
{
    "Parameters": {
        "commands": [
            "hostname"
        ]
    }
}
----------------------------------------

上記の内容で実行してもよろしいですか? (y/n): y

あとは実行完了したものから順々に結果が出力されます。

SSM Run Command 実行
Command ID: 1e0fe575-9fff-445c-b1a4-e4a647532fff
コマンドを実行中...
----------------------------------------
Name: web-server-01 / Instance: i-XXXXXXXXXXXXXXXXX / Status: Success

Log:
ip-10-0-0-125.ap-northeast-1.compute.internal

----------------------------------------
未完了のインスタンスあり。5秒待機して再確認...
----------------------------------------
Name: web-server-02 / Instance: i-YYYYYYYYYYYYYYYYY / Status: Success

Log:
ip-10-0-1-116.ap-northeast-1.compute.internal

----------------------------------------
すべてのインスタンスでコマンドが完了しました。

処理内容

知らない数百行のシェルスクリプトを実行するのは不安だと思うので、
ポイントとなる処理をいくつか解説します。

(1).インスタンス情報のキャッシュ

対象となるインスタンスはSSMがマネージドノードとして認識しているものに限定したいので、aws ssm describe-instance-informationでインスタンスIDの一覧を取得して、それを元にaws ec2 describe-instancesでタグ情報を取得しています。

また、処理の中で複数回インスタンスを参照するため、インスタンス単位でキャッシュファイルとして保存しています。
この後の処理ではキャッシュを参照することで、API(AWS CLI)の実行回数を最小限にしています。

# ssmが認識しているインスタンスのID一覧取得
get_ssm_instances() {
  aws ssm describe-instance-information --query "InstanceInformationList[*].InstanceId" --output text
}

# キャッシュファイル作成
cache_instance_info() {
  local instance_ids=("$@")

  aws ec2 describe-instances \
    --instance-ids "${instance_ids[@]}" \
    --query "Reservations[].Instances[].[InstanceId,Tags]" \
    --output json > "$CACHE_DIR/instance_data.json"

  jq -c '.[]' "$CACHE_DIR/instance_data.json" | while read -r line; do
    local instance_id
    instance_id=$(echo "$line" | jq -r '.[0]')
    echo "$line" | jq -r '.[1]' > "$CACHE_DIR/${instance_id}.json"
  done
}

(2).タグによるインスタンスフィルタリング

次に指定されたタグから対象インスタンスを割り出す処理ですが、作成したキャッシュファイルを元にフィルタリングを行っています。

# インスタンスIDからタグ一覧取得
get_instance_tags() {
  local instance_id="$1"
  local cache_file="$CACHE_DIR/${instance_id}.json"

  if [[ -f "$cache_file" ]]; then
    jq -r 'if . == null then "" else (map("\(.Key):\(.Value)") | join(" ")) end' "$cache_file"
  else
    echo ""
  fi
}

# 指定されたタグを含むインスタンスを検索
filter_instances_by_tag() {
  local instances=("$@")
  local filtered_instances=()

  for inst in "${instances[@]}"; do
    tags=$(get_instance_tags "$inst")
    if [[ "$tags" == *"$TAG"* ]]; then
      filtered_instances+=("$inst")
    fi
  done

  echo "${filtered_instances[@]}"
}

(3).Run Commandの実行とステータス監視

SSM Run Command の実行自体はaws ssm send-commandで行っており、
その後にaws ssm list-command-invocationsでステータスを継続的に確認して、特定のステータスになったものから結果を表示しています。

run_ssm_command() {
  local instance_ids=("$@")

  cmd="aws ssm send-command \
    --instance-ids ${instance_ids[*]} \
    --document-name \"$DOCUMENT_NAME\" \
    --cli-input-json file://$PARAMETERS_FILE \
    --query \"Command.CommandId\" --output text"

  command_id=$(eval "$cmd")
  if [[ $? -ne 0 ]];then
    exit
  fi

  echo ""
  echo "Command ID: ${command_id}"
  echo "コマンドを実行中..."

  local remaining_instances=("${instance_ids[@]}")

  while [[ ${#remaining_instances[@]} -gt 0 ]]; do
    local next_round_instances=()

    for instance in "${remaining_instances[@]}"; do
      status=$(aws ssm list-command-invocations \
        --command-id "$command_id" \
        --instance-id "$instance" \
        --query "CommandInvocations[0].Status" --output text 2>/dev/null)

      if [[ "$status" == "Success" || "$status" == "Failed" || "$status" == "Cancelled" || "$status" == "TimedOut" ]]; then
        instance_name=$(get_instance_name "$instance")
        echo "----------------------------------------"
        echo "Name: ${instance_name} / Instance: ${instance} / Status: ${status}"
        echo ""
        echo "Log:"
        output=$(aws ssm list-command-invocations \
          --command-id "$command_id" \
          --instance-id "$instance" \
          --details \
          --query "CommandInvocations[0].CommandPlugins[0].Output" --output text)
        echo "$output"
        echo ""
      else
        next_round_instances+=("$instance")
      fi
    done

    if [[ ${#next_round_instances[@]} -gt 0 ]]; then
      echo "----------------------------------------"
      echo "未完了のインスタンスあり。5秒待機して再確認..."
      sleep 5
    fi

    remaining_instances=("${next_round_instances[@]}")
  done

  echo "----------------------------------------"
  echo "すべてのインスタンスでコマンドが完了しました。"
}

さいごに

以上、実在するインスタンスのタグを確認して SSM Run Command を実行する方法でした。
ささやかなシェルスクリプトですが、誰かの助けになれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.