S3 Object Lock のバケットに、監査の記録を 1 日 1 つにまとめて保管する仕組みを作ってみた

S3 Object Lock のバケットに、監査の記録を 1 日 1 つにまとめて保管する仕組みを作ってみた

Amazon S3 Object Lock と条件付き書き込みを使い、監査ログの重複保管を防ぐ仕組みを実装しました。EventBridge Scheduler で毎日実行される Lambda 関数が同じ日分を複数回書き込もうとしても、条件付き書き込みによって 2 回目以降は自動的に拒否され、バケット内には 1 つのバージョンだけが残ります。その仕組みと検証結果を紹介します。
2026.10.26

はじめに

テクニカルサポートの 片方 です。
業務システムでは、管理者が利用者の情報を閲覧したり、設定を変更したりした操作を、監査ログとして残します。監査ログは、後から改ざんや削除ができない形で、日ごとにまとめて保管しておきたいものです。
Amazon S3 Object Lock は、オブジェクトのバージョンを、一定期間または無期限に上書き・削除できなくする機能です。今回は、期限を決めてロックする保持期間を使います。前日分の監査ログを書き出す処理を Amazon EventBridge Scheduler で毎日実行すれば、保管を自動化できます。

ただし、毎日実行する書き出し処理は、同じ日の分が 2 回実行されることがあります。Scheduler は Lambda 関数を非同期で呼び出すため、関数がエラーになった後の再試行や、同じイベントの重複が起こり得るからです。運用者が手動で再実行することもあります。Object Lock のバケットでは、保持期間を設定して書き込んだバージョンは、保持期日まで、特別な権限がなければ削除できません(コンプライアンスモードでは誰も削除できません)。余分なバージョンができると、期日まで残り続けます。

そこで、オブジェクトキーを日付から決め、S3 の条件付き書き込み(If-None-Match: *)で書き込むことにしました。条件付き書き込みでは、同じキーのオブジェクトが既にあると、書き込みが拒否されます。東京リージョンに Object Lock を有効にしたバケットを作り、同じ日の分が 1 つしか保管されないかを試しました。

構成

01

バケットは、Object Lock とバージョニングを有効にして作成しました。保持期間は、オブジェクトを書き込むときに 1 つずつ設定します。
監査ログの保存先は、Amazon DynamoDB のテーブルです。パーティションキーに日付(day)を入れ、Query で 1 日分を取得します(件数が多い日は、LastEvaluatedKey を使ってページ単位で取得します)。サンプルデータはすべて架空のものです。

Lambda 関数は、前日分の監査ログを NDJSON(1 行に 1 件の JSON)にまとめ、audit/年/月/日付.ndjson のキーで書き込みます。監査ログが 0 件の日は、オブジェクトを作りません。関数の実行ロールには、ログの書き込みのほかに、次の 4 つだけを許可しました。S3 は s3:PutObject・s3:PutObjectRetention・s3:GetObject、DynamoDB は dynamodb:Query です。

本番で使う場合は、EventBridge Scheduler のスケジュールで、毎日 1 回、日本時間の深夜にこの関数を呼び出す想定です。関数を呼び出せなかったときは 2 回まで再試行し、それでも失敗したイベントは Amazon SQS のデッドレターキュー(DLQ)に送ります。
ただし、関数の中でエラーになった場合は、Scheduler の DLQ には入りません。Scheduler の DLQ に入るのは、関数の呼び出し自体に失敗したイベントです。このことは、呼び出しを許可していない関数を指定したスケジュールで確かめました(後の「確認してみた」で説明します)。関数の中でエラーになったイベントは、Lambda が非同期呼び出しとして再試行し(既定で 2 回)、それでも失敗すると破棄されます。

破棄されたイベントを後から追えるように、本番で使う場合は、Lambda 関数に非同期呼び出しの失敗時の送信先(on-failure destination)も設定します。今回は設定していません。

先に結論から

  • 同じ日の分を 5 回同時に書き込もうとすると、成功したのは 1 回で、残りの 4 回は 412(Precondition Failed)で拒否されました。バケットの中のバージョンは 1 つでした。
  • 試験用のスケジュール(cron(45 3 * * ? *)、Asia/Tokyo)が 3 時 45 分に 1 回実行され、前日分の 5 件が保管されました。保持モードはガバナンスモードで、保持期日は 6 時間後でした。
  • 保管した後に監査ログを 1 件追加して、もう一度書き込もうとしても 412 で拒否され、関数の結果は already-archived-differs でした。保管した後に届いた監査ログは、この仕組みでは保管されません。
  • 保持期日の前にバージョンを指定して削除しようとすると、AccessDenied(object protected by object lock)で拒否されました。ただし、バージョンを指定しない削除で削除マーカーができると、同じ日の 2 つ目のバージョンを書き込めてしまいます。

やってみた

1. バケットの設定を確認する

初めに、Object Lock とバージョニングの設定を確認しました。

aws s3api get-object-lock-configuration --bucket by-10-audit-20261023 --output json
aws s3api get-bucket-versioning --bucket by-10-audit-20261023 --output json
{
    "ObjectLockConfiguration": {
        "ObjectLockEnabled": "Enabled"
    }
}
{
    "Status": "Enabled",
    "MFADelete": "Disabled"
}

Rule が無いので、デフォルトの保持期間は設定されていません。

2. 書き出しの関数を作る

Python 3.13 の Lambda 関数です。

"""前の日の監査の記録を、S3 Object Lock のバケットに 1 日 1 つの NDJSON として保管する。

- 記録の元:DynamoDB の表(パーティションキー day = "YYYY-MM-DD"、ソートキー ts_id)。
- 0 件の日は書かない。
- 冪等性:キーを日付で決め(audit/YYYY/MM/YYYY-MM-DD.ndjson)、If-None-Match: * で書く。
  同じ日の 2 回目以降は 412 で断られるので、書かずに「保管済み」とする。中身が違えば、そう記録する。
- 保持:オブジェクトごとにガバナンスモード、期限は書いた時から RETENTION_HOURS 時間(検証では 6 時間)。
  Object Lock の保持を付けて書くには、Content-MD5 かチェックサムのヘッダーが要るので、SHA-256 のチェックサムを付ける。
- 再試行:SDK の standard モード(指数バックオフとジッター、再試行は最大 5 回で、初回を含めて 6 回)。
  条件付き書き込みの 409(ConditionalRequestConflict)は、フルジッターで待って書き直す(書き込みは最大 3 回、書き直しは 2 回まで)。
"""
import base64
import datetime as dt
import hashlib
import json
import os
import random
import time

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

JST = dt.timezone(dt.timedelta(hours=9))
CFG = Config(retries={"mode": "standard", "max_attempts": 5})
ddb = boto3.client("dynamodb", config=CFG)
s3 = boto3.client("s3", config=CFG)
BUCKET = os.environ["BUCKET"]
TABLE = os.environ["TABLE"]
RETENTION_HOURS = float(os.environ.get("RETENTION_HOURS", "6"))

def target_day(event):
    if isinstance(event, dict) and event.get("day"):
        return event["day"]
    return (dt.datetime.now(JST).date() - dt.timedelta(days=1)).isoformat()

def read_day(day):
    items = []
    kwargs = {
        "TableName": TABLE,
        "KeyConditionExpression": "#d = :d",
        "ExpressionAttributeNames": {"#d": "day"},
        "ExpressionAttributeValues": {":d": {"S": day}},
        "ConsistentRead": True,
    }
    while True:
        r = ddb.query(**kwargs)
        items += r["Items"]
        if "LastEvaluatedKey" not in r:
            return items
        kwargs["ExclusiveStartKey"] = r["LastEvaluatedKey"]

def to_ndjson(items):
    # 同じ記録からは、何度作っても同じバイト列になるように、並べる順とキーの順を決める
    rows = sorted(({k: next(iter(v.values())) for k, v in it.items()} for it in items), key=lambda r: r["ts_id"])
    return "".join(json.dumps(r, ensure_ascii=False, sort_keys=True) + "\n" for r in rows).encode()

def put_once(key, body, retain_until):
    """書けたら True、同じキーがもうあれば False。"""
    for attempt in range(1, 4):
        try:
            s3.put_object(
                Bucket=BUCKET, Key=key, Body=body, ContentType="application/x-ndjson",
                IfNoneMatch="*",
                ChecksumAlgorithm="SHA256",
                ObjectLockMode="GOVERNANCE",
                ObjectLockRetainUntilDate=retain_until,
            )
            return True
        except ClientError as e:
            code = e.response["Error"]["Code"]
            if code == "PreconditionFailed":
                return False
            if code == "ConditionalRequestConflict" and attempt < 3:
                time.sleep(random.uniform(0, 0.2 * 2 ** attempt))
                continue
            raise

def handler(event, context):
    day = target_day(event)
    items = read_day(day)
    key = f"audit/{day[:4]}/{day[5:7]}/{day}.ndjson"
    if not items:
        result = {"day": day, "result": "skipped-empty"}
    else:
        body = to_ndjson(items)
        sha = base64.b64encode(hashlib.sha256(body).digest()).decode()
        retain_until = dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=RETENTION_HOURS)
        if put_once(key, body, retain_until):
            result = {"day": day, "result": "archived", "records": len(items), "key": key}
        else:
            head = s3.head_object(Bucket=BUCKET, Key=key, ChecksumMode="ENABLED")
            same = head.get("ChecksumSHA256") == sha
            result = {"day": day, "result": "already-archived" if same else "already-archived-differs",
                      "records": len(items), "key": key}
    print(json.dumps(result, ensure_ascii=False))
    return result

チェックサムは、保持期間を設定して書き込むときに必要なので付けました。2 回目以降の書き込みが拒否されたときに、保管済みのオブジェクトと中身が同じかを比べるのにも使います。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/object-lock-managing.html

PutObject API を使用する場合は、Content-MD5 ヘッダー、x-amz-sdk-checksum-algorithm ヘッダー、またはその両方を指定して、Object Lock の保持期間を設定する必要があります。

保持モードはガバナンスモードにしました。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/object-lock.html

ガバナンスモードでは、特別なアクセス許可を持たない限り、ユーザーはオブジェクトのバージョンの上書きや削除、ロック設定を変更することはできません。

特別なアクセス許可とは s3:BypassGovernanceRetention のことで、Lambda 関数の実行ロールには付けていません。ガバナンスモードは、コンプライアンスモードで運用する前に、保持期間の設定を試す用途にも使えます。本番で使う場合は、法令や社内規程に合わせて、保持モードと保持期間を決めます。

3. 試験用のスケジュールを作る

本番で使う場合は、たとえば毎日 2 時 30 分(cron(30 2 * * ? *)、Asia/Tokyo)に実行する想定です。

今回の検証では、同じ形式の cron 式で、作成した時刻の少し後に実行されるスケジュールを作りました。スケジュールの実行ロールには、この Lambda 関数の呼び出しと、DLQ へのメッセージ送信だけを許可しています。$R はリージョン、$FN_ARN・$ROLE_ARN・$DLQ_ARN は Lambda 関数・実行ロール・DLQ の ARN です。$MIN・$HOUR は実行時刻、gen/ は生成したファイルの保存先です。

cat > gen/target-daily.json <<EOF
{"Arn": "$FN_ARN", "RoleArn": "$ROLE_ARN", "Input": "{}",
 "RetryPolicy": {"MaximumRetryAttempts": 2, "MaximumEventAgeInSeconds": 3600},
 "DeadLetterConfig": {"Arn": "$DLQ_ARN"}}
EOF

aws scheduler create-schedule --region $R --name by-10-daily-archive \
  --schedule-expression "cron($MIN $HOUR * * ? *)" --schedule-expression-timezone Asia/Tokyo \
  --flexible-time-window Mode=OFF --target file://gen/target-daily.json \
  --description 'Y10: 前の日の監査の記録を S3 Object Lock に保管(検証、2 日以内に消す)' --query 'ScheduleArn' --output text

試験用のスケジュールは 2026 年 10 月 23 日 3 時 32 分 11 秒に作成し、実行時刻は 13 分後の 3 時 45 分(cron(45 3 * * ? *))にしました。3 時 57 分 38 秒に削除したので、実行されたのは 3 時 45 分の 1 回だけです。時刻は、特に書いていない限り日本時間です。

スケジュールから呼び出すときは入力が空({})なので、関数は前日を対象にします。aws lambda invoke で直接呼び出すときは、{"day": "2026-10-20"} のように対象の日を渡します。
DLQ に入ることを確かめるため、by-10-missing という関数を 3 時 43 分に呼び出すスケジュールも、再試行 0 回で作りました。by-10-missing は存在しない関数で、実行ロールにも呼び出しの許可を付けていません。このスケジュールも 3 時 57 分に削除しました。

確認してみた

同じ日の分を 5 回同時に書き込む

試験用のスケジュールが実行される前の 3 時 31 分に、まだ保管していない 2026 年 10 月 20 日を指定して、関数を 5 回同時に呼び出しました。呼び出しには、aws lambda invoke を並列で実行するスクリプト(invoke_parallel.sh)を使いました。画像の後半にあるバケットの一覧は、次の節のスケジュールが実行された後に取得したので、10 月 22 日の分も含まれています。この一覧の時刻は UTC です。

02

書き込みに成功したのは 2 番目の呼び出しだけで、ほかの 4 回は 412 で拒否され、結果は already-archived(中身も同じ)でした。バケットの中は、どちらの日もバージョンが 1 つで、削除マーカーもありません。

試験用のスケジュールが 1 回実行され、前日分が保管された

03

画面の 2 つ目以降の日時は、スケジュールを削除したので実行されていません。
3 時 45 分にスケジュールが実行され、関数は前日(2026 年 10 月 22 日)分の 5 件を、3 時 45 分 34 秒に書き込みました。保持モードはガバナンスモードで、保持期日は、書き込む直前に計算した時刻の 6 時間後(9 時 45 分 33 秒)でした。

04

続けて、保管済みの 10 月 22 日を指定して、aws lambda invoke で 3 回同時に呼び出しても、結果はすべて already-archived でした。監査ログが無い日(2026 年 10 月 21 日)を指定すると、結果は skipped-empty で、オブジェクトは作られませんでした。

保管した後に監査ログが増えたとき

保管した後に、10 月 22 日の監査ログを 1 件追加してから、もう一度呼び出しました。

05

監査ログは 6 件になりましたが、書き込みは 412 で拒否され、保管済みの 5 件のオブジェクトはそのまま残りました。チェックサムが異なるので、結果は already-archived-differs でした。

画像の後半は、by-10-missing を呼び出すスケジュールの結果です。DLQ のメッセージの属性に、AccessDeniedException とその理由が入っていました。画像の「10 件」は、確認用のスクリプトが 1 回の ReceiveMessage(最大 10 件、可視性タイムアウト 0)で受信したメッセージの数です。同じメッセージ ID を繰り返し受信していたので、メッセージ ID で数えると 1 件でした。

保持期日の前に、バージョンを指定して削除する

10 月 20 日のオブジェクトの保持期日(9 時 31 分 33 秒)より前の 4 時 38 分に、バージョンを指定して 1 回だけ削除を試しました。ガバナンスモードの保持を解除するオプション(--bypass-governance-retention)は付けていません。

06

削除は AccessDenied で拒否され、理由は「object protected by object lock」でした。ガバナンスモードでも、オプションを付けない削除は拒否されます。保持を解除して削除するには、このオプションに加えて、s3:BypassGovernanceRetention の権限が必要です。

削除マーカーがあると、条件付き書き込みは成功する

バージョニングが有効なバケットでは、現在のバージョンが削除マーカーであれば、条件付き書き込みは成功します。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/conditional-writes.html

バージョニングが有効なバケットでは、同じ名前の現在のオブジェクトバージョンが存在しない場合、または現在のオブジェクトバージョンに削除マーカーがある場合、書き込みオペレーションは成功します。

Object Lock のバケットでも、バージョンを指定しない削除は成功し、削除マーカーが最新のバージョンになります。その後に同じ日の書き出しが実行されると、2 つ目のバージョンが保管されます。防ぐには、関数の実行ロールに削除の権限を付けないことと、バケットポリシーで削除を拒否することを組み合わせる想定です(今回は試していません)。

まとめ

同じ日の分を 5 回同時に書き込もうとしても、保管されたのは 1 つだけでした。条件付き書き込みによって、2 回目以降は書き込む前に拒否されるためです。保持期間を設定したバージョンは保持期日まで削除できないので、余分なバージョンをそもそも作らないことが大切です。
一方で、削除マーカーがあると 2 つ目のバージョンを書き込めてしまうことと、保管した後に届いた監査ログが保管されないことは、この仕組みだけでは防げません。本番で使う場合は、削除を拒否するバケットポリシーと、遅れて届いた監査ログの扱いを先に決めておきます。
本ブログが誰かの参考になれば幸いです。

参考資料

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

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

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事