Slide 1

Slide 1 text

Kafka Streams で作る10 万rps を支えるイベント駆動 マイクロサービス CNDT 2023 Pre Repro 株式会社 橋立友宏 (@joker1007)

Slide 2

Slide 2 text

自己紹介 橋立友宏 (@joker1007) Repro inc. チーフアーキテクト 人生における大事なことは ジョジョから学んだ 日本酒とクラフトビールが好き Asakusa.rb メンバー

Slide 3

Slide 3 text

Repro のプロダクト Repro はマーケティングソリューションを提供する会社で、 マーケティングオートメーションのための同名のサービスを提供している。 つまり、デジタルマーケティングを支援するツールが主なプロダクト。

Slide 4

Slide 4 text

マーケティングの基本要素 あくまで私見ですが、マーケティングとは 1. 適切な顧客および顧客の集団に 2. 適切なタイミングで 3. 適切なコンテンツ or クリエイティブを提供する

Slide 5

Slide 5 text

デジタルマーケティングに求められるもの システムに求められる特性に言い換えると 1. エンドユーザーの状況を素早く反映できる 行動ログやプロフィール情報、サービス利用ステータスなど 2. 任意のタイミングでユーザーの集合を抽出できる ( ユーザーセグメンテーション) 3. 柔軟な配信チャネルに対応している これらを大量のユーザー規模で提供する。 弊社では延べ数で数億ユーザーを越える。 スケーラビリティとサービス追加の容易さが重要。

Slide 6

Slide 6 text

ストリームベースへの転換 元々は、fluentd でS3 やBigQuery にデータを転送して一定間隔のバッチでBigQuery や Presto のクエリを実行し、ユーザーセグメントを定期更新していた。 しかし、このままでは一定以上の迅速さでユーザーの情報を反映できない。 スケーラビリティと情報反映のレイテンシ短縮のためにストリームベースのアーキテ クチャに転換。 データパイプラインの基盤としてKafka を採用し、システムを組み直した。

Slide 7

Slide 7 text

Kafka とは 分散ストリームバッファを提供するミドルウェア。 キューとは異なり、一定期間もしくは永続的にメッセージを保持するストレージとし ての側面もある。 クライアントがどこまでメッセージを処理したかは、クライアントごとにconsumer group という単位で管理・記録し、メッセージ自体には影響を与えない。

Slide 8

Slide 8 text

現在のアーキテクチャの簡易的な図

Slide 9

Slide 9 text

Fire and Forget による疎結合化 メッセージバスでサービス間を連携する時に大事なことは、サービス同士がお互いの 存在すら知る必要が無い、という状態を維持すること。 イベントやメッセージを送信したら、後は受け取る側の責任で発信者は感知しない。 この形をFire and Forget と表現する。 以下の様なメリットがある。 サービス間の依存関係を無くし、特定箇所が全体の可用性に与える影響を小さく できる あるサービスの応答性が、他のサービスに影響を与えない 一つ一つのサービスは自分のやることだけに関心を持てばいいので、小さく認知 負荷の低いサービスを構築しやすい

Slide 10

Slide 10 text

サービス追加の容易さ Kafka の特性により、発行済みのイベントやメッセージは一定期間Kafka のtopic 上に維 持される。また、キューやファンアウト式のメッセージパッシングと異なり、発信者 やメッセージバス自体が各サービスのことを事前に知らなくて良い。結果として、 同じイベントを元にして駆動できるサービスであれば、後から容易に追加可能 必要なデータの形状が異なるなら加工用のパイプラインを追加することもそこま で難しくない といった利点があった。 Repro のプロダクトにおいては、配信チャネルの追加を容易にし、それに伴う認知負 荷上昇を抑える狙いがある。

Slide 11

Slide 11 text

スキーマによるサービス間の連携 疎結合化を目指すとはいえ、サービスを協調させるためには規約が必要。 そのためには、スキーマフルなデータ構造が必須。 弊社では現状Avro フォーマットを利用している。 また、スキーマの集中管理を行うためのスキーマレジストリを活用する。 各サービスにスキーマ情報を持たせなくて良くなる。 スキーマの変更パターンから後方互換性や前方互換性について検証した上で安全 にスキーマを変更できる。 see. https://docs.confluent.io/platform/current/schema-registry/index.html

Slide 12

Slide 12 text

イベント駆動マイクロサービスのトレードオフ メリットは実感しているが、もちろんトレードオフとしてマイナス面もある。 Kafka の可用性とスケーラビリティに大きく依存している クラスタを簡単に止められない Kafka 自体はスケーラブルだがtopic のパーティション数を後から変えるのが 困難 エラーハンドリングが難しい ローカル開発環境で全体を動かすのが難しい 同期的に別サービスの終了を待ち受ける必要があると複雑さが激増する 根本的に新しい機能を追加する場合は、パイプラインの広い範囲で修正が必要な 場合もある マイナス面を減らすための工夫が継続的な課題。

Slide 13

Slide 13 text

例: 同期的なワークフロー制御 既存のマイクロサービスを活用しつつ、バッチ取り込みなどの同期的に待ち受けが必 要な処理を実装したいケースがあった。 弊社ではAWS を利用しているので、Step Function をワークフローのメディエーターと して利用した。 結果イベントのpolling とタイムアウトを組み合わせ、エラーハンドリングと通知は Step Function でコントロールする。 ワンタイムで必要になる処理はFargate やLambda を使うことでインフラ管理コストを 削減している。

Slide 14

Slide 14 text

例: ローカル開発環境の難しさへの対策 Kafka と多数のサービスで全体が構成されているため、ローカルの開発環境で全体を動 かすことが難しくなる。 開発用のステージング環境には全てのコンポーネントが揃っているため、AWS のVPC に対してVPN で透過的に接続可能にし、ローカルで修正中のコンポーネントを簡単に 差し込める様にした。 全てのコンポーネントには対応できないが、Consumer が主体となるコンポーネント の検証が容易になった。 ( ローカルの開発環境は、Consumer Group の所属ノードの一つになる)

Slide 15

Slide 15 text

ストリームプロセッシングの詳細 ここからは、実際にストリームアプリケーションを書くことに焦点を当てる。

Slide 16

Slide 16 text

Kafka Streams 概要 Java 向けのストリームプロセッサを書くためのフレームワーク。 Apache Kafka プロジェクトの中でメンテされており、Kafka Broker 以外に追加で必 要なものが無いのが特徴。 DSL とローレベルなProcessor API を組み合わせて、ストリームプロセッサが書ける。 基本的な動きとしては、Kafka のtopic からデータを取得し、レコード単位で加工した り集計処理を行なって、結果を再度Kafka のtopic に書き出すという動作を組み合わせ て処理を組み上げていく。

Slide 17

Slide 17 text

ストリームアプリケー ションのTopology Kafka Streams ではアプリケーション 内の処理一つ一つをノードとした DAG としてアプリケーションを表現 する。 この処理グラフをTopology と呼ぶ。

Slide 18

Slide 18 text

ストリームアプリケーション開発の実践 Kafka Streams の細かい解説をすると時間が足りないので、 今回は実践的な開発に役立つ構成要素や考え方を中心に話をする。

Slide 19

Slide 19 text

ストリームアプリケーションを書く上で大事なこと 大量のデータを1 件単位で処理することになるので、とにかく処理のレイテンシに気を 配る必要がある。 ネットワーク通信は可能な限り避けるべき。 処理内容に依るが1 レコード処理するのに1ms は遅過ぎる。 ノードを分けて分散処理できるとはいえ、処理スループットに直結する。 処理レイテンシはしっかりモニタリングして気を配ることが大事。

Slide 20

Slide 20 text

re-partition の回避 Kafka Streams で状態を利用した処理、つまりあるキーでグルーピングして集計した り、レコードを結合してデータエンリッチを行いたい場合、同一のパーティションに レコードが届いている必要がある。 もし、これが異なるキーでいくつも必要になると、その度にキーを振り直して再度re- partition topic にデータを送り直す必要がある。 DSL ではこれを自動で行ってくれる機能があるが、キーによるパーティションを意識せ ずに多用すると、ネットワーク負荷とストレージ消費量の増大、レイテンシの増加に よりパフォーマンスの低下に繋がる。 つまり、パーティションキーの設計が重要。

Slide 21

Slide 21 text

StateStore ストリームアプリケーションにおいて集計を行うためには、以前のレコードの処理結 果の蓄積( 状態) を保持しておく必要がある。 Redis などの外部ストアに蓄積することは可能だが、前述した様にネットワーク通信の オーバーヘッドはストリームアプリケーションにおいて致命的になる。 Kafka Streams ではStateStore という仕組みで各ノードのローカルなストアに状態を 保持する。 実態はバイト順でソートされたキーバリューストアで、in-memory ストアとRocksDB をバックエンドにしたpersistent ストアがある。 DSL によって提供されるcount 処理やレコード同士のjoin の仕組みの裏側もStateStore で実装されている。

Slide 22

Slide 22 text

StateStore のイメージ図

Slide 23

Slide 23 text

Processor API Processor API というローレベルのAPI を利用することでStateStore を直接操作するこ とができる。 任意のデータをStateStore に書き込むことができるし、バイト順にソートされること を利用してRange 探索を行うこともできる。 DSL では利用できない1:N のjoin を実現したり、レコードキーと違う値をキーにして値 を書き込むことや、レコードごとに異なるタイムウインドウで集計処理を実装するこ とも可能。 また、通常のJava のコードとして表現できることは実現できるので、Processor API を 処理の終端として利用し、外部のデータストアに書き込む処理を行ったりもする。 例えば、集計後や加工後のデータをCassandra に書き込んだりできる。

Slide 24

Slide 24 text

Processor API の簡単なサンプル public class WordCountProcessor implements Processor { private KeyValueStore kvStore; @Override public void init(final ProcessorContext context) { kvStore = context.getStateStore("Counts"); } @Override public void process(final Record record) { final String[] words = record.value().toLowerCase(Locale.getDefault()).split("\\W+"); for (final String word : words) { final Integer oldValue = kvStore.get(word); if (oldValue == null) { kvStore.put(word, 1); } else { kvStore.put(word, oldValue + 1); } } } }

Slide 25

Slide 25 text

1:N のjoin の実装例とか載せたかったんですが、 スライドで表現するには長くなってしまうので、 気になる方は懇親時間に質問していただければと思います。

Slide 26

Slide 26 text

保持しているデータの永続化 各ノードのローカルにデータを持つなら、ノードやディスクが壊れた時はどうするの かという疑問が出てくる。 persistent なStateStore はデフォルトでKafka のtopic と関連付けられており、 StateStore に書かれたものは一定間隔でtopic にflush される。 Kafka のtopic に書き込まれてしまえば、ブローカーのレプリケーションで耐久性が担 保される。 もしノードが壊れた場合は、別ノードに処理が移り、担当ノードはKafka topic から データ取得しローカルのStateStore を自動的に復元する。

Slide 27

Slide 27 text

レストア処理のイメージ

Slide 28

Slide 28 text

レストア処理のイメージ

Slide 29

Slide 29 text

レストア処理のイメージ

Slide 30

Slide 30 text

RocksDB のパフォーマンスチューニング 大体どんなDB でも同じだが、処理量が大きくなるとメモリの割り当て量を増やすこと が重要になる。 RocksDB はLSM ツリーを基盤にしたKVS である。 memtable というメモリ上のテーブルにデータを書いて、一定期間でディスクにflush する。 memtable へのメモリ割り当てを増やしたりスロット数を調整して書き込みパフォー マンスをチューニングし、Block キャッシュに使えるメモリを増やして読み込みパ フォーマンスのチューニングを行う。 特にKafka Streams ではデフォルトの割り当てはかなり控え目になっており、処理が多 くなるとディスクに負荷がかかりがち。

Slide 31

Slide 31 text

RockdDB のメモリ割り当ての設定例 private static final long TOTAL_OFF_HEAP_MEMORY = 14L * 1024 * 1024 * 1024; private static final long TOTAL_MEMTABLE_MEMORY = 2L * 1024 * 1024 * 1024; private static final org.rocksdb.Cache cache = new org.rocksdb.LRUCache(TOTAL_OFF_HEAP_MEMORY, -1, false, 0.1); private static final org.rocksdb.WriteBufferManager writeBufferManager = new org.rocksdb.WriteBufferManager(TOTAL_MEMTABLE_MEMORY, cache); private static final long MEM_TABLE_SIZE = 180 * 1024L * 1024L; @Override public void setConfig( final String storeName, final Options options, final Map configs) { BlockBasedTableConfig tableConfig = (BlockBasedTableConfig) options.tableFormatConfig(); tableConfig.setBlockCache(cache); tableConfig.setCacheIndexAndFilterBlocks(true); options.setWriteBufferManager(writeBufferManager); options.setWriteBufferSize(getMemtableSize()); options.setMaxWriteBufferNumber(4); options.setMinWriteBufferNumberToMerge(2); options.setTableFormatConfig(tableConfig); options.setTargetFileSizeBase(256L * 1024 * 1024); options.setLevel0FileNumCompactionTrigger(10); }

Slide 32

Slide 32 text

AWS におけるノード選択 前述した様にノードやディスク破壊に対してはKafka Broker からのレストアが可能な ので、AWS でKafka Streams を運用する場合は、高速なエフェメラルストレージと相性 が良い。 例えば、i4i シリーズやr7gd などの高速なNVMe ストレージが装備されているインスタ ンスだ。 これらのインスタンスは非常に高いIOPS を出せるストレージを低コストで利用できる し、ノードが無くなってもデータ本体はKafka Broker に保持できるという点でKafka Streams と相性が良い。

Slide 33

Slide 33 text

StateStore の問題点 StateStore には大きな難点が一つある。 それはデータレストア中は、そのパーティションのパイプラインの処理が停止すると いうこと。 もし集計結果を大量に保持しなければいけないなら、データロスト時のレストアにも それなりに時間を要してしまう。 その間に蓄積したデータは全て処理が遅延してしまう。 現時点で万能の解決策が存在しないため、状況に合わせた工夫が必要になるかもしれ ない。 弊社ではStateStore とCassandra を多段に積み重ねて、StateStore をキャッシュ的に利 用する構成にしレストアにかかる時間を短くしている箇所がある。

Slide 34

Slide 34 text

チューニングポイントとして重要な設定 max.fetch.bytes, max.partition.fetch.bytes: consumer が一度に取得するデー タ量 max.poll.records: 1 回のpoll で処理する最大のレコード数 num.stream.threads: 1 ノード上の処理スレッドの数。 cache.max.bytes.buffering: StateStore に対するアプリケーションレイヤーでの キャッシュメモリ量 num.standby.replicas: StateStore のスタンバイレプリカの数

Slide 35

Slide 35 text

運用時に注視すべきメトリック consumer_lag: consume 済みのレコードから最新のレコードまでのレコード数 process_latency: 1 つの処理が完了するまでの時間 e2e_latency: あるアプリケーションの一連の処理が完了するまでの時間 commit_latency: consumer が処理済みレコードをcommit するのにかかった時 間 もちろん各種システムメトリックも必要。

Slide 36

Slide 36 text

今後の展望 トレーサビリティの拡充 Debezium を利用したRDB とKafka トピックの同期 Apache Hudi へのストリーム変換により、バッチラインとの協調を強化

Slide 37

Slide 37 text

Repro 株式会社はエンジニアを募集しています 特にこういった基盤を支えるSRE を強く求めています https://company.repro.io/recruit/