はじめに
こんにちは、そしてこんばんは!
サービスプラットフォーム事業部の大嵩です。
今回は、ALB配下のEC2に対する定番メンテナンス作業「ターゲットグループからデタッチ → Apacheアップデート → アタッチ」を、AWS Step Functionsで丸ごと自動化してみます!
みなさん、ALB配下のサーバーへのパッチ適用ってどうされていますか・・?
1台ずつターゲットグループから外して、パッチを当てて、動作確認して、また戻して。。。
これを深夜メンテで人力でやると、手順ミスや「デタッチしたまま戻し忘れ」といった事故がつきものですよね。
そこで今回は、Lambdaを使わず、Step FunctionsのAWS SDK統合だけでこの作業を自動化します!
失敗時の自動リカバリ、Slack通知、スケジュール実行まで全部盛りでお届けします!
ちなみに私は過去にApacheの更新でこんな目にも遭っているので、この作業の自動化には人一倍思い入れがあります(笑)
なお、本記事に登場するアカウントIDはマスクしています。
本記事のゴール
今回のゴールは以下の4点です!
- ALB配下のEC2を1台ずつ・無停止でローリングアップデートする
- 失敗したら自動でリカバリする(インスタンス内はconf自動復元、ワークフロー側は強制再アタッチの二段階リカバリ)
- 進捗をSlackに通知する
- EventBridge Schedulerで定期実行まで
手作業でデタッチ→パッチ→アタッチをしている方、必見です!
今回の構成
構成はこんな感じです。

EventBridge Scheduler (cron / Asia/Tokyo)
│ states:StartExecution
▼
Step Functions (Standard)
1. DescribeTargetHealth でターゲット一覧を取得
2. [START] 通知
3. Map (MaxConcurrency: 1) で1台ずつ:
デタッチ → draining待ち → SSMでApacheアップデート
→ 再アタッチ → healthy待ち → [OK] 通知
4. [SUCCESS] 通知
── 失敗時 ──
強制再アタッチ → [NG] 通知 → [FAILED] 通知 → Fail
通知はすべて SNS → Amazon Q Developer in chat applications → Slack
ベースは「ALB配下にEC2が2台(Amazon Linux 2023 + Apache)」というよくある構成です。
EC2はSSMマネージドインスタンスにしておきます(パッチ実行にSSM Run Commandを使うため)。
ポイントはLambdaレスなことです!
Step FunctionsのAWS SDK統合を使うと、ELBやSSMのAPIをステートマシンから直接呼べるので、管理するアプリケーションコードがゼロになります。
「デプロイするもの」がステートマシン定義だけになるのは運用がかなり楽ですね!
なお検証を回しやすくするため、ターゲットグループの設定を以下のとおり短縮しています。
- deregistration_delay: 300秒 → 30秒
- ヘルスチェック: 30秒間隔×5回 → 10秒間隔×2回
本番では接続の実態(ロングポーリングの有無など)に合わせて調整してください!
ステートマシンを作る
今回の主役、ステートマシンを作っていきます!
全体のグラフビューはこちらです。

ここからは、設計の肝になるポイントを順に紹介します。
ターゲット一覧はTGから動的に取得する
対象インスタンスはハードコードせず、実行のたびにターゲットグループから取得します。
"GetTargets": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:elasticloadbalancingv2:describeTargetHealth",
"Parameters": {
"TargetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXXXXXXX:targetgroup/sample-tg/xxxxxxxxxxxxxxxx"
},
"ResultPath": "$.targets",
"Next": "BuildStartNotification"
}
これで台数が2台から4台に増えても、ステートマシンの定義変更は不要です!
Map + MaxConcurrency=1 で「1台ずつ」を作る
ローリングの心臓部です。
Mapステートに MaxConcurrency: 1 を指定するだけで、「1台終わってから次の1台」という直列処理になります。
"RollingUpdateMap": {
"Type": "Map",
"ItemsPath": "$.targets.TargetHealthDescriptions",
"MaxConcurrency": 1,
"ItemSelector": {
"InstanceId.$": "$$.Map.Item.Value.Target.Id",
"ItemIndex.$": "$$.Map.Item.Index"
},
...
}
draining待ち・healthy待ちはWait + Choiceのループで作る
デタッチ直後はターゲットがdraining状態になり、処理中のリクエストが無くなるのを待ちます。
これをWait(10秒)→ DescribeTargetHealth → Choiceのループで実現します。
"IsDrainingComplete": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.drainingResult.TargetHealthDescriptions[0].TargetHealth.State",
"StringEquals": "draining",
"Next": "WaitDraining"
}
],
"Default": "RunPatchCommand"
}
デタッチが完了したターゲットも、Targetsを明示指定したDescribeTargetHealthなら unused として返ってくるので、エラーにならずに判定できます。
再アタッチ後のhealthy待ちも同じ型のループです。
パッチスクリプトは「バックアップ→更新→検証→ダメなら自動復元」
Apacheのアップデートは、SSM Run Command(AWS-RunShellScript)で以下のスクリプトを実行します。
ここが2段階リカバリの1段目です!
set -eu BACKUP=/var/tmp/httpd-conf-$(date +%Y%m%d%H%M%S).tar.gz tar czf "$BACKUP" -C / etc/httpd echo "[before] $(rpm -q httpd)" dnf update -y httpd if apachectl configtest && systemctl restart httpd && sleep 3 && curl -sf -m 5 -o /dev/null http://localhost/; then echo "[after] $(rpm -q httpd)" ls -1t /var/tmp/httpd-conf-*.tar.gz | tail -n +4 | xargs -r rm -f else echo '[rollback] update broke httpd, restoring config from backup' >&2 mv /etc/httpd /etc/httpd.broken-$(date +%Y%m%d%H%M%S) tar xzf "$BACKUP" -C / systemctl restart httpd exit 1 fi
ポイントはこちらです。
- 更新前に
/etc/httpdをtarでバックアップ - 更新後に
configtest→ 再起動 → ローカルヘルスチェックの3段構えで検証 - どれかがコケたら、壊れたconfを
/etc/httpd.broken-*に退避(事後調査用)した上でバックアップから自動復元し、exit 1で失敗を報告
パッケージ自体のロールバックはあえてスコープ外にしています。
「confを戻して旧状態で配信継続 → 人間が調査」という割り切りです。
失敗したら「まず再アタッチ」— Catchによるリカバリ
二段階リカバリの2段目、そして本記事の一番の肝です!
パッチ処理が失敗したときに一番怖いのは、インスタンスがデタッチされたまま放置されることですよね。
そこで、Map内の各タスクにCatchを仕込み、どの工程で失敗しても必ず RegisterTargets を通ってからFailするようにします。
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "RecoverRegister"
}
]
"RecoverRegister": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:elasticloadbalancingv2:registerTargets",
...
"Next": "BuildInstanceFailureNotification"
}
「失敗したら切り戻す」がワークフローに組み込まれているので、深夜に失敗しても朝まで片肺で耐えてくれます!
通知は「開始・台単位・全体」の3レイヤーで設計する
通知はSNS→Amazon Q Developer in chat applications経由でSlackに送ります。
運用中に「今どこまで進んだ?」がSlackだけで追えるように、以下の粒度にしました。
| タイミング | 通知 |
|---|---|
| 実行開始 | [START](対象台数つき) |
| 1台ごとの完了 | [OK] / 失敗なら [NG](エラー内容つき) |
| 実行全体 | [SUCCESS] / [FAILED] |
Amazon Q Developer in chat applications に送るメッセージは、Passステートで以下のようなJSONを組み立てて sns:publish します。
(この形式でないと届きません。詳細は後述のハマりポイント集にて。。。)
{
"version": "1.0",
"source": "custom",
"content": {
"textType": "client-markdown",
"title": "[OK] instance i-XXXXXXXXXXXXXXXX1 updated and back in service",
"description": "Target 1: deregister -> patch -> register completed..."
}
}
Terraformで構築する
環境一式はTerraformで管理します。
ステートマシン定義(ASL)はJSONファイルに分離して、templatefile でARNを注入するのがおすすめです!
resource "aws_sfn_state_machine" "rolling_update" {
name = "sample-rolling-update"
role_arn = aws_iam_role.sfn.arn
definition = templatefile("${path.module}/../statemachine/rolling-update.asl.json", {
target_group_arn = var.target_group_arn
sns_topic_arn = aws_sns_topic.notify.arn
})
}
スケジュールはEventBridge Schedulerで作ります。
タイムゾーンを直接指定できるので、cron式をJSTのまま書けるのが嬉しいですね!
resource "aws_scheduler_schedule" "rolling_update" {
name = "sample-rolling-update-schedule"
state = "ENABLED"
flexible_time_window {
mode = "OFF"
}
schedule_expression = "cron(0 2 ? * SUN *)" # 毎週日曜 2:00
schedule_expression_timezone = "Asia/Tokyo"
target {
arn = aws_sfn_state_machine.rolling_update.arn
role_arn = aws_iam_role.scheduler.arn
}
}
Amazon Q Developer in chat applicationsの設定もTerraformで作れるのですが、1つ注意があります。
APIは東京リージョンにエンドポイントがないため、us-east-2のproviderエイリアスを切って作成します。
provider "aws" {
alias = "use2"
region = "us-east-2"
}
resource "aws_chatbot_slack_channel_configuration" "notify" {
provider = aws.use2
configuration_name = "sample-rolling-update-slack"
iam_role_arn = aws_iam_role.chatbot.arn
slack_team_id = "TXXXXXXXX"
slack_channel_id = "CXXXXXXXXXX"
sns_topic_arns = [aws_sns_topic.notify.arn]
logging_level = "ERROR"
}
logging_level = "ERROR" は一見地味ですが、後で私を救ってくれることになります。。。
なお、SlackワークスペースとAmazon Q Developer in chat applicationsの連携だけは事前にマネジメントコンソールから実施しておく必要があります。
ステートマシンのIAMロールに付ける権限の要点はこちらです。
elasticloadbalancing:DescribeTargetHealth(リソースレベル制限不可なので*)elasticloadbalancing:RegisterTargets/DeregisterTargets(対象TGのARNに限定)ssm:SendCommand(AWS-RunShellScriptドキュメント + 対象インスタンス)ssm:GetCommandInvocationsns:Publish(通知トピックに限定)
実際に動かしてみる!
それでは、実際に動かしてみましょう!
まずは無停止であることを実証するため、別ターミナルでALBに毎秒curlを打ち続けるスクリプトを回しておきます。
while true; do
ts=$(date '+%H:%M:%S')
out=$(curl -s -m 2 -w '|%{http_code}' "http://${ALB_DNS}/" 2>/dev/null) || out='|FAIL'
echo "${ts} [${out##*|}] $(printf '%s' "${out%|*}" | sed 's/<[^>]*>//g')"
sleep 1
done
この状態でステートマシンを実行します!
aws stepfunctions start-execution \ --state-machine-arn arn:aws:states:ap-northeast-1:XXXXXXXXXXXX:stateMachine:sample-rolling-update
結果は・・・SUCCEEDED!2台の処理が1分44秒で完了しました!

実行履歴から起こったことを整理するとこんな感じです。
| 経過 | イベント |
|---|---|
| 0:00 | [START] 通知 → 1台目デタッチ |
| 0:10〜0:30 | draining待ちループ(30秒) |
| 0:31 | SSMでApacheアップデート実行 |
| 0:41 | 再アタッチ |
| 0:51 | healthy復帰 → [OK] 通知 → ここで初めて2台目に着手 |
| 0:52〜1:43 | 2台目を同じ流れで処理 |
| 1:44 | [SUCCESS] 通知 |
1台目が完全にサービスインしてから2台目に手を付けているのがわかりますね!
curlのループも、応答するインスタンスが切り替わるだけで一度も途切れませんでした!


じゃあ、ほんとにApacheは更新されたんでしょうか・・・?
SSMコマンドの出力で裏取りしてみましょう!
[backup] /var/tmp/httpd-conf-20260811030857.tar.gz [before] httpd-2.4.62-1.amzn2023.x86_64 ... Upgraded: httpd-2.4.68-1.amzn2023.0.1.x86_64 httpd-core-2.4.68-1.amzn2023.0.1.x86_64 httpd-filesystem-2.4.68-1.amzn2023.0.1.noarch httpd-tools-2.4.68-1.amzn2023.0.1.x86_64 mod_lua-2.4.68-1.amzn2023.0.1.x86_64 Complete! [after] httpd-2.4.68-1.amzn2023.0.1.x86_64
2.4.62 → 2.4.68 にしっかり上がっています!
confのバックアップも取れていますね。
Slackにも [START] → [OK] → [OK] → [SUCCESS] の4通が届きました!

わざと失敗させてみる
さて、ここからが本番です。
自動化で一番大事なのは「失敗したときにどうなるか」ですよね!
シナリオA: パッチコマンドをわざと失敗させる
インスタンス上にフラグファイルを置くと、パッチスクリプトが exit 1 で失敗する仕掛けを入れておきました。
2台目に処理されるインスタンスに仕込んで実行します。
aws ssm send-command \ --document-name AWS-RunShellScript \ --instance-ids i-XXXXXXXXXXXXXXXX2 \ --parameters 'commands=["touch /var/tmp/patch_fail_flag"]'
実行結果は・・・想定どおりFAILED!
ですが中身を見ると、欲しかった動きがすべて入っています。
- 1台目は正常にアップデート完了([OK] 通知)
- 2台目は失敗を検知してから1秒以内に再アタッチ完了
- Slackに [NG] 通知(インスタンスIDとエラー内容つき)と [FAILED] 通知が届く


そして最重要ポイント、実行後のターゲットグループを確認すると・・・
❯ bash scripts/check-tg.sh -------------------------------------------- | DescribeTargetHealth | +----------------------+-----------+-------+ | i-03b8a8059e728e954 | healthy | None | | i-00798396bffb0eac3 | healthy | None | +----------------------+-----------+-------+
失敗したのに2台ともTGに戻っています!
「デタッチしたまま放置」が起きない、これがやりたかったことです!
シナリオB: 「アップデートがconfを壊した」を再現する
次は、より現実的な障害です。
別のフラグを置くと、dnf update の直後に壊れたconfが書き込まれる仕掛けで、「アップデートで設定が壊れた」状況を再現します。
実行すると、SSMコマンドの出力([NG] 通知のCauseにもそのまま入ります)に残っていました。
AH00526: Syntax error on line 1 of /etc/httpd/conf.d/99-broken.conf: Invalid command 'BrokenDirective', perhaps misspelled or defined by a module not included in the server configuration [rollback] update broke httpd, restoring config from backup [rollback] config restored, httpd is serving again

configtest が壊れたconfを検知 → バックアップから自動復元 → Apache再稼働、まで全自動で完了しています!
その上でワークフロー側の再アタッチも動くので、インスタンスは旧confのまま配信を継続しながらTGに戻ってくるわけです。
二段階リカバリ、完成です!
スケジュール実行してみる
最後に、EventBridge Schedulerからの定期実行を確認します。
検証では数十分後に一度だけ発火するよう、年まで指定したcron式に書き換えました。
schedule_expression = "cron(30 18 11 8 ? 2026)" # 2026-08-11 18:30 JST に1回だけ
結果、18:30:00の設定に対して18:30:23に発火し、実行はSUCCEEDEDで完了しました!
(数十秒の遅延はSchedulerの仕様の範囲内です)
実行の入力を見ると、Schedulerから渡したパラメータが入っており、スケジュール起動であることも確認できます。
{"trigger":"eventbridge-scheduler"}


これでローリングアップデートをスケジューリングする仕組みが完成しました!
ハマりポイント集
今回もいくつかハマったので、共有します!
1. Amazon Q Developer in chat applications は「ただのテキスト」をSlackに流してくれない
SNSトピックにAmazon Q Developer in chat applicationsを紐付けたので、あとはpublishすれば届くだろうと思いきや、任意の文字列メッセージは何も届きません。。。
独自メッセージを送るには、カスタム通知フォーマットのJSONで publish する必要があります。
{
"version": "1.0",
"source": "custom",
"content": {
"textType": "client-markdown",
"title": "タイトル",
"description": "本文"
}
}
2. descriptionは8000文字上限。超えるとサイレントに捨てられる
これが今回最大のハマりポイントでした。
最初、成功通知にdnfの実行ログを丸ごと埋め込んでいたのですが、SNSのpublishは成功しているのにSlackには何も届かない。。。
Step Functionsから見ると全部成功しているので、原因がまったくわかりません。
救ってくれたのは、Terraformで仕込んでおいた logging_level = "ERROR" でした。
us-east-1の /aws/chatbot/<設定名> ロググループを見ると・・・
Invalid event received: description must have between 1 and 8000 characters
犯人はお前か!!
ということで、通知は要約のみ・詳細ログは実行履歴とSSMに残す設計に変更しました。
Amazon Q Developer in chat applications を使う際は logging_level = "ERROR" を必ず入れておくことをおすすめします!
3. Amazon Q Developer in chat applications のAPIは東京リージョンにいない
aws chatbot describe-slack-workspaces を東京リージョンで叩くとエンドポイントエラーになります。
CLIは --region us-east-2 を指定、Terraformはproviderエイリアスで対応しましょう。
4. GetCommandInvocationは直後に呼ぶと「存在しない」と言われる
ssm:sendCommand の直後に getCommandInvocation でポーリングを始めると、Ssm.InvocationDoesNotExistException が返ることがあります。
ステートマシン側にRetry(5秒間隔×6回程度)を入れておくと安定します。
"Retry": [
{
"ErrorEquals": ["Ssm.InvocationDoesNotExistException"],
"IntervalSeconds": 5,
"MaxAttempts": 6,
"BackoffRate": 1.5
}
]
5. 最後のTaskにResultPath: nullを忘れると実行出力が消える
実行の最終出力は「最後のステートの出力」になります。
末尾のSNS publishに "ResultPath": null を入れ忘れると、実行出力がSNSのMessageIdで上書きされて、Mapの処理結果が実行出力から消えてしまいます(私は消しましたw)。
台数が増えたらどうなる?
今回は2台での検証でしたが、ターゲットは実行のたびにTGから動的取得しているので、4台でも100台でも定義変更は不要です。
ただし大台数で運用する場合は、以下の考慮が必要です!
- TimeoutSecondsの再計算: ステートマシンレベルのタイムアウトはCatchを通らずに実行を即死させるため、超過するとデタッチ放置+通知なしのリスクがあります。実測ペースは約50秒/台だったので、台数×60〜90秒+余裕で設定しましょう
- インラインMapはfail-fast: 1台の失敗で残りは全部スキップされます。「失敗しても続行して最後にまとめて報告」にしたい場合は、Distributed MapのToleratedFailurePercentageの出番です
- 実行履歴25,000イベント上限: 今回の処理内容だと実用上限は300台前後。それ以上もDistributed Map化で対応できます
- スケジュール重複: 実行時間がスケジュール間隔を超えると並行実行になり、複数台同時デタッチの危険があります
まとめ
今回は、ALBターゲットグループからのデタッチ→Apacheアップデート→アタッチを、Step Functionsで無停止自動化してみました!
やってみてわかったことはこちらです。
- Step FunctionsのSDK統合だけで、Lambdaなしでもここまでできる!(管理するコードはASLとパッチスクリプトだけ)
- リカバリは「インスタンス内のconf自動復元」と「ワークフローの強制再アタッチ」の二段階で考えると安心感が段違い
- 通知は「開始・台単位・全体」の3レイヤーにすると、Slackを見るだけで進捗が追える
- Amazon Q Developer in chat applications のカスタム通知には落とし穴が多いので、
logging_level = "ERROR"は必須 - ターゲットを動的取得しておけば台数の増減に強い。ただし大台数ではTimeoutSecondsとfail-fastの考慮を忘れずに
実は今回の仕組み、Apacheに依存しているのはSSMで実行するスクリプト1箇所だけです。
ここをSSMドキュメントに切り出せば、nginxでもTomcatでも同じ基盤でローリングメンテナンスができるはず・・・!
汎用化編はまた別の記事で紹介したいと思います。お楽しみに!
パッチ運用の自動化に興味が湧いた方は、こちらの記事もどうぞ!

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