はじめに

こんにちは、そしてこんばんは!
サービスプラットフォーム事業部の大嵩です。

今回は、EC2 インスタンスの SSM Agent 更新を手動運用から卒業すべく、AWS Systems Manager のメンテナンスウィンドウを使った自動更新の仕組みを Terraform で構築してみます!
業務のとある案件で実際に構築・運用している構成でして、夜間停止している検証環境で見事にハマったポイントも含めて紹介します!

みなさん、SSM Agent のバージョンアップってどうされていますか・・?
新しいバージョンが出るたびに Run Command で AWS-UpdateSSMAgent を手動実行するのは手間ですし、そもそも更新のタイミングを忘れがちですよね。
古いままだと Systems Manager の新機能が使えなかったり、脆弱性対応の観点でも良くないので、仕組みで解決してしまいましょう!

SSM Agent の更新、どうしてますか?

実は AWS 公式にも「自動化することをお勧めします」と明記されています。

AWS は、Systems Manager のツールを追加または更新して、AWS Systems Manager Agent (SSM Agent) の新しいバージョンをリリースします。マネージドノードが古いバージョンのエージェントを使用している場合、新しいツールの使用または更新されたツールを利用することができません。そのため、以下のいずれかの方法でマネージドノード上で SSM Agent を更新するプロセスを自動化することをお勧めします。

とのことですが、自動化の選択肢は主に 3 つありますね!

  1. Fleet Manager のワンクリック自動更新(推奨とされている方法)
  2. State Manager の関連付け
  3. メンテナンスウィンドウ

一番お手軽なのは 1 の Fleet Manager ですが、ここで一つ注意がありまして。
この方法は AWS アカウント内のすべてのマネージドノードが対象で、更新サイクルも 2 週間ごとの固定となっており、実行する時刻や曜日を指定できません。

今回の要件は「本番環境と検証環境で、それぞれ業務影響のない別々の夜間帯に更新したい」だったので、環境ごとにスケジュールを分けられるメンテナンスウィンドウ方式を採用しました!

SSM Agent への更新の自動化 - AWS Systems Manager
SSM Agent の更新プロセスを自動化して、新しい機能を使用したり、更新された機能のメリットを享受したりできます。

今回の構成

メンテナンスウィンドウについても公式の説明を見てみましょう。

AWS Systems Manager のツールである Maintenance Windows では、オペレーティングシステムのパッチ適用、ドライバーの更新、ソフトウェアやパッチのインストールなど、ノードに対して破壊的になり得るアクションを実行するスケジュールを定義できます。

とのことですが、「スケジュール + 対象(ターゲット)+ やること(タスク)」をセットで登録できる箱、というイメージですね!

今回は EC2 の環境タグ(例: Environment=prd / Environment=stg)ごとにウィンドウを分割して、環境別のタイミングで実行します。

  • prd(常時稼働): タグ直指定でターゲット登録。毎週火曜 01:00 に AWS-UpdateSSMAgent を実行
  • stg(夜間停止運用): インスタンス ID 直指定でターゲット登録。毎週月曜 01:00 に「起動 → 更新 → 停止」の 3 タスクを実行

stg の 3 タスク構成はこんな感じです。

priority タスク ドキュメント / タイプ
1 EC2 起動 AWS-StartEC2Instance(Automation)
2 Agent 更新 AWS-UpdateSSMAgent(Run Command)
3 EC2 停止 AWS-StopEC2Instance(Automation)

「なんで stg だけ ID 直指定なの?タグでよくない?」と思った方、鋭いです。
そこが今回最大のハマりポイントなので、後ほどたっぷり紹介します!

Terraform で構築する

それでは、実際に Terraform で構築してみましょう!

変数定義

環境ごとの差分は maintenance_windows というマップ変数に寄せて、リソース定義は for_each で共通化します。

variable "maintenance_windows" {
  description = "環境タグ値ごとの SSM Agent 更新メンテナンスウィンドウ定義"
  type = map(object({
    schedule             = string                         # cron 式
    schedule_timezone    = optional(string, "Asia/Tokyo")
    enabled              = optional(bool, true)
    duration             = optional(number, 2)            # ウィンドウ継続時間(時間)
    cutoff               = optional(number, 1)            # 新規タスク起動の締切
    max_concurrency      = optional(string, "1")          # 1台ずつ更新
    max_errors           = optional(string, "0")          # エラー発生で以降を停止
    allow_downgrade      = optional(bool, false)
    start_stop_instances = optional(bool, false)          # 起動→更新→停止を行うか
    instance_ids         = optional(list(string), [])     # start_stop_instances = true の場合は必須
  }))
}

tfvars はこのように書きます。
キーがそのまま環境タグの値とリソース名のサフィックスになります。

maintenance_windows = {
  stg = {
    schedule             = "cron(0 1 ? * MON *)"
    duration             = 1
    cutoff               = 0
    start_stop_instances = true
    instance_ids         = ["i-xxxxxxxxxxxxxxxxx", "i-yyyyyyyyyyyyyyyyy"]
  }

  prd = {
    schedule             = "cron(0 1 ? * TUE *)"
    duration             = 1
    cutoff               = 0
    start_stop_instances = false
  }
}

ウィンドウとターゲット

ウィンドウ本体とターゲットです。
ポイントはターゲットの三項演算子で、常時稼働の環境はタグ直指定、夜間停止の環境はインスタンス ID 直指定に切り替えています。

resource "aws_ssm_maintenance_window" "ssm_agent_update" {
  for_each = var.maintenance_windows

  name              = "ssm-agent-update-${each.key}"
  schedule          = each.value.schedule
  schedule_timezone = each.value.schedule_timezone
  duration          = each.value.duration
  cutoff            = each.value.cutoff
  enabled           = each.value.enabled
}

resource "aws_ssm_maintenance_window_target" "ssm_agent_update" {
  for_each = var.maintenance_windows

  window_id     = aws_ssm_maintenance_window.ssm_agent_update[each.key].id
  name          = "${each.key}-instances"
  resource_type = "INSTANCE"

  targets {
    key    = each.value.start_stop_instances ? "InstanceIds" : "tag:${var.target_tag_key}"
    values = each.value.start_stop_instances ? each.value.instance_ids : [each.key]
  }
}

タスク定義(起動 → 更新 → 停止)

メインの更新タスクは Run Command で AWS-UpdateSSMAgent を実行するだけです。

resource "aws_ssm_maintenance_window_task" "ssm_agent_update" {
  for_each = var.maintenance_windows

  window_id       = aws_ssm_maintenance_window.ssm_agent_update[each.key].id
  task_type       = "RUN_COMMAND"
  task_arn        = "AWS-UpdateSSMAgent"
  priority        = 2
  max_concurrency = each.value.max_concurrency
  max_errors      = each.value.max_errors

  targets {
    key    = "WindowTargetIds"
    values = [aws_ssm_maintenance_window_target.ssm_agent_update[each.key].id]
  }

  task_invocation_parameters {
    run_command_parameters {
      document_version = "$LATEST"
      timeout_seconds  = 600

      parameter {
        name   = "allowDowngrade"
        values = [each.value.allow_downgrade ? "true" : "false"]
      }
    }
  }
}

夜間停止環境向けの起動タスク(priority 1)と停止タスク(priority 3)は、Automation ドキュメントの AWS-StartEC2Instance / AWS-StopEC2Instance を使います。
InstanceId パラメータには {{RESOURCE_ID}} 擬似パラメータを渡すと、ターゲットのインスタンス ID が入ります。

resource "aws_ssm_maintenance_window_task" "ec2_start" {
  for_each = local.start_stop_envs

  window_id        = aws_ssm_maintenance_window.ssm_agent_update[each.key].id
  task_type        = "AUTOMATION"
  task_arn         = "AWS-StartEC2Instance"
  priority         = 1
  service_role_arn = aws_iam_role.maintenance_window_automation[0].arn
  max_concurrency  = "100%"
  max_errors       = "0"

  targets {
    key    = "WindowTargetIds"
    values = [aws_ssm_maintenance_window_target.ssm_agent_update[each.key].id]
  }

  task_invocation_parameters {
    automation_parameters {
      document_version = "$LATEST"

      parameter {
        name   = "InstanceId"
        values = ["{{RESOURCE_ID}}"]
      }
    }
  }
}

起動と停止は全台同時(max_concurrency = "100%")で実行して、ウィンドウ内の所要時間を短くしています。
また、停止タスクは priority 3 の後続タスクとして実行されるため、万が一更新タスクが失敗してもインスタンスが起動しっぱなしにならないのがポイントです!

ポイント: 起動直後の Agent オンライン待ち

「起動タスクの直後に Run Command を打って間に合うの?」と思いますよね。
起動直後は SSM Agent がオンラインになるまで数十秒かかりますが、Run Command の配信タイムアウト(timeout_seconds = 600)の間はコマンドが Pending で待機してくれるので、追加の待ち処理は不要でした!

IAM ロール

必要な権限は 2 種類に分かれます。

  • Automation タスク(起動 / 停止): service_role_arn の指定が必要。ssm.amazonaws.com を信頼した IAM ロールに、管理ポリシー AmazonSSMAutomationRole と、タグ条件付きの ec2:StartInstances / ec2:StopInstances インラインポリシーを付与
  • Run Command タスク(Agent 更新): service_role_arn 未指定でサービスリンクロール(AWSServiceRoleForAmazonSSM)により実行。インスタンスプロファイルの AmazonSSMManagedInstanceCore だけで完結し、追加権限は不要

起動 / 停止のインラインポリシーはこんな感じです。

{
  "Effect": "Allow",
  "Action": [
    "ec2:StartInstances",
    "ec2:StopInstances"
  ],
  "Resource": "*",
  "Condition": {
    "StringEquals": {
      "ec2:ResourceTag/Environment": ["stg"]
    }
  }
}

なお、管理ポリシー AmazonSSMAutomationRoleec2:StartInstances / ec2:StopInstances に加えて ec2:RunInstances / ec2:TerminateInstances なども Resource: "*" で許可しています。
IAM の Allow は加算式なので、上記のタグ条件付きインラインポリシーを足しても「タグを持つインスタンスに限定される」わけではない点はご注意ください。
実際の対象の絞り込みはメンテナンスウィンドウのターゲット(InstanceIds / タグ)側で行っています。
より厳密に権限を絞りたい場合は、管理ポリシーを使わず必要最小限のカスタムポリシーに置き換えてください。

AmazonSSMAutomationRole - AWS Managed Policy
About the AWS managed policy: AmazonSSMAutomationRole

ハマりポイント:停止中のインスタンスはタグでターゲットに入らない

さて、お待ちかねのハマりポイントです。

当初は stg もタグ直指定でターゲット登録して、「起動タスクを priority 1 に置いておけば、停止中でも起動してくれるでしょ!」という想定で初回実行を迎えました。
翌朝実行履歴を確認すると、起動タスクすら実行されておらず、まさかの空振りでした。。。

原因を調べたところ、メンテナンスウィンドウのタグ指定ターゲット(key = "tag:xxx")は、オンラインの SSM マネージドノードの中からしか解決されないことがわかりました。
EC2 API のタグフィルタとは挙動が違い、停止中のインスタンスは SSM Agent が動いていないため、そもそもターゲット候補に現れないんですね。
つまり、ウィンドウ開始時点で停止しているインスタンスは、ターゲット解決の時点で対象 0 台となり、priority 1 の起動タスク自体が実行されません。

対策は、実行時間帯に停止している環境だけ key = "InstanceIds" でインスタンス ID を直指定することです。
先ほどのターゲット定義の三項演算子は、この対策の結果というわけでした!

ただし、ID 直指定はタグと違ってインスタンスの追加・入れ替えに自動追従しないというトレードオフがあります。
tfvars の更新漏れを防ぐため、variable validation で「start_stop_instances = true なのに instance_ids が空」を plan 時に弾くようにしています。

validation {
  condition = alltrue([
    for k, v in var.maintenance_windows :
    !v.start_stop_instances || length(v.instance_ids) > 0
  ])
  error_message = "start_stop_instances = true の環境では instance_ids を 1 つ以上指定してください。"
}

対象 ID の棚卸しは、停止中も拾える EC2 API のタグフィルタで行います。

aws ec2 describe-instances --region ap-northeast-1 \
  --filters "Name=tag:Environment,Values=stg" \
  --query 'Reservations[].Instances[].[InstanceId,State.Name,Tags[?Key==`Name`]|[0].Value]' --output table

別解:リソースグループなら「タグ運用」と「停止中対応」を両取りできます

「ID 直指定だと自動追従しないのがなあ。。。」という方のために、もう一つの選択肢も紹介します。
環境タグのタグクエリでリソースグループを作成し、それをウィンドウターゲットに登録する方法です。

  • リソースグループのメンバーはタグクエリ(Resource Groups サービス)で解決されるため、SSM のオンライン状態に関係なく、停止中のインスタンスもターゲットに含まれます
  • 起動 / 停止の Automation タスクには、リソースグループターゲットでも {{RESOURCE_ID}} 擬似パラメータでインスタンス ID(i-xxxx の短縮形式)が渡されます(公式ドキュメントに AWS-StopEC2Instance へ渡す例があります)
  • タグ運用のままなので、インスタンスの追加・入れ替えにも自動追従します

ターゲット部分の Terraform はこんなイメージです。

resource "aws_resourcegroups_group" "stg" {
  name = "ssm-agent-update-stg"

  resource_query {
    query = jsonencode({
      ResourceTypeFilters = ["AWS::EC2::Instance"]
      TagFilters          = [{ Key = "Environment", Values = ["stg"] }]
    })
  }
}

resource "aws_ssm_maintenance_window_target" "stg" {
  window_id     = aws_ssm_maintenance_window.ssm_agent_update["stg"].id
  resource_type = "RESOURCE_GROUP"

  targets {
    key    = "resource-groups:Name"
    values = [aws_resourcegroups_group.stg.name]
  }
}

ただし、いくつか注意点があります。

  • {{RESOURCE_ID}} / {{TARGET_ID}} が使えるのは Automation / Lambda / Step Functions タスクのみです(Run Command タスクには使えませんが、Run Command はターゲット登録経由でそのまま実行されるので、そもそも擬似パラメータが不要です)
  • リソースグループに EC2 以外のリソースタイプが混ざるとタスクの対象も広がるため、タグクエリは AWS::EC2::Instance に限定しましょう
  • 管理するリソースが 1 つ増えます

今回は対象が数台で入れ替えもほぼない環境だったため ID 直指定を採用しましたが、インスタンスの入れ替えが多い環境ではリソースグループ方式が有力です。
なお、私はこちらの構成ではまだ実運用していないので、採用する際は単発 cron での試験実行で動作確認してからにしてください!

メンテナンスウィンドウのタスクを登録する際の疑似パラメータの使用 - AWS Systems Manager
コンソールまたは --task-invocation-parameters オプションでの入力パラメータを使用して、Systems Manager で、各メンテナンスウィンドウのタスクタイプに固有のパラメータを指定する方法を説明します。

運用のポイント

実際に運用してみて分かったポイントも残しておきます。

初回は単発日付指定で試験実行

いきなり定期 cron で回すのではなく、初回は日付指定の単発 cron(例: cron(0 1 27 7 ? 2026))で試験実行し、結果を確認してから定期 cron に書き換える二段階運用が安全でおすすめです!

ちなみに SSM メンテナンスウィンドウの cron 式は「分 時 日 月 曜日 年」の 6 フィールドで、日と曜日は同時指定できません(片方を ? にします)。
schedule_timezone = "Asia/Tokyo" を指定すれば、cron 式を JST のまま書けるので UTC 換算は不要です。

用途 cron 式
単発(日付指定) cron(0 1 27 7 ? 2026)
毎週月曜 01:00 cron(0 1 ? * MON *)
毎月第一火曜 01:00 cron(0 1 ? * TUE#1 *)

リファレンス: Systems Manager の cron 式および rate 式 - AWS Systems Manager
メンテナンスウィンドウまたは関連付けを時間ベースエントリ (cron) または頻度ベースのエントリ (rate) として実行するスケジュールを指定する方法を説明します。

実行結果の確認

実行結果とエージェントバージョンは CLI でサクッと確認できます。

# ウィンドウの実行履歴
aws ssm describe-maintenance-window-executions --window-id <ウィンドウID>

# 更新後のエージェントバージョンと最新かどうか
aws ssm describe-instance-information \
  --query 'InstanceInformationList[].[InstanceId,AgentVersion,IsLatestVersion]' --output table

IsLatestVersion が True になっていれば無事更新完了です!

❯ aws ssm describe-instance-information \                                                           
  --query 'InstanceInformationList[].[InstanceId,AgentVersion,IsLatestVersion]' --output table
-----------------------------------------------
|         DescribeInstanceInformation         |
+----------------------+--------------+-------+
|  i-XXXXXXXXXXXXXXXXX |  3.3.4851.0  |  True |
|  i-XXXXXXXXXXXXXXXXX |  3.3.4851.0  |  True |
|  i-XXXXXXXXXXXXXXXXX |  3.3.4851.0  |  True |
|  i-XXXXXXXXXXXXXXXXX |  3.3.4851.0  |  True |
|  i-XXXXXXXXXXXXXXXXX |  3.3.4851.0  |  True |
+----------------------+--------------+-------+

まとめ

今回は、SSM メンテナンスウィンドウを使った SSM Agent の環境別自動更新を Terraform で構築してみました!

今回の構築と運用から、下記のことがわかりました!

  • Fleet Manager の自動更新はアカウント単位・2 週間ごと固定のため、環境別にタイミングを分けたい場合はメンテナンスウィンドウが有効
  • メンテナンスウィンドウのタグ指定ターゲットはオンラインの SSM マネージドノードからしか解決されない。停止中のインスタンスはインスタンス ID 直指定が必要
  • 夜間停止環境は「起動 → 更新 → 停止」の 3 タスク構成(priority 1 / 2 / 3)で対応できる。停止タスクは更新失敗時も後続実行されるので起動しっぱなしにならない
  • ID 直指定は自動追従しないため、variable validation で設定ミスを plan 時に弾くと事故防止になる
  • タグクエリのリソースグループをターゲットにすれば、タグ運用のまま停止中インスタンスも対象にできる(別解)
  • 初回は単発日付指定の cron で試験実行してから定期 cron に切り替える二段階運用が安全

常時稼働かつタグ運用が整っている環境であれば、タグ直指定だけでほぼメンテナンスフリーな仕組みになります。
SSM Agent の更新を手動でやっている方は、ぜひ試してみてください!

ここまで読んでいただき、ありがとうございました!
この記事がどなたかの参考になりますと幸いです!

AWS Systems Manager Maintenance Windows - AWS Systems Manager
Systems Manager Maintenance Windows を使用して、ノードおよび他の AWS リソースを中断する可能性があるアクションを実行するスケジュールを定義します。