
開発と本番が別アカウントのEC2(Ubuntu) に Patch Manager で順番にパッチを当ててみた
製造ビジネステクノロジー部の小林です。
前回の記事では、EC2(Ubuntu 24.04 )の OS パッチ運用ルールを Patch Manager 前提で考えました。
今回は、そのしくみを実際に作って動かしてみます。開発と本番を別々の AWS アカウントにし、開発に当たったセキュリティ更新だけを、アカウントをまたいで本番へ当てるところまで確かめます。
本番の運用では夜と翌朝にスケジュールで動くしくみですが、検証では各処理を手で順番に起動します。
作るもの
| アカウント | 種類 | 名前 | 役割 |
|---|---|---|---|
| 開発 | パッチベースライン | ubuntu-security-baseline | セキュリティ更新だけを承認。拒否は BLOCK |
| 開発 | メンテナンスウィンドウ | patch-dev-install | 開発の EC2 に Install を流す |
| 開発 | S3 バケット | patch-lists-xxxxxxxx | 候補と承認済みの一覧を置く。本番アカウントから読めるようにする |
| 開発 | Lambda | patch-build-list | 開発に入ったバージョンから候補の一覧を作る |
| 開発 | Lambda | patch-morning-check | 翌朝に開発の状態を確かめ、合格なら候補を承認済みにする |
| 本番 | パッチベースライン | ubuntu-security-baseline | 開発と同じ内容。ベースラインはアカウントごとに作る |
| 本番 | Lambda | patch-prod-apply | 開発アカウントの承認済み一覧があれば本番に当て、なければ見送る |
前回の記事にあった検証環境、自動停止と起動、AWS Backup、チャットへの通知は、今回は省いています。

前提
EC2 と IAM ロールは、次のとおり作成済みとします。
| アカウント | 種類 | 名前 | 設定 |
|---|---|---|---|
| 開発 | EC2 | patch-dev | タグ Environment=dev |
| 本番 | EC2 | patch-prod | タグ Environment=prod |
| 両方 | IAM ロール | patch-ec2-role | EC2 のインスタンスプロファイル。AmazonSSMManagedInstanceCore と、開発アカウントの S3 の一覧を読む s3:GetObject を付ける |
パッチベースラインを作る
パッチベースラインは、どのパッチを当ててよいかを決めるルールです。アカウントごとの設定なので、開発と本番の両方に同じものを作ります。AWS 提供の AWS-UbuntuDefaultPatchBaseline は設定を変更できないため、自分用のベースラインを作ります。
Systems Manager の [パッチマネージャー] を開き、[パッチベースライン] タブで [パッチベースラインを作成] を選びます。

承認ルール


| 項目 | 設定 | 意味 |
|---|---|---|
| 製品 | Ubuntu24.04 | ルールを当てる OS のバージョン |
| セクション | 選ばない | Debian パッケージの分類(admin、libs など)で絞り込まない |
| 優先度 | All | Debian パッケージの重要度すべて。AWS の既定のベースラインと同じ |
| コンプライアンスレポート | 重大 | 承認したパッチが未適用のときに報告する重大度 |
| セキュリティ以外の更新を含める | チェックしない | noble-security のパッチだけが対象になる |
Ubuntu ではパッケージの公開日を確実に取得できないため、「公開から N 日後に承認する」設定には対応していません。
パッチの例外

| 項目 | 設定 | 意味 |
|---|---|---|
| 承認済みパッチ | 空のまま | ルール外のパッチを個別に許可する欄 |
| 拒否されたパッチ | 空のまま | 問題が出たときにパッケージを入れる欄 |
| 拒否されたパッチのアクション | ブロック | 拒否したパッケージが、別の更新の依存としても入らないようにする |
パッチのソースは Ubuntu 標準のリポジトリを使うので追加しません。

作成すると、パッチベースラインの一覧に表示されます。

既定のベースラインにする
作ったベースラインを Ubuntu の既定にしておくと、パッチグループを作らなくても、2 台ともこのベースラインで判定されます。コンソールの「デフォルトのパッチベースラインとして設定」は、Patch Manager を 2022 年 12 月より前から使っていたアカウントにしか表示されないため、CloudShell から設定します。
aws ssm register-default-patch-baseline --baseline-id pb-xxxxxxxxxxxxxxxxx --region ap-northeast-1

メンテナンスウィンドウを作る
開発の EC2 に、平日 21:00 にセキュリティ更新を当てるメンテナンスウィンドウを作ります。
Systems Manager の [メンテナンスウィンドウ] で [メンテナンスウィンドウの作成] を選びます。


| 項目 | 設定 | 意味 |
|---|---|---|
| 名前 | patch-dev-install | |
| 未登録ターゲットを許可する | チェックしない | 登録したターゲット以外には実行しない |
| スケジュール | cron(0 21 ? * MON-FRI *) | 平日の 21:00 に開始する |
| 期間 | 1 時間 | ウィンドウが開いている時間 |
| タスクの開始を停止する | 0 時間 | 終了時刻まで新しいタスクを始められる |
| スケジュールのタイムゾーン | Asia/Tokyo | 指定しないと UTC になる |
作成したら、ウィンドウにターゲットとタスクを登録します。
ターゲットの登録
ウィンドウを開き、[ターゲット] タブで [ターゲットの登録] を選びます。



| 項目 | 設定 | 意味 |
|---|---|---|
| Target name | dev-instances | タスクの登録時に選ぶ名前 |
| ターゲットの選択 | インスタンスタグを指定 | EC2 が増えても、タグを付ければ対象になる |
| タグキー / タグの値 | Environment / dev | 入力したら [Add] を押す。押さずに登録するとタグが入らない |
タスクの登録
[タスク] タブで [Run Command タスクの登録] を選びます。






| 項目 | 設定 | 意味 |
|---|---|---|
| 名前 | install-security-updates | |
| New task invocation cutoff | チェックしない | 対象が 1 台なので使わない |
| コマンドドキュメント | AWS-RunPatchBaseline | Scan と Install を行う SSM ドキュメント。一覧の先頭が選ばれた状態で開くので、検索して選び直す |
| ドキュメントのバージョン | ランタイムのデフォルトバージョン | |
| タスク優先度 | 1 | |
| ターゲット | dev-instances | 先ほど登録したターゲット |
| 同時実行数 / エラーのしきい値 | 1 / 1 | |
| IAM サービスロール | 空欄 | サービスにリンクされたロール(AWSServiceRoleForAmazonSSM)が使われ、なければ自動で作られる |
| S3 / CloudWatch output / SNS 通知 | チェックしない | 結果は Patch Manager のコンプライアンスで確かめる |
| CloudWatch alarm | 選ばない | 選ばなくても「Choose alarm」が保存されることがある(後述) |
CloudWatch alarm の欄に注意
登録後、21:00 にウィンドウが動きましたが、実行は「Failed due to unknown alarm status」で FAILED になりました。タスクの CloudWatch alarm の欄に、プルダウンの初期表示の「Choose alarm」がアラーム名として保存されていました。

こちらは CLI から確認した結果です。
aws ssm get-maintenance-window-task --window-id mw-xxxxxxxxxxxxxxxxx \
--window-task-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --query 'AlarmConfiguration'
{
"IgnorePollAlarmFailure": false,
"Alarms": [
{
"Name": "Choose alarm"
}
]
}
コンソールの編集画面では外れなかったため、CloudShell から --replace を付けて更新しました。--replace では指定しなかった項目が空になるので、アラームの設定が外れます。
aws ssm update-maintenance-window-task \
--window-id mw-xxxxxxxxxxxxxxxxx \
--window-task-id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
--replace \
--name install-security-updates \
--targets Key=WindowTargetIds,Values=<ターゲットの ID> \
--task-arn AWS-RunPatchBaseline \
--service-role-arn arn:aws:iam::<アカウント ID>:role/aws-service-role/ssm.amazonaws.com/AWSServiceRoleForAmazonSSM \
--task-invocation-parameters '{"RunCommand":{"Parameters":{"Operation":["Install"],"RebootOption":["NoReboot"],"StepTimeoutSeconds":["10800"]}}}' \
--priority 1 --max-concurrency 1 --max-errors 1 \
--query 'AlarmConfiguration'
出力が null になれば外れています。
2 台の自動更新を止める
前回の記事では unattended-upgrades を止めるのは最後にしましたが、今回は先に止めます。止めないと自動更新で本番にも同じパッチが当たり、開発から本番へ渡すしくみを確かめられないためです。開発と本番の EC2 にセッションマネージャーで接続し、次のコマンドを実行します。
sudo tee /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "0";
APT::Periodic::Unattended-Upgrade "0";
EOF
apt-config dump APT::Periodic

Update-Package-Lists と Unattended-Upgrade が "0" になっていれば、自動更新は止まっています。
開発に Install を流す
メンテナンスウィンドウの 21:00 を待たずに、同じ処理を Run Command で今すぐ実行します。Systems Manager の [Run Command] で [コマンドを実行] を選びます。




| 項目 | 設定 | 意味 |
|---|---|---|
| コマンドドキュメント | AWS-RunPatchBaseline | メンテナンスウィンドウのタスクと同じ |
| Operation / Reboot Option | Install / NoReboot | 初期値から変更する |
| ターゲット | インスタンスタグで Environment=dev | タグキーと値を入れて [Add] を押す |
| 同時実行数 / エラーのしきい値 | 50 / 0 のまま | 対象は 1 台なので既定のままでよい |
| S3 バケットへの書き込み | チェックを外す | 結果は Patch Manager で確かめる |
| そのほか | 既定のまま |
t4g.micro では、Install に 15 分ほどかかりました。結果は次のとおりです。
aws ssm describe-instance-patch-states --instance-ids i-xxxxxxxxxxxxxxxxx \
--query 'InstancePatchStates[].{Installed:InstalledCount,Other:InstalledOtherCount,PendingReboot:InstalledPendingRebootCount,Missing:MissingCount,Failed:FailedCount}'
| 項目 | 件数 | 意味 |
|---|---|---|
| Installed | 30 | 対象のセキュリティ更新で、以前から入っていたもの |
| InstalledPendingReboot | 183 | 今回入れた更新で、再起動待ちのもの |
| InstalledOther | 429 | 対象外だが入っているパッケージ |
| Missing / Failed | 0 / 0 | 未適用も失敗もない |
入ったパッチは describe-instance-patches で 1 件ずつ見られます。
{
"Title": "bind9-host.arm64:1:9.18.39-0ubuntu0.24.04.8",
"KBId": "bind9-host.arm64",
"Classification": "Security",
"Severity": "standard",
"State": "InstalledPendingReboot",
"InstalledTime": "2026-11-03T21:12:34+09:00"
}
一覧を作る Lambda
開発の EC2 に入ったパッチから本番に当てる候補の一覧を作り、S3 の candidates/ に日付ごとに置きます。
- 対象は、Environment=dev の EC2 に入っているベースライン対象のパッチ
- 開発が複数台あるときは、全台に同じバージョンで入っているものだけを残す
問題が出て入れたくないパッケージは、開発のパッチベースラインの「拒否されたパッチ」に入れます。拒否したパッケージは、次の Scan から Installed ではなく InstalledRejected と報告されるため、この一覧には載りません。前回の記事では、本番向けの拒否リストを Parameter Store に置いて一覧から除く設計にしていました。一覧は開発の状態から作るので、拒否は開発のベースラインの 1 か所にまとめています。
一覧を置く S3 バケット
開発のアカウントに、バケット名の接頭辞を patch-lists にしてバケットを作ります。そのほかは既定のまま(SSE-S3、ACL 無効)です。この記事では、バケット名を patch-lists-xxxxxxxx と書きます。
関数を作る
lambda_function.py
import datetime
import logging
import os
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
BUCKET = os.environ["BUCKET"]
DEV_TAG_VALUE = os.environ.get("DEV_TAG_VALUE", "dev")
JST = datetime.timezone(datetime.timedelta(hours=9))
ec2 = boto3.client("ec2")
ssm = boto3.client("ssm")
s3 = boto3.client("s3")
def dev_instance_ids():
"""タグ Environment=dev の起動中・停止中の EC2 を返す"""
pages = ec2.get_paginator("describe_instances").paginate(
Filters=[
{"Name": "tag:Environment", "Values": [DEV_TAG_VALUE]},
{"Name": "instance-state-name", "Values": ["running", "stopped"]},
]
)
return [i["InstanceId"] for p in pages for r in p["Reservations"] for i in r["Instances"]]
def installed_patches(instance_id):
"""ベースラインの対象で、入っているパッチを {KBId: Title} で返す"""
patches = {}
pages = ssm.get_paginator("describe_instance_patches").paginate(
InstanceId=instance_id,
Filters=[{"Key": "State", "Values": ["Installed", "InstalledPendingReboot"]}],
)
for page in pages:
for p in page["Patches"]:
patches[p["KBId"]] = p["Title"]
return patches
def to_yaml(patches):
"""InstallOverrideList の形式。id はパッケージ名.アーキテクチャ、title はバージョン付き"""
lines = ["patches:"]
for kb_id, title in sorted(patches.items()):
lines.append(" -")
lines.append(" id: '{}'".format(kb_id.replace("'", "''")))
lines.append(" title: '{}'".format(title.replace("'", "''")))
return "\n".join(lines) + "\n"
def lambda_handler(event, context):
date = event.get("date") or datetime.datetime.now(JST).strftime("%Y-%m-%d")
instance_ids = dev_instance_ids()
if not instance_ids:
raise RuntimeError("開発の EC2 が見つかりません")
# 開発の全台に同じバージョンで入っているものだけを残す
common = None
for instance_id in instance_ids:
patches = installed_patches(instance_id)
if common is None:
common = patches
else:
common = {k: v for k, v in common.items() if patches.get(k) == v}
key = f"candidates/{date}.yaml"
s3.put_object(Bucket=BUCKET, Key=key, Body=to_yaml(common).encode(), ContentType="application/x-yaml")
logger.info("instances=%s patches=%d key=%s", instance_ids, len(common), key)
return {"key": key, "patches": len(common)}
Lambda のコンソールで [関数の作成] を選び、次のとおり作成します。
| 項目 | 設定 | 意味 |
|---|---|---|
| 関数名 | patch-build-list | |
| ランタイム / アーキテクチャ | Python 3.13 / arm64 | x86_64 でも動作する |
| 実行ロール | 基本的な Lambda アクセス権限で新しいロールを作成 | CloudWatch Logs への書き込み権限だけのロールが作られる |
実行ロールには、インラインポリシーで次の権限を足します。DescribeInstances と DescribeInstancePatches はリソースで絞り込めないため * にしています。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ec2:DescribeInstances", "ssm:DescribeInstancePatches"],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx/candidates/*"
}
]
}
| 設定 | 値 | 意味 |
|---|---|---|
| 環境変数 BUCKET | patch-lists-xxxxxxxx | 一覧を置くバケット |
| タイムアウト | 1 分 | 既定の 3 秒では足りないことがある |
[テスト] タブで、イベント JSON を {"date": "2026-11-03"} にして実行すると、candidates/2026-11-03.yaml が置かれました。件数は 213 件で、開発の Installed(30)と InstalledPendingReboot(183)の合計と一致します。
patches:
-
id: 'apport-core-dump-handler.arm64'
title: 'apport-core-dump-handler.arm64:2.28.1-0ubuntu3.8'
-
id: 'apport.arm64'
title: 'apport.arm64:2.28.1-0ubuntu3.8'
...
翌朝の確認と承認の Lambda
翌朝、開発の EC2 を確かめ、合格なら候補の一覧を approved/ にコピーして承認済みにします。本番の運用では、朝の起動と Scan のあと 8:00 に動かします。検証環境にはアプリがないので、合格条件は次の 4 つに絞っています。
- Environment=dev の EC2 がすべて起動している
- Systems Manager に Online で登録されている
- パッチの状態に Failed と InstalledPendingReboot がない
- 最後のパッチ操作が 24 時間以内
確かめる一覧はイベントの date で指定し、省略時は candidates/ のいちばん新しい一覧を使います。前夜に作った一覧は前夜の日付なので、今日の日付では見つからないためです。不合格のときは、前の実行の承認済みが残らないよう、同じ日付の approved/ を消します。
lambda_function.py
import datetime
import logging
import os
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
BUCKET = os.environ["BUCKET"]
DEV_TAG_VALUE = os.environ.get("DEV_TAG_VALUE", "dev")
MAX_AGE_HOURS = int(os.environ.get("MAX_AGE_HOURS", "24"))
ec2 = boto3.client("ec2")
ssm = boto3.client("ssm")
s3 = boto3.client("s3")
def dev_instances():
"""タグ Environment=dev の EC2 を、終了したもの以外すべて {ID: 状態} で返す"""
pages = ec2.get_paginator("describe_instances").paginate(
Filters=[
{"Name": "tag:Environment", "Values": [DEV_TAG_VALUE]},
{"Name": "instance-state-name", "Values": ["pending", "running", "stopping", "stopped"]},
]
)
return {
i["InstanceId"]: i["State"]["Name"]
for p in pages for r in p["Reservations"] for i in r["Instances"]
}
def latest_candidate_date():
"""candidates/ の中で、日付がいちばん新しい一覧の日付を返す。なければ None"""
keys = []
pages = s3.get_paginator("list_objects_v2").paginate(Bucket=BUCKET, Prefix="candidates/")
for page in pages:
keys += [o["Key"] for o in page.get("Contents", []) if o["Key"].endswith(".yaml")]
return max(keys)[len("candidates/"):-len(".yaml")] if keys else None
def check_instance(instance_id, state_name, now):
"""開発の 1 台が合格かを調べ、不合格の理由のリストを返す(空なら合格)"""
if state_name != "running":
return [f"起動していない({state_name})"]
reasons = []
info = ssm.describe_instance_information(
Filters=[{"Key": "InstanceIds", "Values": [instance_id]}]
)["InstanceInformationList"]
if not info or info[0]["PingStatus"] != "Online":
reasons.append("Systems Manager に Online で登録されていない")
states = ssm.describe_instance_patch_states(InstanceIds=[instance_id])["InstancePatchStates"]
if not states:
reasons.append("パッチの状態がまだ報告されていない")
return reasons
state = states[0]
if state.get("FailedCount", 0) > 0:
reasons.append(f"失敗したパッチがある({state['FailedCount']} 件)")
if state.get("InstalledPendingRebootCount", 0) > 0:
reasons.append(f"再起動待ちのパッチがある({state['InstalledPendingRebootCount']} 件)")
age = now - state["OperationEndTime"]
if age > datetime.timedelta(hours=MAX_AGE_HOURS):
reasons.append(f"最後のパッチ操作が {MAX_AGE_HOURS} 時間より前({state['OperationEndTime']})")
return reasons
def ng(approved, reasons):
"""不合格のときは、同じ日付の承認済みの一覧が前の実行で残っていても消す"""
s3.delete_object(Bucket=BUCKET, Key=approved)
logger.warning("NG: %s", reasons)
return {"result": "NG", "reasons": reasons}
def lambda_handler(event, context):
now = datetime.datetime.now(datetime.timezone.utc)
# 日付を省略したときは、いちばん新しい候補の一覧(前夜に作ったもの)を確かめる
date = event.get("date") or latest_candidate_date()
if date is None:
logger.warning("NG: 候補の一覧が 1 つもない")
return {"result": "NG", "reasons": ["候補の一覧が 1 つもない"]}
candidate = f"candidates/{date}.yaml"
approved = f"approved/{date}.yaml"
try:
s3.head_object(Bucket=BUCKET, Key=candidate)
except s3.exceptions.ClientError:
return ng(approved, [f"{candidate} がない"])
instances = dev_instances()
if not instances:
return ng(approved, ["開発の EC2 がない"])
results = {i: check_instance(i, s, now) for i, s in instances.items()}
failed = {i: r for i, r in results.items() if r}
if failed:
return ng(approved, failed)
s3.copy_object(Bucket=BUCKET, Key=approved, CopySource={"Bucket": BUCKET, "Key": candidate})
logger.info("OK: instances=%s approved=%s", list(instances), approved)
return {"result": "OK", "approved": approved}
関数は patch-morning-check という名前で作ります。実行ロールには次の権限を足します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ssm:DescribeInstanceInformation",
"ssm:DescribeInstancePatchStates"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx",
"Condition": { "StringLike": { "s3:prefix": "candidates/*" } }
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx/candidates/*"
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx/approved/*"
}
]
}
| 設定 | 値 | 意味 |
|---|---|---|
| 環境変数 BUCKET | patch-lists-xxxxxxxx | 一覧を置くバケット |
| 環境変数 MAX_AGE_HOURS | 24(省略可) | 最後のパッチ操作から何時間までを合格にするか |
| タイムアウト | 30 秒 |
朝の起動と Scan を、手で再現します。
- EC2 のコンソールで patch-dev を再起動する
- Run Command で AWS-RunPatchBaseline を Operation=Scan で実行する。ターゲットは Environment=dev
- patch-morning-check の [テスト] で、イベント JSON を {"date": "2026-11-03"} にして実行する
再起動と Scan のあと、開発のパッチの状態は次のとおりになりました。
| 状態 | 件数 |
|---|---|
| Installed | 210 |
| InstalledPendingReboot | 0 |
| Failed | 0 |
| Missing | 3 |
再起動待ちの 183 件のうち 180 件は Installed に移り、残りの 3 件は Missing になりました。Installed と Missing の合計は、一覧と同じ 213 件です。
Missing の 3 件は bind9 のパッケージです。開発には …24.04.8 が入っていますが、Install が終わった 21:16 のあと、22:38 に …24.04.9 が公開されたため、Scan で未適用と数えられました。
承認済みの一覧には開発で確かめた …24.04.8 が載るので、本番にも …24.04.8 が入ります。…24.04.9 は次の開発の Install で確かめてから本番へ渡ります。
テストの結果は次のとおりで、S3 に approved/2026-11-03.yaml が置かれました。

不合格のときは result が NG になり、理由(例:「再起動待ちのパッチがある」)を返して、承認済みの一覧を置きません。
本番に当てる Lambda
本番のアカウントに、承認済みの一覧を本番の EC2 に当てる関数を作ります。イベントで日付を指定すればその日の一覧を、指定しなければ approved/ のいちばん新しい一覧を使い、一覧がなければ見送ります。
日付を指定しない場合、朝の確認が NG の日は前回の一覧で Install が流れます。そのバージョンは本番に入っているので、パッケージは更新されません。その日の承認状態だけで判断したい場合は、日付を必ず指定します。
一覧は InstallOverrideList パラメータで渡し、ファイルは本番の EC2 の SSM Agent が読みます。そのため patch-ec2-role にも開発のバケットを読む権限を付けています。
lambda_function.py
import logging
import os
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
LIST_BUCKET = os.environ["LIST_BUCKET"]
PROD_TAG_VALUE = os.environ.get("PROD_TAG_VALUE", "prod")
REBOOT_OPTION = os.environ.get("REBOOT_OPTION", "NoReboot")
s3 = boto3.client("s3")
ssm = boto3.client("ssm")
def latest_approved_key():
"""approved/ の中で、日付がいちばん新しい一覧のキーを返す。なければ None"""
keys = []
pages = s3.get_paginator("list_objects_v2").paginate(Bucket=LIST_BUCKET, Prefix="approved/")
for page in pages:
keys += [o["Key"] for o in page.get("Contents", []) if o["Key"].endswith(".yaml")]
return max(keys) if keys else None
def lambda_handler(event, context):
if event.get("date"):
key = f"approved/{event['date']}.yaml"
try:
s3.head_object(Bucket=LIST_BUCKET, Key=key)
except s3.exceptions.ClientError:
key = None
else:
key = latest_approved_key()
if key is None:
logger.warning("SKIP: 承認済みの一覧がないため、本番には当てません event=%s", event)
return {"result": "SKIP"}
list_url = f"s3://{LIST_BUCKET}/{key}"
command = ssm.send_command(
DocumentName="AWS-RunPatchBaseline",
Targets=[{"Key": "tag:Environment", "Values": [PROD_TAG_VALUE]}],
Parameters={
"Operation": ["Install"],
"RebootOption": [REBOOT_OPTION],
"InstallOverrideList": [list_url],
},
TimeoutSeconds=600,
MaxConcurrency="50",
MaxErrors="0",
Comment=f"apply {key}",
)["Command"]
logger.info("SENT: command_id=%s list=%s", command["CommandId"], list_url)
return {"result": "SENT", "commandId": command["CommandId"], "list": list_url}
本番のアカウントで、開発と同じ手順で patch-prod-apply を作り、実行ロールに次の権限を足します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ssm:SendCommand",
"Resource": [
"arn:aws:ssm:ap-northeast-1::document/AWS-RunPatchBaseline",
"arn:aws:ec2:ap-northeast-1:<本番のアカウント ID>:instance/*"
]
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx",
"Condition": { "StringLike": { "s3:prefix": "approved/*" } }
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx/approved/*"
}
]
}
| 設定 | 値 | 意味 |
|---|---|---|
| 環境変数 LIST_BUCKET | patch-lists-xxxxxxxx | 開発のアカウントにある一覧のバケット |
| 環境変数 REBOOT_OPTION | NoReboot(省略可) | 本番の再起動は翌朝の起動に任せる |
| タイムアウト | 30 秒 | コマンドを送るだけなので、Install の完了は待たない |
開発のバケットのバケットポリシー
別アカウントのバケットを読むには、バケットポリシーでも許可が要ります。開発のアカウントで、バケットの [アクセス許可] タブの [バケットポリシー] に次を設定します。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ProdReadApproved",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::<本番のアカウント ID>:role/patch-ec2-role",
"arn:aws:iam::<本番のアカウント ID>:role/service-role/<patch-prod-apply の実行ロール名>"
]
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx/approved/*"
},
{
"Sid": "ProdListApproved",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<本番のアカウント ID>:role/service-role/<patch-prod-apply の実行ロール名>"
},
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::patch-lists-xxxxxxxx",
"Condition": { "StringLike": { "s3:prefix": "approved/*" } }
}
]
}
本番から読めるのは approved/ だけです。SSE-KMS のカスタマーマネージドキーを使う場合は、キーポリシーでも本番のロールに kms:Decrypt を許可します。
実行する
patch-prod-apply の [テスト] で、イベント JSON を {"date": "2026-11-03"} にして実行しました。
{
"result": "SENT",
"commandId": "349475e3-baaf-40b9-9ce4-327a59f49e39",
"list": "s3://patch-lists-xxxxxxxx/approved/2026-11-03.yaml"
}

結果を確かめる
本番のパッチの状態は次のとおりになりました。
| 状態 | 件数 |
|---|---|
| Installed | 30 |
| InstalledPendingReboot | 180 |
| Failed | 0 |
| Missing | 3 |
Installed と InstalledPendingReboot の 210 件は、パッケージ名もバージョンも承認済みの一覧と一致しました。主なパッケージを当てる前と比べると次のとおりです。
| パッケージ | 当てる前 | 当てたあと | 一覧 |
|---|---|---|---|
| linux-image-aws | 6.14.0-1017.17~24.04.1 | 7.0.0-1014.14~24.04.1 | 7.0.0-1014.14~24.04.1 |
| openssh-server | 1:9.6p1-3ubuntu13.14 | 1:9.6p1-3ubuntu13.20 | 1:9.6p1-3ubuntu13.20 |
| bind9-libs | 1:9.18.39-0ubuntu0.24.04.2 | 1:9.18.39-0ubuntu0.24.04.8 | 1:9.18.39-0ubuntu0.24.04.8 |
bind9 は一覧どおり …24.04.8 で入り、新しい …24.04.9 は入っていないため Missing と数えられています。新しいカーネル本体の linux-image-7.0.0-1014-aws も、linux-image-aws の依存として一覧のバージョンで入りました。確かめたコマンドは次のとおりです。
uname -r
dpkg -l linux-image-aws openssh-server bind9-libs linux-image-7.0.0-1014-aws | grep ^ii
6.14.0-1017-aws
ii bind9-libs:arm64 1:9.18.39-0ubuntu0.24.04.8 arm64 Shared Libraries used by BIND 9
ii linux-image-7.0.0-1014-aws 7.0.0-1014.14~24.04.1 arm64 Signed kernel image aws
ii linux-image-aws 7.0.0-1014.14~24.04.1 arm64 Linux kernel image for Amazon Web Services (AWS) systems.
ii openssh-server 1:9.6p1-3ubuntu13.20 arm64 secure shell (SSH) server, for secure access from remote machines
NoReboot なので、起動中のカーネルは 6.14 のままです。
コンプライアンスレポートで見る
本番の Patch Manager の [コンプライアンスレポート] では、本番の EC2 が非準拠と表示されました。
| 項目 | 本番の値 | 意味 |
|---|---|---|
| パッチ設定タイプ | Patch group | パッチポリシーを使わず、既定のベースラインで当てている |
| 重要な非準拠の数 | 183 | 非準拠と数えたパッチの数 |
| 利用可能なセキュリティ更新のカウント | - | Windows Server 向けの項目で、Linux では - になる |
| 欠如している数 | 3 | 承認されているが入っていないパッチ(Missing) |
| インストール済み保留中の再起動数 | 180 | 再起動待ちのパッチ(InstalledPendingReboot) |
| 失敗した数 | 0 | 入れるのに失敗したパッチ |
非準拠の 183 件は、再起動待ちの 180 件と bind9 の Missing の 3 件の合計です。Patch Manager は再起動待ちも非準拠として数えます。bind9 の 3 件は、開発で確かめていない更新を本番に入れない設計のため、次の開発の Install を経て本番に入るまで非準拠のまま表示されます。
コンプライアンス状況だけでは、未確認の更新が残っているのか、適用に失敗したのかは区別できません。運用では内訳の Missing と Failed を分けて見ます。
再起動と Scan のあと
本番の EC2 を再起動し、Run Command で Operation=Scan を流しました。ターゲットは Environment=prod です(Environment=dev のままだと、対象なしで成功として終わります)。新しいカーネルで起動していました。
$ uname -r
7.0.0-1014-aws
| 状態 | 件数 |
|---|---|
| Installed | 210 |
| InstalledPendingReboot | 0 |
| Failed | 0 |
| Missing | 4 |
再起動待ちの 180 件は Installed に移りました。Missing には libmspack0t64 の 1 件が加わっています。新しいバージョンの公開(0:23)が、開発の Scan(0:10)と本番の Scan(1:11)の間だったためです。
libmspack0t64 は、それまでセキュリティ更新が出ていなかったため、ベースライン対象外の InstalledOther に数えられていました。セキュリティ更新が公開されたことで対象になり、InstalledOther から Missing に移っています。そのため Installed は 210 件のまま、合計は 213 件から 214 件に増えています。
まだ入っていない更新を、リポジトリごとに数えました。
apt list --upgradable 2>/dev/null | grep -v ^Listing | cut -d/ -f2 | cut -d" " -f1 | tr , "\n" | sort | uniq -c
| EC2 | noble-security | noble-updates |
|---|---|---|
| 開発 | 3 | 70 |
| 本番 | 4 | 70 |
noble-updates の 70 件は、セキュリティ以外の更新を承認していないため対象外のままです。ただし APT の履歴(/var/log/apt/history.log)を見ると、Install の最初に python3-apt が 2.7.7ubuntu5.3 に更新されていました。AWS のドキュメントのとおり、Ubuntu では Patch Manager が使う python3-apt だけは、この設定でも最新にされます。
まとめ
前回の記事で考えたパッチ運用のしくみを、開発と本番を別々のアカウントにした検証環境で、マネジメントコンソールから作りました。
通知のしくみは次の記事で検証してみます!
この記事が、Patch Manager でパッチ運用を組み立てる方の参考になれば幸いです。







