実践セキュリティ監視基盤構築(16): ログ変換の実装要点
この記事はアドベントカレンダー実践セキュリティ監視基盤構築の16日目です。
今回はデータウェアハウスにおけるETL(Extract, Transform, Load)のうち、ログの変換(Transform)について紹介します。一般的なデータ基盤におけるTransformではデータクレンジングや集約が主な処理ですが、セキュリティ監視基盤では特にログのスキーマ変換が重要です。
ログの変換の必要性
セキュリティ監視基盤で利用するログデータは、システム内部で発生した、またはシステムが観測したイベントの記録です。そのため、データ取得時にノイズは基本的に発生せず[1]、そのままデータベースに取り込んでも問題ありません。
セキュリティ監視基盤でのTransformで最も重要な処理は、ログのスキーマ変換です。ログデータは外部から提供されることが多く、そのスキーマは提供元の特性に合わせて決定されます。このため、主に2つの理由からログのスキーマ変換を可能にする仕組みを構築する必要があります。
(1) 書込先のデータベースに合わせたスキーマに修正する
ログデータをデータウェアハウスに取り込む際には、書込先のデータベースに合わせたスキーマに修正する必要があります。近年のデータウェアハウス向けソフトウェア・サービスは様々な型に対応していますが、全てのログをそのまま取り込めるわけではありません。以下のような修正が必要になることがあります。
タイムスタンプ
時刻はログによって様々な形式で表現されます。Unix時間として数値で表現されることや、文字列で表現されることもあります。数値にしても秒、ミリ秒、ナノ秒のいずれで表現されるかはログによって異なりますし、文字列はさらに多様です。また、タイムゾーンの情報が含まれているかどうかも異なります。これらをデータベースに取り込む際には、データベースの型に合わせて変換する必要があります。
また、ログを扱う際には「ログの発生時刻」と「ログの書込時刻」が全く異なることに注意が必要です。一部のデータベースではログの書込時間をそのまま利用することがありますが、ログがシステム上で発生した時刻と実際にデータベースに書き込まれるまでには必ず遅延があります。リアルタイムにログを取り込む設計でも数秒、遅延を許容する設計なら数分、障害が発生して後からログを取り込む場合は数時間から数日のズレが生じます。このような遅延を考慮して、ログの発生時刻を正しく取り込むための処理が必要です。
ネスト
現代のログデータは、ほとんどがJSONのような構造化データで提供されます。これはログデータの構造的な意味を表現しているため、なるべくそのまま取り込むことが望ましいです。セキュリティ監視基盤に用いるデータベースは構造化データに対応しているものも多いですが、そうでない場合はフラットな構造に変換する必要があります。
フラット化する方法はいくつかありますが、区切り文字を使ってフィールド名を連結する方法が一般的です。例えば以下のようになります。
変換前
{
"user": {
"id": 123,
"name": "Alice"
},
"action": "login"
}
変換後
{
"user.id": 123,
"user.name": "Alice",
"action": "login"
}
map形式の場合はこの方法で問題ありませんが、ネスト構造の中に配列型が含まれる場合は注意が必要です。配列型はインデックスをフィールド名にする方法がありますが、構造をそのまま再現しているわけではありません。これは取り込み先のデータベースがこの差をうまく吸収してくれるかを確認しながら検討する必要があります。
フィールド名
ログデータのフィールド名は提供元が決定します。例えばJSON形式で提供されるログデータの場合、フィールド名はUnicodeを含む任意の文字列が指定できます。しかし多くのデータベースではフィールド名に利用可能な文字の制限があります。また、フィールド名によっては予約語として利用できないものもあります。これらの制約に合わせてフィールド名を変更する必要があります。
(2) 提供されるログの不安定なスキーマに対応する
外部システムから提供されるログは提供元によってスキーマが決定されますが、そのスキーマは不安定であることが多いです。外部サービスなどによる監査ログの提供は、ログの保全(利用者側へのバックアップ)と検索可能性のためであり、あくまで補助的なものという扱いが多いと考えられます。そのため、ログが提供されている場合でも、そのスキーマや仕様についてドキュメント化されていないケースが多々あります。このようなケースではスキーマの不整合や変更が発生することがあり、これを「不安定」と表現しています。不安定なスキーマに対応するためには、ログの変換処理において柔軟な対応が必要です。
例えば、BigQueryではJSONデータをそのまま格納する機能がありますが、クエリする際はJSONをパースした結果を利用するため、統一されたフィールドとして扱う必要があります。同じフィールドで異なる型が混在すると、クエリの実行に失敗するため、変換のタイミングで対処するのが妥当です。
具体的には、以下のようなケースが実際に起こります。
同じフィールド名で異なる型が混在する
JSONなどの構造データは柔軟に扱える反面、データベースのように事前に決まったスキーマのデータだけが投入できるわけではないため、ログごとに異なるスキーマになることがあります。例えば以下のように型が異なる場合です。
// log1
{
"field1": "abc"
}
// log2
{
"field1": 123
}
BigQueryのようなデータベースはあらかじめフィールドと型を定義しておく必要があります。この場合、log2をそのまま投入することはできません。このような場合は文字列に統一することで対応できますが、それ以外にもデータ構造自体が違うケースもあります。
// log1
{
"field1": {
"sub1": 123
}
}
// log2
{
"field1": [
{
"sub1": 123
},
{
"sub1": 456
}
]
}
上記の例も実際の商用サービスで発生したスキーマ衝突の例です。このような場合には、log1を配列化する、あるいはlog2をオブジェクト化するなどの処理をしないと、データベースに投入することができません。
同じフィールド名で異なるフォーマットが混在する
異なる型と似たような話ですが、型(数値、文字列など)は同じでも値の解釈が異なるケースもあります。例えば以下のようなケースです。
- 数値の意味が異なる: 数値が「秒数」として解釈される場合と「ミリ秒数」として解釈される場合があります。この場合は数値をそのまま投入してしまうと後から揃えるのが面倒になります。
- 文字列のレイアウトが異なる: 日付を表す文字列が「YYYY-MM-DD」と「MM/DD/YYYY」の2つのフォーマットで提供される場合があります。これもデータベースに投入する際には統一する必要があります。
このような変換をする場合は単にそのフィールドの型を見るだけでなく、他のフィールドを参照しながら変換のロジックを組む必要があります。(例えば field2
に A
という値が入っている場合は field1
が 秒数
として解釈される、 B
が入っている場合は ミリ秒数
として解釈される、など)そのため、ログ変換処理は単純な型変換だけではない点に注意が必要です。
スキーマが変更される
これまで挙げた例は同じ期間に取得したログにスキーマの衝突などが含まれる例ですが、提供元の都合でログのスキーマが変更される場合もあります。これは既存のスキーマに対して新しいフィールドが追加されるだけの後方互換性のある変更の場合もあれば、全く異なるスキーマになる破壊的変更の場合もあります。変更の検知は提供元のお知らせ、ドキュメントの変更などをチェックする、あるいは実地でログを取得してスキーマを確認するなどの方法があります。
変更が破壊的かつ大規模であれば異なるテーブルを用意するのが妥当ですが、いくつかのフィールドの値を変更するだけであれば、もとからあるテーブルに追加することもできます。その場合は型やフォーマットの差分があるときと同様に、変換処理ができればよいでしょう。
ログ変換の実装
ここまで説明した通り、セキュリティ監視基盤におけるログ変換の手続き自体は非常にシンプルで、個々の処理はスクリプト言語(Python、Ruby、Node.jsなど)を使えば1〜5行程度で表現できる簡単なロジックです。しかし、セキュリティ監視基盤のパイプラインとして実装する場合、以下の処理も必要となります。
- どのログデータを処理すればよいかを判断する処理(今回はPub/Sub経由で受け取った情報を利用する)
- データレイクとして保存されているオリジナルのログデータを取得する処理
- どのログに対してどの変換処理を適用するかを管理する処理
- 変換したログをデータウェアハウスに書き込む処理
これらの処理をロジックごとに実装するのは冗長なので、ログ変換処理の方を抽象化して管理する仕組みを構築するのが適切でしょう。
ログ変換ロジックの実装方法
ログ変換ロジックを実装する方法はいくつかありますが、ここまでの説明の通りログ変換ロジック自体は非常にシンプルであるものの、バリエーションは多岐にわたります。そのため、柔軟かつ自由にロジックを実装できるような仕組みを構築することが重要です。
アプローチ1: プログラミング言語を利用する
1つ目のアプローチは、一般的なプログラミング言語を利用してログ変換ロジックを実装する方法です。例えばNode.js、Python、Rubyのような言語はプラグインのような形式で後からロジックを追加するのが容易な仕組みになっています。この場合、ログ変換ロジックをプラグインとして実装し、それを管理する仕組みを構築することで自由かつ柔軟にログ変換ができるようになります。
このアプローチのデメリットは、プログラミング言語の自由度が高すぎる点です。一般的なプログラミング言語はロジックを実装するだけでなく、様々な外部入出力の機能を持ちます。例えばファイルの読み書き、ネットワーク通信、外部APIの利用などが可能です。これらの機能を適切に制限した上で実装・運用ができればいいのですが、自由に使えるようにしてしまうと機能が肥大化・複雑化しやすくなり、注意が必要です。
アプローチ2: DSLを実装する
2つ目のアプローチは、DSL(Domain Specific Language)を実装する方法です。DSLは特定のドメインに特化した構造化言語やデータを指します。完全に独自仕様の言語・文法を実装することもできますし、既存の構造データ(JSON、YAML、Jsonnet、CUEなど)を利用することもできます。
DSLによって変換のロジックを記述するメリットは、プログラミング言語よりも制約が強いため、純粋にロジックだけしか表現できなくなる点です。これによって余計な機能を実装することが難しくなり、変換処理の実装に集中できます。記述もシンプルに表現できることで、メンテナンスなどのコストも低くなります。
一方、DSLによる実装の問題は機能をある程度まで揃えるための実装コストが重い点です。単純に「数値を文字列に変換」というような指定は難しくありませんが、先述した通りスキーマの変換ではいくつかの条件に基づいて実施されることがあります。例えば「このフィールドが〜であった場合のみ変換を行う」といった複雑な条件を表現するためには、そのための機能をDSLに実装する必要があります。さらには文字列の結合や分割、数値の演算、日付の変換といった基本的な処理も必要になるケースがあり、これらに対応するためには高い実装コストが必要になります。
テスト可能性
変換ロジックの実装においてもう一つ重要なポイントは、テスト可能性です。ログ変換ロジックはデータの変換をする際に、条件による分岐などが発生します。このような分岐が正しく機能しているか、また変換された結果が期待した出力になっているかを確認することが必要です。
一度変換ロジックを実装したら二度と変更しない、ということであれば実装者が目視で確認すればいいのですが、実際にはログのスキーマが提供元の都合で変更されたり、新しいログを取り込むなどによって変更が必要になる場面はしばしばあります。そのような変更を加えたときに新しいロジックが正しく動作するかだけでなく、既存のログの変換ロジックに対しても影響がないかを確認するためには自動テストできるような仕組みを構築することが重要です。
テスト可能性を考慮した場合、実装方法としては「アプローチ1: プログラミング言語を利用する」が有利です。プログラミング言語を利用する場合は、一般的なテストフレームワークを利用してテストを実装することができます。一方で「アプローチ2: DSLを実装する」の場合は、DSL自体のテストフレームワークを実装する必要があります。これはDSLの実装コストが高くなる要因の一つです。このテスト可能性をどのように実現するかを考慮しながら、ログ変換ロジックの実装方法を選択するとよいでしょう。
まとめ
ログ変換はセキュリティ監視基盤において意外と重要な処理です。ログデータは外部から提供されることが多く、そのスキーマは提供元の都合によって決定・変更されます。これを自分たちの基盤で使いやすいように取り込むためには変換処理が必要です。個々の変換処理は難しくありませんが、それをパイプライン上でどのように実装・管理していくかはやや難しい問題であり、慎重に検討したほうがよいでしょう。
-
例外として、設定ミスやシステムのバグによって不要なデータが発生することがあります。 ↩︎
Discussion