実在するインスタンスのタグを確認してから SSM Run Command を実行したい
はじめに
皆様こんにちは、あかいけです。
突然ですが、Systems Manager Run Command 使っていますか??
Systems Managerは痒いところに手が届くサービスの集合体ですが、
その中でもRun Commandは、意外と利用頻度が高いのではないでしょうか。
私の場合はPoCや初期構築など、ガッチリAnsibleを組むほどの工数はかけられないけどコマンドを一括で実行したい、みたいな場合に利用することが多いです。
ただ、個人的に一つだけ不満な点があります。
それはマネジメントコンソールから実行する際、
ターゲットとしてインスタンスのタグを指定した場合、実際にどのインスタンスが該当するか事前に確認できないことです。
例として以下のようにNameタグを指定した場合、
対象となるインスタンスを確認できず、そのまま実行することになります。
そのため、タグ指定の誤りによって、誤ったインスタンスを対象としてしまう可能性があります。
これは非常に危険ですね…。
またこのような仕様のため、指定したタグが付与されているインスタンスが存在しない場合も実行でき、その場合は以下の実行結果となります。
そこで今回はAWS CLIを利用して、
実在するインスタンスのタグを確認した上でRun Commandを実行するシェルスクリプトを作ってみました。
なお、Run Commandを利用するにあたり、前提条件としてマネージドノードのセットアップが必要になります。
詳しい手順は本記事には含めないため、詳細は以下をご参照ください。
また、ローカル環境でAWS CLIが必要になるため、各自インストールしてください。
シェルスクリプト
まずシェルスクリプトの全量は以下の通りです。
軽い気持ちで作り始めましたが、思いのほか行数が多くなったため折りたたんでおきます。
シェルスクリプト 全量
シェルスクリプト 全量
#!/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ファイルのパスを渡しています。
以下はサンプルとして用意した、
AWS-RunShellScript用のhostnameを実行するだけの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
あとは実行完了したものから順々に結果が出力されます。
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
あとは実行完了したものから順々に結果が出力されます。
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 を実行する方法でした。
ささやかなシェルスクリプトですが、誰かの助けになれば幸いです。