SSM Run Command で複数台にスクリプトを流すときに引っかかった 3 点について検証してみた

SSM Run Command で複数台にスクリプトを流すときに引っかかった 3 点について検証してみた

AWS Systems Manager の Run Command を複数台で運用する際に踏みやすい3つの落とし穴を、検証してみました
2026.09.14

はじめに

かつまたです。

AWS Systems Manager の Run Command は、SSH を開けずに EC2 へコマンドを実行できるため、複数台への設定変更やスクリプト実行でよく使われます。
1 台への単発実行では問題にならなくても、数十台への一括実行になると独特の落とし穴があります。

本記事では、実際に複数台運用で踏んだ 3 点を検証環境で再現し、それぞれの原因と対策をまとめてみます。

  • 罠 1: インラインでスクリプトを渡すと壊れる
  • 罠 2: Status が Success なのに実行されていない
  • 罠 3: 一括投入で「適用されない個体」が混ざる

やりたいこと

3 つの罠を検証環境で意図的に再現し、壊れ方の実際の出力と、検出・回避の方法を確認します。

前提条件

  • Amazon EC2(Amazon Linux 2023、t3.micro)× 2 台。以降 A・B と呼びます
  • 罠 3 の検証用に、B だけ設定ファイル /etc/app/app.conf の空白を 2 個にして、構築時期による個体差を模擬
  • 実行環境: AWS CloudShell(AWS CLI v2)

罠 1: インラインでスクリプトを渡すと切れてエラーになってしまった

何が起きるか

まず、カンマを含む sed コマンドを shorthand 構文(commands="..." 形式の簡易記法)で渡してみます。

aws ssm send-command \
  --document-name "AWS-RunShellScript" \
  --instance-ids i-0e8a67f59c0ad7caf \
  --parameters commands="sed -i s/example.com,example.net/example.org/ /etc/app/app.conf"

1 つのコマンドのつもりですが、SSM には 2 要素の配列として登録されます。

$ aws ssm list-commands --command-id ${CMD_ID} \
    --query 'Commands[0].Parameters.commands' --output json
[
    "sed -i s/example.com",
    "example.net/example.org/ /etc/app/app.conf"
]

sed はパターンが途中で切れて構文エラー、後半は独立したコマンドとして実行されて「No such file or directory」になりました。

{
    "Status": "Failed",
    "ExitCode": 127,
    "Stdout": "",
    "Stderr": "sed: -e expression #1, char 13: unterminated `s' command\n/var/lib/amazon/ssm/i-0e8a67f59c0ad7caf/document/orchestration/8cdae561-769a-4e9b-867c-4dda2977773e/awsrunShellScript/0.awsrunShellScript/_script.sh: line 2: example.net/example.org/: No such file or directory\nfailed to run commands: exit status 127"
}

コンソールのコマンド詳細でも、パラメータが 2 要素に分かれて登録されたことを確認できます。

スクリーンショット 2026-09-14 1.02.08.png

では JSON 形式で渡せばよいかというと今度は複数行スクリプトでエラーが発生しました。

$ aws ssm send-command \
    --document-name "AWS-RunShellScript" \
    --instance-ids i-0e8a67f59c0ad7caf \
    --parameters "{\"commands\": [\"$(cat deploy.sh)\"]}"

aws: [ERROR]: An error occurred (ParamValidation): Error parsing parameter '--parameters': Invalid JSON: Invalid control character at: line 1 column 27 (char 26)

なぜ起きるか

コマンド文字列は実行までに 3 つの層で解釈されます。

① ローカルシェルのクォート解釈
② AWS CLI のパラメータ解析
   - shorthand 構文はカンマを配列の区切りとして解釈する
   - JSON 構文は生の改行(制御文字)を許さない
③ リモート側で commands 配列の各要素がシェルのスクリプト行として実行

各層でクォート・改行・カンマが別々に処理されるため、エスケープを重ねて通そうとしても、どこかの層で崩れます。

対策: base64 で渡す

スクリプト本体をエスケープの対象から外します。

# ローカルでスクリプトを base64 化(改行なしの 1 行にする)
B64=$(base64 deploy.sh | tr -d '\n')

aws ssm send-command \
  --document-name "AWS-RunShellScript" \
  --instance-ids i-0e8a67f59c0ad7caf \
  --parameters "commands=[\"echo ${B64} | base64 -d > /tmp/deploy.sh && bash /tmp/deploy.sh\"]"

SSM に渡る文字列が ASCII 英数字のみになるため、3 つの層のどこでも壊れません。先ほど CLI に拒否されたのと同じ deploy.sh が、そのまま通ります。

{
    "Status": "Success",
    "ExitCode": 0,
    "Stdout": "redirect.conf: 61 bytes\n",
    "Stderr": ""
}

罠 2: Status が Success なのに実行されていなかった

何が起きるか

設定ファイルを生成するスクリプトで、catat とタイポしたとします。

#!/bin/bash
at > /tmp/generated.conf <<'EOF'
server_name example.com;
EOF
chmod 640 /tmp/generated.conf

結果は以下になります。

{
    "Status": "Success",
    "ExitCode": 0,
    "Stdout": "",
    "Stderr": "Garbled time\n"
}

スクリーンショット 2026-09-14 1.01.38.png

Status は Success です。ところが、生成されたはずのファイルを確認すると 0 バイトです。

{
    "Status": "Success",
    "ExitCode": 0,
    "Stdout": "-rw-r-----. 1 root root 0 Sep 13 14:27 /tmp/generated.conf\n0 /tmp/generated.conf\n",
    "Stderr": ""
}

スクリーンショット 2026-09-14 1.00.52.png

なぜ起きるか

3 つの仕様が重なっています。

  1. シェルはリダイレクトをコマンド実行より先に処理する: at > file の時点で空ファイルが作られ、その後に at の実行が失敗する
  2. at は実在するコマンド(ジョブスケジューラ)のため、エラーが「command not found」ではなく「Garbled time」という一見無関係なメッセージになる
  3. SSM の Success は「最後のコマンドの終了コードが 0」しか意味しない: エラーの後に chmod が成功(空ファイルは存在するため)して終了コードが 0 になり、全体が Success と報告される

一括実行では全台が同じように偽装成功するため、Status の一覧を眺めても異常に見えません。今回の元になった実務でも、リセットも投入も実行されないまま「変更なし = 一致」となり、検証 1 回目の合格判定が無効になっていました。

スクリーンショット 2026-09-14 0.38.35.png

対策: 出力を明示的に確認する

Status だけでは実際に実行されたかどうか分からないため、出力の中身まで確認する必要があります。

aws ssm get-command-invocation \
  --command-id ${CMD_ID} --instance-id i-0e8a67f59c0ad7caf \
  --query '{Status:Status, OutBytes:length(StandardOutputContent), ErrBytes:length(StandardErrorContent)}'

罠 3: 一括投入で適用されなかったリソースが発生した

何が起きるか

2 台の EC2 の設定ファイルには、構築時期の違いを模した微差があります。

# A
server_name example.com;$
# B
server_name  example.com;$

同一の sed を 2 台へ一括投入します。

sed -i 's/server_name example.com;/server_name example.com www.example.com;/' /etc/app/app.conf

結果は 2 台とも Success です。

// A
{ "Status": "Success", "ExitCode": 0, "Stdout": "", "Stderr": "" }
// B
{ "Status": "Success", "ExitCode": 0, "Stdout": "", "Stderr": "" }

スクリーンショット 2026-09-14 0.39.59.png

しかし変更後の状態を確認すると、B には適用されていません。

// A
{ "Status": "Success", "Stdout": "APPLIED\nserver_name example.com www.example.com;\n" }
// B
{ "Status": "Success", "Stdout": "NOT_APPLIED\nserver_name  example.com;\n" }

なぜ起きるか

sed は置換パターンが 1 件も一致しなくても終了コード 0 で正常終了します。長期運用のフリートは「同じ構成のはず」でも、構築時期による差異(設定ファイルのパス・書式・デフォルト値)を持っています。B の空白 2 個のような微差でパターンが外れると、罠 2 と同型の偽装成功が個体単位で起きます。

対策: 投入後に変更後の状態を全台確認する

コマンドの成否ではなく、変更後の状態そのものを全台で確認することが予防になります。

# 期待する状態になっているか確認
aws ssm send-command \
  --document-name "AWS-RunShellScript" \
  --targets "Key=tag:Project,Values=my-fleet" \
  --parameters 'commands=["grep -q \"www.example.com\" /etc/app/app.conf && echo APPLIED || echo NOT_APPLIED"]'

APPLIED / NOT_APPLIED を全台分集計すれば、適用漏れの個体が機械的に特定できます。

おわりに

ご覧いただきましてありがとうございました。
今回ご紹介した 3 つはケアレスミスも含むものですが、Run Command 実行時に踏んでしまうこともありそうなミスなので備忘録としてブログ化してみました。
同じ状況に遭遇した方の助けになれば幸いです。

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。
※2026年1月 アノテーション㈱から社名変更しました

この記事をシェアする

関連記事