MessageConsumer を並列処理するノウハウと注意点

## 前置き

Symfony Advent Calendar 2024 の11日目の記事です。

ここ数年、社内プロダクトの開発をしていて、ようやくリリースし一段落したところです (多忙)

その際に小さなサーバー(コンテナ)を複数立ち上げて負荷を分散させたりと、しっかりスケールするアーキテクトを設計しました。

その際の一箇所、少しこだわったポイントがあるので本記事で紹介します。

## なぜ MessageConsumer を並列実行する必要があったか？

以下の要件を満たす必要があり、一般的なバッチ処理では処理が間に合わないと判断しました。

- MessageConsumer が1件のメッセージを処理するのに「30秒から5分以上」かかる
- 毎日2000件以上のメッセージを処理する必要がある（今後もビジネス規模の拡大するため、メッセージ数の増加が見込まれる）
- 「朝4時から毎日9時まで」に処理を完了させる必要がある
- 処理は CPU バウンド ではなく、外部 API サーバーとの通信に依存する I/O バウンド の性質が強い
- 可能な限りサーバーコストを低く抑えたい

このため、メッセージ処理の高速化を目指し、MessageConsumer の「並列実行」と「オートスケールを行う」をインフラ構成に組み込みました。

クラウドサービスには AWS を使用し、ECS Fargate へデプロイしています。

## コンテナの図

![スクリーンショット 2024-11-15 16.53.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1709628/f08635df-f39e-fb67-1048-6ce1bc5a7c2e.png)

シングルプロセスとマルチプロセスとの差を図にしてみました。

マルチプロセスとは別でコンテナ数もスケールするので、1コンテナが増えるだけで処理するプロセスが2つ増えます。

つまりスケールアウトするたびにコンテナ数の2倍のプロセスが増えることになります。
1コンテナ1プロセスでスケールするよりも単純計算で2倍になります。

並列処理する速度を上げるだけでなく、サーバーコストも低く抑えることができました。

## 実装時の注意点

### Supervisor 上でプロセスを管理する

Docker のドキュメントには、「1コンテナで1プロセスを実行する」ことが推奨されていますが、複数プロセスを実行する場合は「Supervisor」を利用するよう記載されています。

https://docs.docker.jp/v17.06/engine/admin/using_supervisord.html

コスト削減が目的でなければ、シンプルに「1コンテナ1プロセス」で運用する方が管理しやすいですが、今回はコスト削減を踏まえて、Supervisor を使用したマルチプロセスなコンテナを採用しました。

### メモリリークのリスクに配慮する

symfony/messenger などのプロセスを継続して実行するライブラリや、そもそも PHP アプリケーションを Worker として動かす場合、メモリリークが発生しやすい特性があります。

(特定のファイルをパースした際、メモリにパースしたデータを保持したまま次のメッセージが処理される形跡がありました。PHP拡張の実装だったため、PHPコード上で unset() してもそのメモリにアクセスはできません)

そのため、Supervisor のようなプロセス管理ツールを利用するか、クラウドサービスの自動再起動機能を活用し、定期的にメモリを解放することが推奨されています。

これに関する詳細は以下のドキュメントを参照してください。

https://symfony.com/doc/current/messenger.html#deploying-to-production

### 1つのプロセスの負荷を確認する

メッセージの内容により CPU やメモリに対する負荷は変わるので、処理したい内容がどういったボトルネックを抱えているのか調べる必要があります。

また、プロセス数によってメモリに格納するセッション数が増えるので、メモリの枯渇にも注意が必要です。

並列処理を行ったメッセージの性質として、CPU バウンドが低く、I/O バウンドが高かったことから、マルチプロセス化しても CPU に余裕があり、安定して運用できています。

そのため、実装中だけでなくリリース後もメトリクスを見ておくと、将来的なスケール時の判断材料になるので、日課としてメトリクスは見るようにしています。

:::note info
処理のボトルネックの解析方法として、ローカルでも Docker を使えば「1プロセスの動き」が読みやすいため、実装のパフォーマンスに懸念がある場合は Docker を使うのが手軽でオススメです。

今回の開発ではローカルで起動しているコンテナを Docker Desktop のメトリクスグラフで、ステージング環境のコンテナを AWS CloudWatch のメトリクスグラフとで照らし合わせていましたが、近い負荷推移を表していたため、リークになりやすい処理の判定がつきやすく、デバッグもしやすくとても良かったです。

https://www.docker.com/ja-jp/blog/how-to-monitor-container-memory-and-cpu-usage-in-docker-desktop/

https://docs.docker.jp/compose/compose-file/index.html#cpus
:::

### データベース負荷の確認

プロセスが DB 操作の実装を持っている場合、データベースにも相応の負荷が生じます。

1プロセスがデータベースに対するコネクションを持っているので、プロセスが多ければ多いほどデータベースに負荷がかかりますし、プロセスの中にスロークエリやロックしやすい実装が含まれてる場合、やはりデータベースのスループットに影響が及びます。

このことを念頭に置きつつ、開発を進める必要があります。

## 感じたメリット

- 1コンテナで複数プロセスが実行されるので、コンテナ数が少なく安価で高速
- 1コンテナで水平スケールを行うので、スケールアップした時の処理に即効性がある
- 安定している
- 今後のメッセージ数の増加に耐えられる

コンテナのスペックや数も最小限で済んでいるので、高速かつ低コストの設計が実現できたと感じています。

今後見込まれるメッセージ数の増加に関しても、コンテナを並列スケールさせるか、プロセス数を増やすといった手法で対処できるようになりました。

## 感じたデメリット

- 理解してもらうのにインフラとバックエンドの知識が必要
- 1プロセスの監視を他の人に任せること
- 1コンテナのログに、複数プロセスのログが密集する

インフラとバックエンドの間の話になるので、スキルに偏りがあると説明が難しいです。
この時に Docker や図を使って説明することで、少しずつ理解してもらうのを頑張るつもりです。

ログに関してはログを読めば「どのプロセスか？」を把握できるので、今は問題としていませんが、
やはり1コンテナには1プロセスのログが出力されているほうが、直感的で読むコストは少ないと感じます。

## まとめ

要するに「お皿にどれくらいおかずを乗せるか判断しましょう的な話」でした。

居酒屋に出てくるような小皿に唐揚げは1つか載せられないから、大きなお皿を乗せたらスペース余ったからもう2〜3個唐揚げを乗せましょう的な考え方で高速化を図りました。

## 最後に

Symfony 関係なくなってきているので、最後にちょっと触れます。

Symfony で SQS を使っている方は、ぜひ以下のドキュメントを読んでください。
(こういったニッチな挙動に関することが書かれてるので、Symfony の公式ドキュメントは流石です)

https://symfony.com/doc/current/messenger.html#deploying-to-production

ちなみにSDKによってメモリ空間(セッション)にトークン情報を保持する場合があります。メッセージ間でセッションが使い回されると……、考えるだけで怖いですね😅

## 追伸

「Symfony、仕事で使ってみたいんですよね…」と思ってる方は以下で募集してます。
軽くチャットする程度で良いので、コンタクトもらえたら嬉しいです！

https://quartetcom.co.jp/recruit/engineer/