Gmail と MailHog にメールがランダムに送られる Laravel でのバグを仮説ドリブンなアプローチで解決してみた
TL;DR
- 症状: ステージング環境から送ったメールが Gmail や ローカル MailHog にランダムに届く
 - 原因: 全ての環境が同じ AWS SQS キューをポーリングしており、ジョブを取り合っていた
 - 対応: .env で 
QUEUE_CONNECTION=syncで解決 - 主張: 手を動かすより、頭を使おう!
 
はじめに
NO AI (人間が書きました)
メールやインフラが絡むバグは考えることが多く、闇雲なデバッグ作業になりがちです。
ここでは、どのように原因を特定したか、その泥臭いプロセスをまとめてみます。
バグ修正の技術記事というよりは、問題解決手段として仮説ドリブンなアプローチを勧めており、その具体の共有がメインです。
設計と仕様
ざっくり下記な感じです。
- フロントエンド:React
 - バックエンド:Laravel
 - インフラ:AWS
 
ユーザー間でチャットができる SNS アプリケーションを開発しています。
オフショアで開発されたプロジェクトの改修案件に参画したばかりで、アプリケーションやシステムの全体像がまだ把握できていない段階での対応でした。
アカウント作成時、チャット受信時にメール通知をするのですが、そのメールが Gmail や ローカル MailHog サーバーに、なぜかランダムに届いてしまうというバグの解決をしたものです。
思想
最初に主張というか思想を書きます。
この記事では、生産性について下記の不等式が成り立つ、という思想を推しています。
- (とにかく網羅的に情報収集するのではなく)時間をかけて頭を使い、仮説を立ててから初めて検証する。
 - 検証から得られた情報をもとに、(すぐに手を動かすのではなく)再度仮説を立てる。
 
このようなプロセスを経ることで、問題解決そのものが深い学習機会となり、次に同じような問題に当たった時に解決が爆速になります。
「とりあえず ChatGPT のコードを貼り付けたら解決したので ok」という人は、次に同じような問題に出会った時にまた ChatGPT に聞くのでしょう。
これは長期的生産性が上がっていない、すなわち「成長していない」と考えています。
フェーズ1: 問題の絞り込み、問題の解像度を上げる
まず、https://dev.xxxxx.jp (ステージング)でいくつかアカウントを作成した時に、なぜか登録完了メールが Gmail にきたり MailHog に来たりしたのです。
また、あろうことか、Aさんがローカルでチャットのテストをしたら、その通知メールがBさんのローカル MailHog サーバーに落ちたりしたのです。
ステージングでのメール送信や、離れた場所でのローカル操作がトリガーになり、別の場所で立っているローカルサーバーの MailHog にメールが届くのは明らかにおかしいです。
おかしい理由
MailHog は ローカル PC 内で立てているサーバーです。外から自宅の Wi-Fi ネットワークまで侵入してくるなんてことはかなり難しい(通常不可能)です。
こっちがファイアウォールで TCP  1025 ポートを解放したり、ルーターで「外部ポート(例:2525)→内部IP:1025」のポートフォワード設定などをして、道を作ってあげない限り、外部から自宅ネットワークへ侵入することなど不可能です。
それなのに、ステージングでの操作をトリガーに、ローカルの MailHog サーバーへメールが落ちたわけです。
この時は、
「明らかにおかしいけど、事実起きてる。仮説が立たない。」
という状態でした。
ちなみに ChatGPT に上記の内容を投げてみましたが、
「VPN 内でみんなで MailHog 立ててるんじゃないか」
とか
「Aさん PC の DNS が、Bさんの IP アドレスを参照してるんじゃないか」
とか、そんなわけないことばかり言われました。
フェーズ2: 仮説を立てる
1. 再現手順の把握 & 仮説立て
ここからは、手を動かして情報を得て、仮説を立ててまた手を動かす、というサイクルで再現手順を見つけようとしました。
メールのゆくえが問題なので、http://localhost:3000/ で チャット送信をピッタリ10回連打して、そのメール10通がどこへ行くのかを検証しました。
ローカル環境で実行した理由は、フェーズ1 ではステージングでの発生だったので、ローカルでも再現するのか、ステージングのみの事象なのかを切り分けたかったからです。
結果、
- Gmail 2通
 - MailHog 4通
 - どちらにも来ないメール 4通
 
でした。
また、メールは重複することはありませんでした。つまり、Gmail に届いたメールは MailHog には来ず、逆に MailHog に届いたメールは Gmail には来ませんでした。
得られたインサイトは、
- メールは重複しない
 - (Gmail 9通、MailHog 1通 のような)偏りがない。ランダムである。
 
でした。
まず、意図せずランダムになっているということは、非同期に何かの処理が走っているのだと思いました。非同期処理やマルチスレッド、分散コンピューティング的な、複数のワーカーやプロセス、マシンが動いている感じの仕組みなのだろうと仮説を立てました。
また、重複しないということは、メールを作成するところまでは同期的な処理なのだとも思いました。ここも非同期なら、同じメールが複数作成されたりしそうですからね。
つまり、まとめると
です。
ここまで、手を動かしたのは、チャットを10通送信しただけです。
手を動かす検証よりも仮説を立てる方に時間を使っています。
2. 仮説を元に実装の深掘り
次に気になるのは、ちょうどそこの境目の実装がどうなってるのかですね。
なので、チャット送信ボタンが呼び出す API パス /users/room/${id}/chat を、バックエンドの Laravel まで辿り、中を見ました。
中はこんな感じでした。
$emailBody = [/* メール作成 */];
dispatch(new SendEmail(data: $emailBody));
僕の仮説を再掲すると、
なので、メール作成直後の dispatch() が怪しくて仕方ありません。
僕は dispatch() を知らなかったので、ChatGPT に聞きました。
dispatch()は、Laravel の「ジョブ (Job)」機能を使って処理をキューに投げる (ディスパッチする) ための書き方です。
ほう、キューに投げるのね。ということはディスパッチしたジョブはバッチ処理で実行されるのか。
バッチは複数ワーカー動くのよくあるよ〜!怪しいぞ〜〜
デフォルトは
sync(同期実行) になっていることが多いので、実際にキューを使いたい場合は Redis/database などに変更します。例:.env でQUEUE_CONNECTION=database
.env を見たら QUEUE_CONNECTION=sqs とありました!
SQS...?その名前は知ってるぞ?AWS のキューだよな...?
dispatch() は AWS SQS キューにジョブをディスパッチしてるのか?
そして、Laravel バッチ処理ワーカーがそれを見に行っているのではないか?
ローカルもステージングも、Laravel が動いているということは、どの環境でもワーカーが動いているはず。
全ての環境のワーカーがみんな同じ一つの SQS キューを監視して、ジョブを取り合っているんだ!
そして、
ローカルのワーカーがジョブを取ったら MailHog へ、
ステージングのワーカーがジョブを取ったら Gmail へ、それぞれメールが送信されるのではないか?
という感じで仮説が立ちました。
この仮説を図にすると、下記のようになります。
mermaid
flowchart TD
 subgraph LocalEnv["ローカル環境"]
        FE_L["React フロントエンド"]
        BE_L["Laravel バックエンド"]
        W_L["Laravel ワーカー"]
  end
 subgraph StagingEnv["ステージング環境"]
        FE_S["React フロントエンド"]
        BE_S["Laravel バックエンド"]
        SQS["AWS SQS キュー"]
        W_S["Laravel ワーカー"]
  end
    FE_L -- HTTP POST --> BE_L
    BE_L -- dispatch --> SQS
    SQS -- poll --> W_L & W_S
    W_L -- send mail --> MailHog["MailHog (ローカル)"]
    FE_S -- HTTP POST --> BE_S
    BE_S -- dispatch --> SQS
    W_S -- send mail --> Gmail["Gmail (クラウド)"]

ここまで手は動かしていません。コードを読んで必要な情報を取りに行き、それを元に仮説を立てているだけです。
フェーズ3: 仮説の検証
ここまできたら、仮説を検証するだけです。
検証内容は下記です。
検証1
http://localhost:3000/ と https://dev.xxxxx.jp でチャット送信したら、いずれも同じ SQS がメッセージを受信することをAWS マネジメントコンソールで確認。
→ これはすんなりできました。
一つのキューに対して、全ての環境からメール送信リクエストが送られていることが示されました。
検証2
SQS に手動でメッセージを送信したら、Gmail or MailHog にメールが落ちることを確認。
→ これはいくつかメールが来ないこともありましたが、どちらにもいくつかメールがいくことを確認できました。
一つのキューから、Gmail や MailHog にメールがいくような処理を行うワーカーが存在することが示されました。
きっと複数のワーカーがジョブを取り合っているのでしょう。
検証3-1
Gmail にメールが来た場合、ステージング環境内にワーカーが処理したログが残っていることを確認。
→ これは検証できませんでした。
queue.log もないし、Cloud Watch にも吐かれていませんでした。多分ログは残さない設定か何かになっているのでしょう。
これは諦めました。
検証3-2
MailHog にメールが来た場合、ローカル環境内にワーカーが処理したログが残っていることを確認。
→ これは検証できました。queue.log があり、そこでメールを送ったログが残っていました。
また、Gmail にメールが来たときは、ローカルにログは残りませんでした。
これで下記が示されました。
- MailHog にメールを送っていたのは、ローカルの Laravel ワーカーである
 - Gmail にメールを送っていたのは、ローカルの Laravel ワーカーではない
 
検証4
http://localhost:3000/ でチャット送信をピッタリ10回連打すれば、その10通すべてがローカルの MailHog に即座に落ちることを確認。
→ これは確認できました。
.env で QUEUE_CONNECTION=sync にしたら同期処理になるため、キューを使わず普通にAPIの処理で実行されるはず。つまり、QUEUE_CONNECTION=sync にして、http://localhost:3000/ でチャット送信をピッタリ10回連打してみれば、その10通はすべてローカルの MailHog に即座に落ちるはず。という仮説です。
実際に検証したら、確認できました。
これで下記が示されました。
.env で QUEUE_CONNECTION=sync にしたら解決する
フェーズ3: 結論
以上で、仮説が立証されたと判断し、下記の結論に至りました。
下記の1行、sqs から sync に更新すれば治る
QUEUE_CONNECTION=sync
ついでにローカルで SQS の情報は遮断したいので、下記も行いました。
下記の2行を削除
SQS_QUEUE=dev-sqs
SQS_PREFIX=https://sqs.ap-northeast-1.amazonaws.com/12345678
こうすることで、下記のようなフローになります。
mermaid
flowchart TD
 subgraph LocalEnv["ローカル環境"]
        FE_L["React フロントエンド"]
        BE_L["Laravel バックエンド"]
        W_L["Laravel ワーカー"]
  end
 subgraph StagingEnv["ステージング環境"]
        FE_S["React フロントエンド"]
        BE_S["Laravel バックエンド"]
        SQS["AWS SQS キュー"]
        W_S["Laravel ワーカー"]
  end
    FE_L -- HTTP POST --> BE_L
    BE_L -- dispatch --> W_L
    W_L -- send mail --> MailHog["MailHog (ローカル)"]
    FE_S -- HTTP POST --> BE_S
    BE_S -- dispatch --> SQS
    SQS -- poll --> W_S
    W_S -- send mail --> Gmail["Gmail (クラウド)"]

なお、そこまで大規模なアプリでもないので、本来なら SQS や  dispatch() など使わず、そのまま同期的に書けば十分かと思っています。
これをすればインフラ構成も1段スッキリし、障害再発の可能性もなくなるため、根本的な解決になるでしょう。
最終的な理想像は下記です。
mermaid
flowchart TD
 subgraph LocalEnv["ローカル環境"]
        FE_L["React フロントエンド"]
        BE_L["Laravel バックエンド"]
  end
 subgraph StagingEnv["ステージング環境"]
        FE_S["React フロントエンド"]
        BE_S["Laravel バックエンド"]
  end
    FE_L -- HTTP POST --> BE_L
    BE_L -- send mail --> MailHog["MailHog (ローカル)"]
    FE_S -- HTTP POST --> BE_S
    BE_S -- send mail --> Gmail["Gmail (クラウド)"]

まとめ
今回の障害は、「Gmail MailHog メール ランダム」のように Google 検索しても、AWS SQS が原因だなんて記事は出てきません。
ChatGPT に丸投げしても、「VPN や DNS の設定を見直せ」というズレた回答が関の山でしょう。
もしくは、ChatGPT が「QUEUE_CONNECTION=syncにしてください」というので言うとおりにしたらなぜか直った!となり、根本的な解決に至ることはないでしょう。
このように、仮説を立てて、少ない検証で得られる情報から次の仮説を立てる、という一連の仮説ドリブンなアプローチは、問題解決の経験が深く血肉になります。
次に同じような問題に出会った際は、筋の良い仮説が爆速で立てられるでしょう。
それこそがエンジニア、ひいてはビジネスパーソン全般にとっての成長だと私は思います。
Discussion