注:本記事は(2022年3月31日)に公開された(Solving a Partial Data Retention Challenge with Cryptography and Java UDFs)を翻訳して公開したものです。

Singularでは、テラバイトにおよぶ使用状況ログなど、大量の顧客の機密情報を処理しており、それぞれがあらゆる機密情報にアタッチされています。お客様からは、プライバシーを理由にこのデータに選択的な保持ポリシーを適用して欲しいという要望を受けることがあります。そのような要件の1つが、Snowflakeへの移行時に浮上しました。それは、特定の情報を30日以上保持してはいけないというものでした。しかし、その情報は複数の列に分散しており、それらの列は30日以上保管する必要のある情報でした。さらに複雑なのは、30日間の保持制限のある特定の値が、文字列の中にあったことです。その文字列も、30日以上保管する必要があるものです。

ここで興味深い課題が生まれました。取り込みから30日後に列や文字列の一部を抜粋して消去するにはどうすればよいか、それも、クエリ速度を維持したまま、コストを最小限に抑えて実現する方法はあるのかという課題です。

Snowflakeの簡単な紹介

私たちが操作する使用状況ログには、信頼できる方法でアクセス可能であることに加えて、追加の費用が発生しないという条件がありました。それが、Snowflakeを使用している理由です。Snowflakeとの作業の中で、必要なレベルでのクエリ実行を確実にするには、すべてのアタッチされた情報を維持した状態で各ログを非正規化して保存する必要があることがわかりました。そのため、各ログのほとんどのデータを数か月間保存しながら、一部の保存きかんを30日間にするというのは難しい状況となります。

Snowflakeはデータをマイクロパーティションに保存し、各パーティションには50MB~500MBのデータが保存されます。マイクロパーティションの行は縦欄形式で保存されるため、クエリされた列のみが読み取られます。これは、大量のフィールドがある場合に非常に便利な仕組みです。各マイクロパーティションで範囲インデックス(マイクロパーティション内の各列の最大値と最小値)も維持されます。これを使用して、クエリが検索する必要のあるマイクロパーティションをフィルタリングできるため、スキャンするデータ量を減らすことができます。大量のデータセットに対して目的の性能レベルでクエリを実行するには、これらのインデックスを賢く活用することだと学びました。しかしこのブログではこれについては掘り下げません。このブログで強調したいのは、増え続けるフィールドを使用して優れたフィルターを作成できるという点です。私たちの場合は、それがタイムスタンプフィールドでした。

呼び出し元が外部APIであるか、内部の定期的なジョブであるかにかかわらず、Snowflakeのクエリではすべて仮想ウェアハウスとして割り当てられたリソースを使用します。各仮想ウェアハウスはメモリと処理能力のアロケーションであり、クエリ対象のデータのストレージとは完全に分離されています。これにより、このアロケーションは完全に動的になります。小規模でも、4XL規模でも、簡単に追加の仮想ウェアハウスを割り当てることができます。

集約されたクエリでも、単純な変更でも、すべてのオペレーションにおいて大量のデータをスキャンするには多大なリソースの投資が必要になります。そのことを念頭に読み進めてください。

SingularでのSnowflakeの活用方法

Singularでは、継続的に更新される大量のデータの分析にSnowflakeを使用しています。数十テラバイトにおよぶログを毎日取り込み、絶え間なくファイルをS3バケットにアップロードし、Snowpipeを使用してそれをSnowflakeに取り込み、最後にストリームタスクを使用して長期用のストレージテーブルに挿入します。この取り込みアーキテクチャの詳細については、以前のブログ「イベントウェアハウスのAthenaからSnowflakeへの移行」をご覧ください。

まとめると、私たちのデータは複数のテーブルで長期間保存され、その保持は行を削除することで処理されています。取り込み時にデータを正しくパーティション化することにより、DELETEコマンドでマイクロパーティション全体がドロップされるケースがほとんどとなり、効率が大幅に向上しました。

このどれも、一定期間後に特定の列だけをドロップしなければならない、という問題を解決するものではありません。文字列やJSON列に含まれる部分的な情報をドロップできるようになるわけではありません。では、どのようにしてこの問題を解決したのでしょうか。

データ保持の課題へのアプローチ

まずは、単純なアプローチを試しました。SQLクエリを使用して、1日に1回該当データを修正しましたが、これは実用的な解決策ではありませんでした。Snowflakeでは、1つの行の1つの列の修正には、その行のあるマイクロパーティションの書き換えが必要になります。この場合、データセット全体を何度も書き直すことになります。

2つ目のアプローチは、保持期間の制限されている機密データを、メインテーブルから補助テーブルに分離し、データをクエリする際にJOIN句を使用して取得する方法です。この方法では、簡単なDELETEジョブを設定して、補助データテーブルの保持期間を30日間に制限できましたが、問題は別のデータと並んで文字列内に埋め込まれた機密データの値をどう処理するかという点でした。ただ、一番大きな問題は、機密データ独自の値の数が膨大であることでした。補助テーブルを使用するJOINステートメントによって、クエリの速度が大幅に低下します。

少しの暗号化

そこで思いついた解決策は、機密データの値をはじめから隠すことでした。Snowflakeにアップロードする前にデータを検索し、見つかった機密データの値を暗号化します。暗号化されたデータは暗号化キーがなければ読み取られません。

ただし、大量の短い値に暗号化メソッドを使用すると、動作が非常に遅くなる可能性があります。例えば、数十個のテキストメッセージを解読するには、同じサイズの1つのファイルを解読するよりもはるかに時間がかかります。そのため、暗号化と解読をより速く行える方法を探したところ、暗号化の最も基本的なメソッドに解決策がありました。排他的論理和(XOR)バイナリ演算子です。これを、プレーンな値(隠したいもの)と暗号化キーに適用します。

XORは、2つのビットを組み合わせる演算子です。2つのビットが異なる場合は1、同じである場合は0という結果になります。他のビット演算子のように、ビット単位で同じ長さの2つのバイナリ入力に適用できます。このように使用すると、非常に便利なプロパティを持つシンプルな(つまり高速な)演算子になります。プレーンメッセージPとランダム化されたキーKを取得し、XOR(P、K)を適用すると、Kがないと読み取れない暗号が生成されます。しかし、再度XOR(暗号、K)を適用すると、プレーンPを再度取得できます。キーが一度だけ使用され、その後隠されている限り、暗号化されたデータをキーなしで読み取ることはできません(暗号化する値ごとにキーが一意である必要がある理由については、Cryptosmithによる説明を参照してください。)

図1:簡単かつ高速に基本的な暗号化を行う際に使用されるXOR演算子

次に、値ごとに一意の暗号化キーを見つける必要がありました。それも一定量のメモリのみを使用しながら(つまりJOINを使用せずに)、値と同じまたは値よりも大きいサイズである必要があります。それには、暗号化の構成要素の1つである暗号化ハッシュ関数(CHF)が必要でした。

CHFは非常に便利なツールです。CHFに慣れていない方は是非学習されることをお勧めします。CHFは、任意の長さの文字列を固定サイズの出力にマップする高速の一方向関数です。出力は入力によって確定的に決定されますが、それでも入力の一部から推測するのは困難です。CHFを使用すれば、複数の情報に基づいて暗号化キーを作成でき、データの復号化にはその情報すべてが必要になります。1つでも欠けていると、暗号化キーを再作成できなくなります。

少なくとも上記の暗号化方法を使用する場合、CHFでは暗号化データの長さが制限されます。幸い、私たちは地理情報やネットワーク情報といった短い文字列を暗号化するだけで済みました。

図2のように、30日後に削除される、1日あたりの1つのランダムな値(1)を使用して、一意の値が付いている場合に期限切れとするデータすべてに一意の暗号化キーを作成できました。これをランダムデータジェネレーターの助けを借りて実行しい、ランダム化されたソルト(2)値をデイリー値と共に使用して暗号化用の一意のキーを作成しました。その後ソルトはSnowflakeに書き込まれる実際の結果に、デイリー値の日付と共に暗号化された値にアタッチされました(4)。これにより、クエリで暗号化キーを再作成できるようになりました。

図2:30日後に削除される、1日あたりの1つのランダムな値(1)を使用して、一意の値が付いている場合に期限切れとするデータすべてに一意の暗号化キーを作成。

縦欄式およびイントラ文字列の保持ポリシーの実装

暗号化された値を読み取るには、各クエリで以下が必要です。

  • 特定の値の暗号化に使用されたデイリーキー
  • 隠された値を含む暗号化を見つけて解読するメソッド

1つ目については、Snowflakeのセッション変数を使用します。これにより、GETVARIABLEコマンドを使用してクエリでキーを使用できるようになります。セッション変数は単純で、他のデータベースと同様の内容のため、ここでは詳細は省きます。2つ目については、マスキングポリシーを使用し、特定の列にアクセスする時にすべてのクエリのSQLをオンザフライで変更します。

マスキングポリシーについては例と説明が必要かもしれません。マスキングポリシーは列に定義され、以下のような簡単なクエリを変換します。

を以下に変換

これによって、解読プロセス全体が内部で移動し、すべての機密データがプレーンに保存されているかのように、暗号化に関係なくクエリの作成を続行できます。

要約すると、マスキングポリシーは以下のように定義されます。

このように、このポリシーは列の値を操作する関数によく似ています。これで、選択した列に適用できます。

これで、ログテーブルへのクエリは、変更することなくcustomer_sensitive_info列のデータをシームレスに解読します。

Java UDFの実装

このソリューションの実装に欠かせないもう1つのツールは、JavaScriptユーザー定義関数(Java UDF)です。まずは簡単な例から始めて、より複雑な例を通してこのツールの柔軟性をお見せしたいと思います。

マスキングポリシーを実装する過程で、問題が発生しました。Snowflakeでは、2つのバイナリ入力をXORする方法が提供されていなかったのです。組み込みのSQLメソッドを使用してみましたが、満足できる結果は得られませんでした。しかし、SnowflakeではSnowflakeエンジンでUDFを実行でき、UDFメカニズムには、迅速でシンプルなソリューションが備わっています。js_binxorを見てみましょう。

この簡単な例からも、UDFの有用性がわかると思います。ただし、UDFにはグローバルステートがあるため、この例よりもはるかに多くの可能性を秘めており、性能を犠牲にすることなく、高価な初期化ロジックを使用できます。この例では、デフォルトでUDF環境にインポートされていないJavaScriptライブラリを使用しました。

このように、実行時の効率を損なうことなく、SnowflakeのJavaScript依存関係に含まれていないライブラリを「インポート」できます。必要に応じて、ヘルパーメソッドを定義したり、通常であればこの状況では躊躇するような他のセットアップを実行したりすることも可能です。これはUDFの真の多様性を表しており、皆様にとっても何かと役立つ場面があると思います。

一部のデータの保持:学習内容
  • 問題解決のために、1つの列の値レベルで処理を行うことができます。大掛かりなJOINまたはDELETEに頼る前に検討してください。
  • 性能の影響を受けやすい環境で暗号化を使用する場合は、関数の背後にある理論を理解することが重要です。標準的な既存のソリューションは最適ではない可能性があります(性能の微調整時にも言えることです)。
  • プライバシーやセキュリティが懸念される場合は、実装に特に注意してください。ほとんどの場合、暗号化関数は順守する必要のあるパラメーターと用途を推測するため、脆弱性を作成するリスクを冒すことになります。
  • Snowflakeでは恐れずに独自のメソッドを作りだしてください。開発を大幅にスピードアップできます。