Fluentd + MySQL でログ保存 & 検索システムを考えてみた話

食べログ DevOps チームの @e-ronny と申します。
この記事は Advent Calendar の15日目の投稿です。

# 概要

以下のようなシステムの設計を検討する機会があったので、その過程をまとめてみます。

- アプリケーションから特定のタイミングで任意の key - value の形式でログを記録する
 - 型は特に考えず文字列としてのみ扱うのでよい
- ログを記録する際にはそれを特定するためにログの種別と ID を指定する
- 記録したログは種別と ID を指定すると `O(1)` で検索できる
- ログを記録してから検索できるようになるまでは数秒程度の遅延は許容される
- ログを記録する実体はアプリケーション本体の DB とは分ける
 - 全文検索するわけでもないのでディスクがとっても無駄になるから
- 各環境(開発・本番等)で同様な構成で構築するが別環境のログが混入することがない

# 実現方法の検討

具体的な実現方法としては以下のように考えました。

## 全体的な構成

- ログ収集といえば fluentd かな
- [可用性を考慮した構成](https://docs.fluentd.org/v1.0/articles/high-availability) のためには forwarder(AP) & aggregator 構成かな
- ストレージの実体を後々差し替えることも考慮して aggregator とストレージの実体(以下 `storage` と表記)は別サーバにしよう
- サーバ構築用 ansible playbook を用意しよう
 - 環境ごとの差異は変数で吸収しよう

## forwarder の構成

- ログの書き出し先パスは in_tail の対象パスに合わせればいいけど、そのままだとディスクを無尽蔵に消費するので logrotate を設定しておこう
 - ただし logrotate のタイミングでのログを拾いそびれて欠損しないように注意が必要
- key - value 部分は JSON 形式にするのでログのフォーマットは [LTSV](https://docs.fluentd.org/v1.0/articles/parser_ltsv) にしよう
 - ログのフォーマットも JSON にするとネストして可読性が下がるので
- ログの欠損を防ぐため At most once ではなく At least once で aggregator に送ろう ([参照](https://docs.fluentd.org/v1.0/articles/high-availability#message-delivery-semantics))

## aggregator の構成

- 基本的には [in_forward](https://docs.fluentd.org/v1.0/articles/in_forward) / [out_forward](https://docs.fluentd.org/v1.0/articles/out_forward) で forwarder と aggregator の仲介をすればいいけど、 [out_copy](https://docs.fluentd.org/v1.0/articles/out_copy) で [out_file](https://docs.fluentd.org/v1.0/articles/out_file) もしておくとログ調査の際に全 forwarder を見に行かなくてもここに集約されているので楽そう
  - ファイルのフォーマットは in_tail 元に合わせて [LTSV](https://docs.fluentd.org/v1.0/articles/formatter_ltsv) にしよう
- 発生元の forwarder も記録しておくと障害対応等で役立ちそう
 - storage には記録する必要はない

## storage の構成

- ストレージの実体としては手っ取り早く使い慣れた MySQL にしよう
 - 将来的には [MongoDB](https://docs.fluentd.org/v1.0/articles/out_mongo) や [s3](https://docs.fluentd.org/v1.0/articles/out_s3) に移行するかも 
- key - value を何も考えずに [JSON 型](https://dev.mysql.com/doc/refman/5.7/en/json.html) として記録するため MySQL のバージョンは5.7以上を使おう
- ログの種別はテーブル名として、ID はそのテーブルの primary key として表現しよう
 - ログの種別をカラムとして表現し、ID との複合 primary key にする手もあるけど種別やログ総量が増えた場合の性能劣化が怖いし [innodb_file_per_table](https://dev.mysql.com/doc/refman/5.7/en/innodb-multiple-tablespaces.html) にしておけばいらなくなったログ種別のゴミ掃除が楽そう
 - ログの種別ごとにテーブルにする代わりに key ごとにカラムを分ける手もあるけどテーブルごとにスキーマが変わって面倒なので json カラムとして単純に扱おう
     - 各テーブルのスキーマが全く同じになるので取り扱いが楽
- MySQL 用の output plugin は [組み込み](https://docs.fluentd.org/v1.0/articles/output-plugin-overview#list-of-output-plugins) ではないので [プラグイン一覧](https://www.fluentd.org/plugins/all#input-output) で見つけた [out_mysql_bulk](https://github.com/tagomoris/fluent-plugin-mysql) を使おう
  - 同 gem 内の out_mysql は [deprecated](https://github.com/tagomoris/fluent-plugin-mysql/blob/5db3057e1134c9c218e08a4ecd707e64b6c484fc/lib/fluent/plugin/out_mysql.rb#L42)
- 更新クエリはこのホスト内で out_mysql_bulk からのみ発生するので更新用ユーザの接続元は localhost だけに絞れそう


# サーバ構成

上記の内容をまとめると、サーバ構成は以下のようになります。

| ロール | input プラグイン | output プラグイン | fluentd 以外のミドルウェア等 |
| --- | --- | --- | --- |
| fowarder(AP) | [in_tail](https://docs.fluentd.org/v1.0/articles/in_tail)(with [parser_ltsv](https://docs.fluentd.org/v1.0/articles/parser_ltsv)) | [out_forward](https://docs.fluentd.org/v1.0/articles/out_forward) | アプリケーション本体 |
| aggregator | [in_forward](https://docs.fluentd.org/v1.0/articles/in_forward) |  [out_copy](https://docs.fluentd.org/v1.0/articles/out_copy) <br> [out_file](https://docs.fluentd.org/v1.0/articles/out_file)(with [formatter_ltsv](https://docs.fluentd.org/v1.0/articles/formatter_ltsv)) <br> [out_forward](https://docs.fluentd.org/v1.0/articles/out_forward) | |
| storage | [in_forward](https://docs.fluentd.org/v1.0/articles/in_forward) | [out_mysql_bulk](https://github.com/tagomoris/fluent-plugin-mysql) | MySQL |


# 各ロールのざっくりした説明

それぞれのロールにおいて、設定の一部を記載してその意図やはまりかけた点などを説明します。
なお、以下の説明は省略します。

- forwarder
 - アプリケーション本体が in_tail 対象のログを出力するところ
- aggregator
 - 集約したログの取り扱い(バックアップ・障害発生時の storage への再送等)
- storage
 - 個別のテーブルを作成するところ
 - サーバの冗長化
 - 参照クエリを投げるところ(対象のテーブルに対して primary key で検索するだけ・負荷が気になるなら replica を立ててそちらに向けてもよい)
- その他
 - 外部プラグインを導入する際の設定([こちら](https://docs.fluentd.org/v1.0/articles/plugin-management) をご参照ください)
 - [buffer](https://docs.fluentd.org/v1.0/articles/buffer-plugin-overview) まわり
 - 監視・モニタリング用設定([こんなの](https://github.com/tagomoris/fluent-plugin-ping-message) とか [こんなの](https://docs.fluentd.org/v1.0/articles/in_monitor_agent) とかあります)

## forwarder

### in_tail 設定

```xml:roles/forwarder/templates/td-agent.conf.j2
<source>
  @type tail

  tag data.{{ storage_env }}
  path {{ in_tail_log_paths }}
  read_from_head true

  <parse>
    @type ltsv
  </parse>
</source>
```

- `storage_env` を環境ごとに差し替えることでタグを環境ごとに分け、別の環境から誤って aggregator にイベントを送っても受け付けないようにしています。
- 環境によってログの出力パスが異なるのでそこも変数にしており、環境によっては対象のファイルが複数になる(パスに `*` を含む)ので [read_from_head](https://docs.fluentd.org/v1.0/articles/in_tail#read_from_head) を指定しています。

### out_forward 設定

```xml:roles/forwarder/templates/td-agent.conf.j2
<match data.{{ storage_env }}>
  @type forward

  require_ack_response true

{% for host in groups.aggregator %}
  <server>
    host {{ hostvars[host]['inventory_hostname'] }}
  </server>
{% endfor %}
<match>
```

- 対象のタグを絞り込むことで対応する環境のイベントだけ拾うようにしています。
- aggregator のホストも環境ごとの inventory に合わせて差し替えられるようにしています。
- At least once にするために [require_ack_response](https://docs.fluentd.org/v1.0/articles/out_forward#require_ack_response) を指定しています。

### logrotate 設定

```roles/forwarder/templates/logrotate_forwarder.j2
{{ in_tail_log_paths }} {
  daily
  rotate 7
  missingok
  create
  compress
  delaycompress
}
```

- 対象のログファイルの指定は in_tail での指定と同じものですが、 logrotate の仕様上記載できるような簡単な記述にしています。(`/path/to/in_tail/*/*.log` といった感じ)
- [in_tail の説明](https://docs.fluentd.org/v1.0/articles/in_tail#logrotate-setting) にある通り、ローテート後のファイルを検出できるように `create` を指定しています。


## aggregator

### in_forward 設定

```xml:roles/aggregator/templates/td-agent.conf.j2
<source>
  @type forward

  source_hostname_key forwarder_host
</source>
```

- [source_hostname_key](https://docs.fluentd.org/v1.0/articles/in_forward#source_hostname_key) を指定することで forwarder のホストを記録するようにしています。

当初はこのオプションに気付かず forwarder 側の [filter_record_transformer](https://docs.fluentd.org/v1.0/articles/filter_record_transformer) でやろうとしてました・・・

```xml:roles/forwarder/templates/td-agent.conf_old.j2
<filter>
  @type record_transformer

  <record>
    forwarder_host "#{Socket.gethostname}"
  </record>
</filter>
```

### out_forward 設定

```xml:roles/aggregator/templates/td-agent.conf.j2
<match data.{{ storage_env }}>
  @type copy

  <store>
    @type forward

    require_ack_response true

    <server>
      host {{ storage_host }}
    </server>
  </store>

  <store>
    @type file

    path /path/to/backup
    append true

    <format>
      @type ltsv
    </format>
  </store>
```

- forwarder の out_forward でも制限していますが、forward 先ホスト間違いがあっても out_forward しないようにこちらでもタグによって対応する環境のみに制限しています。
- out_copy でバックアップ用のファイルにも書き出しています。

## storage

### out_mysql_bulk 設定

```xml:roles/storage/templates/td-agent.conf.j2
<match data.{{ storage_env }}>
  @type mysql_bulk

  table                    data_${category}
  column_names             id, data
  json_key_names           data
  on_duplicate_key_update  true
  on_duplicate_update_keys data

  <buffer category>
    @type file
    ...
  </buffer>
</match>
```

- タグによる制限は forwarder/aggregator の out_forward と同様です。
- テーブル名はログ中の category の値から動的に決定します。

chunk の key を指定しないと placeholder として使用できない点にはまりました・・・([参照](https://docs.fluentd.org/v1.0/articles/buffer-section#placeholders))

- テーブルのスキーマはログの種別によらず固定なので `column_names` はべた書きしています。
- ログは JSON 形式でそのままカラムに記録するため当該カラムを [json_key_names](https://github.com/tagomoris/fluent-plugin-mysql/blob/5db3057e1134c9c218e08a4ecd707e64b6c484fc/lib/fluent/plugin/out_mysql_bulk.rb#L196-L198) に指定しています。
- At least once による多重レコードの発生への対策として `on_duplicate_key_update` と `on_duplicate_update_keys` を指定しています。([参照](https://github.com/tagomoris/fluent-plugin-mysql/blob/5db3057e1134c9c218e08a4ecd707e64b6c484fc/lib/fluent/plugin/out_mysql_bulk.rb#L87-L98))


# まとめ

(大枠さえわかってしまえば) td-agent.conf の記述だけで結構なことができる fluentd はさすがだなと思いました。
実装も面白そうなので機会があれば深く読んでみたいと思います。

明日は @tsukasa_oishi さんによる「RubyでCPUコアをフル活用してみた」です。