PostgreSQL Anonymizerによるデータマスキング:ステージング環境向けの機密データ保護手法

Database tutorial - IT technology blog
Database tutorial - IT technology blog

本番データ同期におけるジレンマ

半年前、私たちのチームはステージング環境の同期パイプラインが原因でコンプライアンス監査に不合格となりました。多くの開発現場と同様、私たちもcronジョブを使って毎晩45 GBの本番データベースをダンプし、ステージング環境へそのままリストアしていました。開発者には、リアルなデータ量、クエリプランナのプロファイル、そしてモックデータ生成ツールでは決して再現できない複雑なエッジケースが必要だったためです。

しかし問題は、そのデータと一緒に持ち込まれるものにありました。顧客の生の決済情報、居住地住所、パスワードハッシュ、個人のメールアドレスが、20名ものエンジニアがアクセスできる環境にそのまま展開されていたのです。さらに悪いことに、Sentryのようなサードパーティ製エラー監視ツールが、ステージング環境でキャッチされなかった例外から実ユーザーの識別情報を知らぬ間に収集していました。GDPRやSOC 2のガイドラインに基づき、こうした運用は即座に停止しなければなりませんでした。

当初、リストア後にデータをクレンジングするその場しのぎのPythonスクリプトを作成することも検討しました。しかし、その案はすぐに却下しました。Pythonによるサニタイズ処理は、誰かがマイグレーションを追加するたびに壊れてしまうような、保守性の低い脆弱なスキーマ定義辞書を管理し続けることを意味していたからです。PostgreSQLは拡張機能のエコシステムを通じてエンジン内でネイティブにプライバシー変換を処理できるため、マスキングルールを適用する場所として最も自然な選択肢でした。

PostgreSQL Anonymizerの基本概念

PostgreSQL Anonymizer(anon)は、Dalibo社がメンテナンスしているオープンソースの拡張機能です。正規表現を使ってSQLダンプファイルを書き換えたり、リストア後にデータ消去スクリプトを実行したりするのではなく、セキュリティラベルと宣言型のプライバシーポリシーを利用してPostgreSQL内部で直接動作します。

データをマスキングする3つの手法

この拡張機能は、主に3つの運用戦略をサポートしています。

  • 匿名ダンプ(Anonymous Dumps):pg_dump_anonを介してサニタイズ済みデータをストリーミング出力します。コピー元のデータベースには一切手を加えず、ダンプファイルやUnixパイプへ直接マスキング済みデータを出力します。
  • インプレースの恒久マスキング(In-Place Permanent Masking):開発者にアクセス権を付与する前に、リストア専用のインスタンス上で破壊的な変更(データ書き換え)を実行します。
  • 動的マスキング(Dynamic Masking):呼び出し元のデータベースロールに基づいて、実行時にクエリの投影(SELECT結果)をマスキングします。これは分析用のリードレプリカやカスタマーサポート用コンソールに最適です。

私たちの同期パイプラインでは、匿名ダンプのパターンを採用しました。これにより、マスキングされていないPII(個人識別情報)が本番ホストのメモリ境界の外へ出ることは一切ありません。

組み込みマスキング関数

この拡張機能には、内部のFakerエンジンを利用した実用的なデータ生成機能が標準搭載されています。

  • anon.fake_first_name() および anon.fake_last_name():自然な人名を生成します。
  • anon.fake_email():実際の顧客メールアドレスを、[email protected]のような現実的なダミーアドレスに置き換えます。
  • anon.pseudo_iban() および anon.partial():文字長を保ちながら、金融識別子を部分的に伏字化またはパディングします。
  • anon.noise():広範な分布曲線を歪めることなく、数値カラムに一定割合のランダムなばらつきを加えます。

実践的な実装手順

以下は、毎月のマルチギガバイト規模のエクスポートで検証を重ね、私たちのGitHub Actions同期ジョブで実際に稼働しているパイプラインです。

ステップ1:拡張機能のインストールと初期化

使用しているPostgreSQLのメジャーバージョン(PostgreSQL 14、15、または16)に対応するパッケージをインストールします。DebianおよびUbuntuシステムの場合:

sudo apt-get install postgresql-16-anon

postgresql.confでサーバーの共有ライブラリにanonを追加し、クラスターを再起動します:

shared_preload_libraries = 'anon'

スーパーユーザーとしてソースデータベースに接続し、拡張機能を初期化します:

CREATE EXTENSION IF NOT EXISTS anon CASCADE;
SELECT anon.init();

ステップ2:セキュリティラベルによるマスキングルールの宣言

すぐに古くなってしまう外部のYAMLマッピングファイルを管理する代わりに、PostgreSQL AnonymizerではネイティブのSECURITY LABEL文を使用します。標準的なEコマースの顧客テーブルを例に考えてみましょう:

CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    email VARCHAR(100) UNIQUE,
    phone_number VARCHAR(20),
    balance_cents INT
);

マイグレーションファイル内で、カラムにプライバシー宣言を直接関連付けます:

-- 名前をリアルな偽名にマスキング
SECURITY LABEL FOR anon ON COLUMN customers.first_name
    IS 'MASKED WITH FUNCTION anon.fake_first_name()';

SECURITY LABEL FOR anon ON COLUMN customers.last_name
    IS 'MASKED WITH FUNCTION anon.fake_last_name()';

-- 一意性を保ちながらメールアドレスを置換
SECURITY LABEL FOR anon ON COLUMN customers.email
    IS 'MASKED WITH FUNCTION anon.pseudo_email(id::TEXT)';

-- 電話番号を部分的に非表示化(プレフィックスとサフィックスを残す)
SECURITY LABEL FOR anon ON COLUMN customers.phone_number
    IS 'MASKED WITH FUNCTION anon.partial(phone_number, 2, ''******'', 2)';

-- 残高に+/- 15%のランダムなばらつきを追加
SECURITY LABEL FOR anon ON COLUMN customers.balance_cents
    IS 'MASKED WITH FUNCTION anon.noise(balance_cents, 0.15)';

これらのルールはPostgreSQLのネイティブシステムカタログ(pg_seclabel)に保存されます。スキーマとともにマイグレーションを通過し、バージョン管理システムにクリーンにコミットされます。

ステップ3:匿名化データセットのエクスポート

pg_dump_anonを使用してデータをエクスポートします。これは標準のpg_dumpをラップしており、データ行がネットワーク経由で転送される最中にオンザフライでマスキング変換を適用します:

pg_dump_anon \
    -h prod-replica.internal \
    -U backup_agent \
    -d app_production \
    -F c \
    -b \
    -v \
    -f /tmp/sanitized_export.dump

サニタイズされたダンプファイルをステージング先のデータベースへ直接リストアします:

pg_restore \
    -h staging-db.internal \
    -U postgres \
    -d app_staging \
    --clean \
    /tmp/sanitized_export.dump

開発者がapp_stagingに接続したとき、リレーショナル整合性は完全に保たれています。外部キーの対応関係は維持されたまま、顧客のメールアドレス、氏名、電話番号はすべて合成データに置き換わっています。

ステップ4:外部キー整合性の維持

完全にランダムな生成器を使用すると、外部キーのリレーションシップが壊れてしまいます。例えばorders.customer_emailがcustomers.emailを参照している場合、両方に個別のランダム文字列を割り当てると結合(JOIN)が破綻してしまいます。

リレーショナルな関連付けを保護するために、一貫したソルト値(salt)を用いた決定論的な仮名化関数を使用します:

-- エクスポートセッション用の共通ソルトを設定
SELECT anon.init('a8f9c2d1e0b4a7f6c3d2e1b0');

SECURITY LABEL FOR anon ON COLUMN users.login_handle
    IS 'MASKED WITH FUNCTION anon.pseudo_first_name(login_handle)';

SECURITY LABEL FOR anon ON COLUMN activity_logs.user_handle
    IS 'MASKED WITH FUNCTION anon.pseudo_first_name(user_handle)';

固定のソルト値を使用することで、同じ入力文字列からは常に同一の置換後文字列が生成されます。これによりテーブル間の結合を維持しつつ、元の個人情報はしっかり保護されます。

本番運用6ヶ月で得られた知見

運用を進める中で、2つの技術的課題に直面しました。

1つ目はCPUのオーバーヘッドです。pg_dump_anonは暗号論的ハッシュ化や文字列フォーマットを行単位で適用するため、完全にCPUバウンドな処理となります。私たちの45 GBのデータセットでは、通常のpg_dumpが7分で完了したのに対し、マスク処理付きダンプは19分と約2.7倍の時間を要しました。ダンプ処理のエージェントを独立したリードレプリカに向けることで、プライマリの書き込みノードへの負荷スパイクを防ぎました。

2つ目の課題はスキーマドリフトです。ある開発者が自由形式の住所を含む新しいshipping_notesカラムを追加した際、データベースはデフォルトでマスキングされていない生のデータを出力してしまいました。この抜け穴を防ぐため、CIパイプラインに独自のチェック処理を追加しました:

-- 機密テーブルに未分類のカラムが存在する場合はエラーとする
SELECT c.table_name, c.column_name
FROM information_schema.columns c
LEFT JOIN pg_seclabel s 
    ON s.objname = c.table_name || '.' || c.column_name
WHERE c.table_schema = 'public'
  AND c.table_name IN ('customers', 'orders', 'users')
  AND s.label IS NULL;

保護対象のテーブルにラベル未設定のカラムを追加するマイグレーションが含まれている場合、コードが本番環境へマージされる前にビルドが失敗するようになります。

PostgreSQL内部でデータマスキングを完結させたことで、リストア後に実行していた数十もの不安定なBashスクリプトを全廃できました。ステージング環境は毎晩本番スケールの最新データセットを受け取りながら、顧客データが本番環境の安全圏から外へ流出することは一切なくなりました。

Share: