監査ログ機能の設計におけるトレードオフ
はじめに:監査ログはただの「ログ」ではない
Webアプリケーション開発において、「ログ」といえばどんなものか。
デバッグのためのアプリケーションログ、あるいはアクセス解析のためのアクセスログ。これらは通常、標準出力(stdout)に書き出され、Fluent Bitなどを経由して非同期にログ基盤へ送られます。パフォーマンスへの影響を最小限に抑える、非常に理にかなった構成です。
しかし、「監査ログ(Audit Log)」の要件はこれらとは根本的に異なります。
特に金融や医療、あるいは厳格なガバナンスが求められるSaaSにおいて、監査ログには以下の3つの性質が求められます。
- 完全性 (Completeness): 操作が行われた事実がある以上、ログは 絶対に 存在しなければならない。
- 原子性 (Atomicity): 「処理は成功したが、ログ保存に失敗した」という不整合は許されない。
- 意味性 (Semantics): 単なるデータの差分ではなく、「誰が・どのような意図で」その操作を行ったかというビジネスコンテキストが記録されていること。
この記事では、これらの要件を満たすために、あえて 「データベースへの同期書き込み」 を選択する理由と、その際に生じるコードの複雑性を AsyncLocalStorage (ALS) と ドメインイベントを用いて解決する設計パターンを紹介します。
実装例として NestJS と Prisma を使用しますが、設計思想自体はフレームワークを問わず応用可能です。
アーキテクチャの選定:なぜ同期書き込みなのか
設計段階で検討されうる3つのアプローチを比較し、なぜ「アプリケーション層での同期書き込み」が最適解となるのかを整理します。
案1:標準出力 + Log Shipper (Fluent Bit 等)
コンテナの標準出力にJSON形式で監査ログを吐き出し、サイドカー構成のログ収集基盤に送るクラウドネイティブなアプローチです。
- メリット: アプリケーションのレスポンスタイムへの影響が極小。疎結合。
- 却下理由: 原子性が保証されない。
DBへのコミット直後、ログを出力する前にプロセスがOOM Kill等でクラッシュした場合、監査ログだけが消失するリスクがあります。「99.9%記録されている」では不十分な監査要件において、この0.1%のリスクは許容できません。
案2:CDC (Change Data Capture) / DB Trigger
DebeziumなどでDBのWALを監視したり、トリガーを用いて別テーブルへコピーする方法です。
- メリット: データの変更はDBレベルで確実に保証される。
- 却下理由: コンテキストと意図の欠落。
DBレイヤーでは、「どのHTTPリクエストによる変更か」「実行ユーザーのIPアドレスは?」といったアプリケーションコンテキストを知ることは困難です。また、status カラムが 1 から 9 に変わった事実は記録できても、それが「管理者によるBAN」なのか「ユーザーによる退会」なのかというビジネス的な意図が消失してしまいます。
結論:アプリケーション層での同期書き込み
同一トランザクション内で業務データと監査ログテーブル(audit_logs)を更新します。
- 採用理由:
- ACID特性の享受: 業務データのコミットとログの保存が「一連托生」となり、原子性が100%保証されます。
- コンテキストの保持: メモリ上のアプリケーションコンテキスト(User, IP)と、ビジネス意図(Domain Event)を完全な状態で記録できます。
- 代償: 書き込みレイテンシが増加します。しかし、これはデータの整合性と引き換えに支払うべきコストと割り切ります。
実装課題:ACIDを守りつつ、コードを綺麗に保つ
方針は決まりましたが、素朴に実装するとユースケース層が悲惨なことになります。
// ❌ 避けるべき実装: バケツリレーとベタ書き
async execute(command: Command, user: User, ip: string) { // 引数地獄
return this.prisma.$transaction(async (tx) => {
// 1. ドメインロジック
const entity = await tx.entity.find(...);
const before = JSON.stringify(entity); // 変更前をとっておく...?
entity.update(command.data);
await tx.entity.update(...);
// 2. 監査ログ (ドメインロジックとは無関係なコードが混入)
await tx.auditLog.create({
data: {
userId: user.id,
ipAddress: ip,
action: 'UPDATE_ENTITY',
// Diffを手動計算...面倒だし漏れる
changes: JSON.stringify(diff(before, entity)),
}
});
});
}
この「バケツリレー」と「ユースケースの肥大化」を防ぐために、以下の戦略をとります。
- ALS でコンテキスト(User, IP)を裏で持ち回る。
- ドメインイベント に before / after の状態を持たせる。
- AuditService にDiff計算と保存ロジックを隠蔽する。
実装:NestJS × Prisma × ALS
Step 1: ContextとStoreの定義
まず、リクエストスコープで持ち回りたい情報と、イベントを溜めるキューを定義します。
// audit-context.store.ts
import { AsyncLocalStorage } from 'async_hooks';
export type AuditContext = {
userId?: string;
ipAddress?: string;
userAgent?: string;
requestId: string;
};
// 発生したドメインイベントを保持するコンテナ
export class AuditStore {
private readonly events: BaseDomainEvent[] = [];
constructor(public readonly context: AuditContext) {}
addEvent(event: BaseDomainEvent) {
this.events.push(event);
}
getEvents() {
return [...this.events];
}
clearEvents() {
this.events.length = 0;
}
}
export const auditStorage = new AsyncLocalStorage<AuditStore>();
これを NestJS の Middleware で初期化し、auditStorage.run() でリクエスト全体をラップします(コードは割愛)。
Step 2: ドメインイベントとEntityの基盤
ここが工夫ポイントです。全てのドメインイベントが「変更前」「変更後」のスナップショットを持てるように基底クラスを用意します。
// domain-event.base.ts
export abstract class BaseDomainEvent {
constructor(
public readonly eventName: string,
// 監査用に Before/After のスナップショットを持つ
public readonly before: Record<string, any> | null,
public readonly after: Record<string, any> | null,
) {}
}
Entity側には、監査ログ用に安全なJSONを返すメソッド toAuditJson を用意させます。
// organization.entity.ts
export class Organization {
// 💡 ポイント: パスワードハッシュなどの機密情報はここで除外する
toAuditJson(): Record<string, any> {
return {
id: this.id,
name: this.name,
ipRestriction: this.props.ipRestriction,
};
}
changeIpRestriction(enabled: boolean) {
// 1. 変更前のスナップショットを取得
const before = this.toAuditJson();
// 2. 状態変更
this.props.ipRestriction = enabled;
// 3. イベント発行(ALSに積まれる)
publishDomainEvent(new OrganizationSettingsUpdated(
before,
this.toAuditJson() // after
));
}
}
Step 3: Diff計算を隠蔽する AuditService
ユースケースを汚さないために、複雑な処理はすべてこのサービスに押し込めます。
deep-object-diff などのライブラリを使うと便利です。
// audit.service.ts
import { diff } from 'deep-object-diff';
@Injectable()
export class AuditService {
/**
* 現在のコンテキスト(ALS)に溜まっているイベントを回収し、
* Diffを計算してトランザクション内に書き込む
*/
async flush(tx: Prisma.TransactionClient) {
const store = auditStorage.getStore();
if (!store) return;
const events = store.getEvents();
if (events.length === 0) return;
const auditData = events.map(event => {
// ★ ここで Diff を自動計算!
// before と after の差分だけを抽出する(データ容量の節約)
const changes = (event.before && event.after)
? diff(event.before, event.after)
: event.after; // 新規作成時などはAfterのみ
return {
id: crypto.randomUUID(),
occurredAt: new Date(),
userId: store.context.userId,
ipAddress: store.context.ipAddress,
action: event.eventName,
changes: JSON.stringify(changes),
};
});
await tx.auditLog.createMany({ data: auditData });
// 二重書き込み防止
store.clearEvents();
}
}
Step 4: ユースケースの実装
これまでの準備のおかげで、ユースケースはビジネスロジックや永続化のオーケストレーションのみに集中できます。
監査ログのことは auditService.flush(tx) を呼ぶだけ。
// change-settings.usecase.ts
@Injectable()
export class ChangeSettingsUseCase {
constructor(
private prisma: PrismaService,
private auditService: AuditService // 専用サービス
) {}
async execute(command: Command) {
// Interactive Transaction
return this.prisma.$transaction(async (tx) => {
const org = await tx.organization.findUniqueOrThrow(...);
// ドメインロジック(内部でイベント発行済み)
org.changeIpRestriction(command.enabled);
// 業務データの保存
await tx.organization.update({ ... });
// ★ユースケースはこれを呼ぶだけ!
// 「今の変更、ログに残しておいて」
await this.auditService.flush(tx);
});
// ここでコミット。業務データと監査ログが同時に確定する。
}
}
Step 5: 失敗時の記録(Interceptor)
上記は成功時のフローですが、監査ログには「操作に失敗した」事実も必要です。ドメインレベルのエラーはResult型を用いて前述のユースケースレイヤーで取れますが、ランタイムエラーはResult型にしない設計なので Global Interceptor でキャッチします。ぜんぶResult型にする選択もあるとおもいます。
// audit-error.interceptor.ts
@Injectable()
export class AuditErrorInterceptor implements NestInterceptor {
constructor(private prisma: PrismaService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
catchError(async (error) => {
const store = auditStorage.getStore();
if (store) {
// トランザクションとは無関係に、失敗の事実を記録する
// (fire-and-forget、あるいは await しても良い)
await this.prisma.auditLog.create({
data: {
userId: store.context.userId,
action: 'OPERATION_FAILED',
errorReason: error.message,
// ※ 失敗時はDiffは取れないので、エラー内容を残す
}
});
}
throw error;
}),
);
}
}
運用設計:データ爆発への対抗策
「監査ログをRDBに同期書き込みする」と決定したとき、最大の懸念事項はデータ量の爆発的増加です。
数百万、数千万行の監査ログがメインDBを圧迫し、Vacuum処理やインデックス更新がボトルネックになっては本末転倒です。
この問題に対しては、アプリケーション層ではなくデータベース層(PostgreSQL)の機能で対処します。
1. Table Partitioning (pg_partman)
audit_logs テーブルは単一の巨大なテーブルとして運用せず、1ヶ月ごと(あるいは1週間ごと)のパーティションに分割します。監査ログは典型的な時系列データであり、アクセス頻度は「直近のものは高く、古いものは低い」とはっきりしているため、Range Partitioningが極めて有効です。
PostgreSQLには pg_partman という強力な拡張機能があり、これを導入することでパーティション管理を自動化できます。
-- migration.sql イメージ
-- pg_partman拡張の有効化
CREATE EXTENSION IF NOT EXISTS pg_partman;
-- 親テーブルの作成
CREATE TABLE audit_logs (
id UUID NOT NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- ...
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
-- partmanによる管理開始(1ヶ月単位でパーティション作成)
SELECT partman.create_parent(
p_parent_table => 'public.audit_logs',
p_control => 'occurred_at',
p_type => 'native',
p_interval => '1 month',
p_premake => 2
);
このように pg_partman を使うことで、毎月のテーブル作成やメンテナンスを手動で行うリスクから解放されます。
2. コールドデータのS3退避(Archiving)
監査ログの保持期間が「7年」や「10年」と定められている場合、その全てをRDBに入れておくのはコスト効率が悪すぎます。
パーティショニングを行っていれば、古くなったデータの退避は非常に簡単です。
- Detach: 古いパーティション(例: 1年以上前)をメインテーブルから切り離す。
- Export: 切り離したテーブルをCSVやParquet形式でエクスポートし、S3(Glacier Deep Archiveなど)へ転送する。
- Drop: 完了後、テーブルを DROP する。
このライフサイクルを cron や AWS Batch 等で自動化することで、メインDBには常に「直近1年分のホットデータ」だけが存在し、パフォーマンスとコストのバランスが保たれた状態を維持できます。
まとめ
今回の設計パターンの要点は以下の通りです。 - 同期書き込みを選択する: パフォーマンスを犠牲にしてでも、監査ログの「完全性」と「原子性」を最優先する。
- ALS × ドメインイベント: バケツリレーを排除し、ドメインロジックを純粋に保つ。
- Diffの自動化: before/after パターンと AuditService により、詳細な変更履歴を低コストで実装する。
- 運用設計: pg_partman と S3アーカイブにより、スケーラビリティを担保する。
要件定義の段階では後回しにされがちな監査ログですが、ここを堅牢に作り込むことで、システム全体の信頼性は大きく向上します。本記事が、皆様のプロジェクトにおける設計の一助となれば幸いです。
Discussion