開発と本番が別アカウントのEC2(Ubuntu) に Patch Manager で順番にパッチを当ててみた

開発と本番が別アカウントのEC2(Ubuntu) に Patch Manager で順番にパッチを当ててみた

前回設計したUbuntu 24.04のパッチ運用ルールを、開発と本番の2つのAWSアカウントで実際に構築・検証してみます。セキュリティ更新の承認から本番への適用まで、各ステップを手動で動かしながら仕組みを確認していきます。
2026.11.08

製造ビジネステクノロジー部の小林です。

前回の記事では、EC2(Ubuntu 24.04 )の OS パッチ運用ルールを Patch Manager 前提で考えました。

https://dev.classmethod.jp/articles/shoma-designed-os-patch-operations-for-ubuntu-24-04-ec2-based-on-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 日後に承認する」設定には対応していません。

https://docs.aws.amazon.com/systems-manager/latest/userguide/patch-manager-create-a-patch-baseline-for-linux.html

パッチの例外

項目 設定 意味
承認済みパッチ 空のまま ルール外のパッチを個別に許可する欄
拒否されたパッチ 空のまま 問題が出たときにパッケージを入れる欄
拒否されたパッチのアクション ブロック 拒否したパッケージが、別の更新の依存としても入らないようにする

https://docs.aws.amazon.com/systems-manager/latest/userguide/patch-manager-installing-patches.html

パッチのソースは 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」が保存されることがある(後述)

https://docs.aws.amazon.com/systems-manager/latest/userguide/patch-manager-aws-runpatchbaseline.html

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 になれば外れています。

https://docs.aws.amazon.com/cli/latest/reference/ssm/update-maintenance-window-task.html

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 か所にまとめています。

https://docs.aws.amazon.com/systems-manager/latest/userguide/patch-manager-compliance-states.html

一覧を置く S3 バケット

開発のアカウントに、バケット名の接頭辞を patch-lists にしてバケットを作ります。そのほかは既定のまま(SSE-S3、ACL 無効)です。この記事では、バケット名を patch-lists-xxxxxxxx と書きます。

関数を作る

lambda_function.py
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
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 を、手で再現します。

  1. EC2 のコンソールで patch-dev を再起動する
  2. Run Command で AWS-RunPatchBaseline を Operation=Scan で実行する。ターゲットは Environment=dev
  3. 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 で確かめてから本番へ渡ります。

https://launchpad.net/ubuntu/+source/bind9

テストの結果は次のとおりで、S3 に approved/2026-11-03.yaml が置かれました。

不合格のときは result が NG になり、理由(例:「再起動待ちのパッチがある」)を返して、承認済みの一覧を置きません。

本番に当てる Lambda

本番のアカウントに、承認済みの一覧を本番の EC2 に当てる関数を作ります。イベントで日付を指定すればその日の一覧を、指定しなければ approved/ のいちばん新しい一覧を使い、一覧がなければ見送ります。

日付を指定しない場合、朝の確認が NG の日は前回の一覧で Install が流れます。そのバージョンは本番に入っているので、パッケージは更新されません。その日の承認状態だけで判断したい場合は、日付を必ず指定します。

一覧は InstallOverrideList パラメータで渡し、ファイルは本番の EC2 の SSM Agent が読みます。そのため patch-ec2-role にも開発のバケットを読む権限を付けています。

lambda_function.py
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 件に増えています。

https://launchpad.net/ubuntu/+source/libmspack

まだ入っていない更新を、リポジトリごとに数えました。

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 でパッチ運用を組み立てる方の参考になれば幸いです。

この記事をシェアする

関連記事