
S3 Object Lock のバケットに、監査の記録を 1 日 1 つにまとめて保管する仕組みを作ってみた
はじめに
テクニカルサポートの 片方 です。
業務システムでは、管理者が利用者の情報を閲覧したり、設定を変更したりした操作を、監査ログとして残します。監査ログは、後から改ざんや削除ができない形で、日ごとにまとめて保管しておきたいものです。
Amazon S3 Object Lock は、オブジェクトのバージョンを、一定期間または無期限に上書き・削除できなくする機能です。今回は、期限を決めてロックする保持期間を使います。前日分の監査ログを書き出す処理を Amazon EventBridge Scheduler で毎日実行すれば、保管を自動化できます。
ただし、毎日実行する書き出し処理は、同じ日の分が 2 回実行されることがあります。Scheduler は Lambda 関数を非同期で呼び出すため、関数がエラーになった後の再試行や、同じイベントの重複が起こり得るからです。運用者が手動で再実行することもあります。Object Lock のバケットでは、保持期間を設定して書き込んだバージョンは、保持期日まで、特別な権限がなければ削除できません(コンプライアンスモードでは誰も削除できません)。余分なバージョンができると、期日まで残り続けます。
そこで、オブジェクトキーを日付から決め、S3 の条件付き書き込み(If-None-Match: *)で書き込むことにしました。条件付き書き込みでは、同じキーのオブジェクトが既にあると、書き込みが拒否されます。東京リージョンに Object Lock を有効にしたバケットを作り、同じ日の分が 1 つしか保管されないかを試しました。
構成

バケットは、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 回目以降の書き込みが拒否されたときに、保管済みのオブジェクトと中身が同じかを比べるのにも使います。
PutObject API を使用する場合は、
Content-MD5ヘッダー、x-amz-sdk-checksum-algorithmヘッダー、またはその両方を指定して、Object Lock の保持期間を設定する必要があります。
保持モードはガバナンスモードにしました。
ガバナンスモードでは、特別なアクセス許可を持たない限り、ユーザーはオブジェクトのバージョンの上書きや削除、ロック設定を変更することはできません。
特別なアクセス許可とは 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 です。

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

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

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

監査ログは 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)は付けていません。

削除は AccessDenied で拒否され、理由は「object protected by object lock」でした。ガバナンスモードでも、オプションを付けない削除は拒否されます。保持を解除して削除するには、このオプションに加えて、s3:BypassGovernanceRetention の権限が必要です。
削除マーカーがあると、条件付き書き込みは成功する
バージョニングが有効なバケットでは、現在のバージョンが削除マーカーであれば、条件付き書き込みは成功します。
バージョニングが有効なバケットでは、同じ名前の現在のオブジェクトバージョンが存在しない場合、または現在のオブジェクトバージョンに削除マーカーがある場合、書き込みオペレーションは成功します。
Object Lock のバケットでも、バージョンを指定しない削除は成功し、削除マーカーが最新のバージョンになります。その後に同じ日の書き出しが実行されると、2 つ目のバージョンが保管されます。防ぐには、関数の実行ロールに削除の権限を付けないことと、バケットポリシーで削除を拒否することを組み合わせる想定です(今回は試していません)。
まとめ
同じ日の分を 5 回同時に書き込もうとしても、保管されたのは 1 つだけでした。条件付き書き込みによって、2 回目以降は書き込む前に拒否されるためです。保持期間を設定したバージョンは保持期日まで削除できないので、余分なバージョンをそもそも作らないことが大切です。
一方で、削除マーカーがあると 2 つ目のバージョンを書き込めてしまうことと、保管した後に届いた監査ログが保管されないことは、この仕組みだけでは防げません。本番で使う場合は、削除を拒否するバケットポリシーと、遅れて届いた監査ログの扱いを先に決めておきます。
本ブログが誰かの参考になれば幸いです。
参考資料
- 条件付き書き込みによるオブジェクトの上書きを防ぐ方法 - Amazon Simple Storage Service
- S3 Object Lock を使用したオブジェクトのロック - Amazon Simple Storage Service
- オブジェクトロックの考慮事項 - Amazon Simple Storage Service
- Adding context attributes in EventBridge Scheduler - EventBridge Scheduler
- Invoke a Lambda Event Function on a schedule - AWS Lambda
- Lambda がエラーを処理し、非同期呼び出しで再試行する方法 - AWS Lambda
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026年1月 アノテーション㈱から社名変更しました







