【俺の屍を】クソ正規表現で本番サイトを吹っ飛ばした話【超えていけ】


こちらはLivesense アドベントカレンダー 2020 23日目の記事です。

こんにちは。転職会議事業部でエンジニアをやっている落合です。
同僚のエンジニアが素晴らしい記事を投稿しているのを横目に、本日のコンテンツは転職会議の本番サイトを一時的にダウンさせた経緯を共有・懺悔する謝罪広告となっております。

え?お前が書くべきアドベントカレンダーはここじゃない?
そういうことを言うのはやめて差し上げろ。

背景

話を始める前に、今回の惨事の舞台になるシステムについて解説したいと思います。
問題が発生したシステムは、転職会議において企業に対する口コミを掲載している企業情報ページ です。
ご存知の方も多いと思いますが、転職会議は企業に関する様々な口コミを集めて提供しているメディアです。
求職者向けの求人ページなどもあるのですが、トラフィックの大部分は各企業の年収・財務情報・面接情報などが公開されている企業ページで発生しており、転職会議のサービスの中でも最も多くのリクエストをさばいています。

この企業情報ページは、フロントエンド(Next.js/React/Redux/TypeScript) + BFF(Rails6系) + 各種マイクロサービスAPI(Rails6系)という環境で構築されています。インフラはAWS上に構築されておりまして、これはちょっとした自慢なのですが、つい先日ほぼ全てのサービスがEKSに移行完了しました🎉

転職会議をEKSに移行させていった経緯については、我らのスーパーSREである @katainaka0503すごくいい感じの記事を書かれていますので、興味がある方はそちらをご覧ください。

何が起きたか

11月のある日、Slackチャンネルに不吉なメッセージが投稿されました。

companyで特定のクチコミページにリクエストするとリクエストがタイムアウトし、companyのPodがそのまま死んでしまう現象が起きています。
https://jobtalk.jp/companies/xxxxx/answers/xxxxxx (サーバが死ぬので調査目的以外でアクセスしないため番号をぼかしてます)
クチコミページに詳しい方に調査に手伝っていただきたいのですが、どなたか手伝っていただけないでしょうか?

当然ですが、このURLは正規のものでユーザーがサイトを回遊する中で普通にアクセスされる可能性があるものです。
また、恐ろしいことに誰かがこのURLにアクセスすると、リクエストを受けたPodが以後のリクエストを正常に返せなくなるため、他のページをみていたユーザーも転職会議の企業情報ページを見ることができなくなる状態でした

幸い企業情報ページは複数のPodでリクエストをさばいていおり、Kubernetesのセルフヒーリングによって正常に動かなくなったPodには速やかにトラフィックが流れなくなったため、新しいPodが立ち上がる構成となっていたため企業情報ページが完全に閲覧できなくなる状態は避けることができました。

しかしながら、本番環境にうっかり押したら本番サイトを吹っ飛ばす自爆スイッチが埋め込まれていることに変わりはありません。

このメッセージを読んだ転職会議事業部のフロントエンドエンジニア、@srkw___ が早速初期調査に乗り出し、Reactコンポーネント内で実行されていた関数の処理に時間と負荷がかかっていることを突き止めてくれました。
ここまでくれば賢い読者のみなさんはお気づきでしょうが、このバルスコマンドを実装・埋め込んだ張本人が私でした(みなさんホントすいません…)。

問題の箇所がわかったため、まずは該当のコードを本番環境から取り除くリリースを実施し、自爆スイッチが本番環境から撤去されたことを確認した上で詳細な原因調査を行いました。

なぜこのページに繋がらなくなってしまったのか

惨劇はなぜ起こってしまったのか。それを説明するために、まずは問題の関数がどのような処理を行っていたのかについて解説したいと思います。
問題の関数は、BFFから返却される口コミのテキストを整形するための関数で、特定のパターンにマッチする文字列をトリムする処理を正規表現を使って行っていました。

本当はもうちょっと複雑な正規表現なのですが、話を簡単にするために単純化したものがこちらです。

review.match(/(【.*】(.|\s)*)【.*】/)

この正規表現のまずいところは、 (.|\s).\s は両方とも空白文字にマッチしてしまうところです。
例えば、正規表現にマッチングさせる文字列に空白文字が2つ入っていると、2つの空白文字に対して(.|\s)するパターンは以下の4通りになります。

  • . -> . でマッチしたパターン
  • . -> \s でマッチしたパターン
  • \s -> . でマッチしたパターン
  • \s -> \s でマッチしたパターン

仮に、空白文字は2つ含まれているが、最終的にはこの正規表現にマッチしない文字列が関数に渡ってきたとしましょう。この場合、上記4つのパターンについて正規表現がマッチングしないことを正規表現エンジンは確認しないといけません。もうお分かりかと思いますが、空白文字が1つ増えるごとに(.|\s)がマッチするパターンが2の冪乗で大きくなるのでマッチングしないことを確認するために必要なステップ数も2の冪乗で大きくなります。

今回問題が発生したページでは、大量の空白文字が含まれた口コミをこの正規表現にマッチさせようとした結果、正規表現のマッチング処理に時間がかかってしまいレスポンスが返せなくなっていました。

加えて、Next.jsによるSSRはexpress(Node.js)によって行われているためシングルスレッドです。
I/O処理には滅法強いですが、今回のようにCPUを食い潰すような実装が紛れこんでいるとイベントループが止まり、後続のリクエストの処理ができなくなってしまいます。
これが問題のページだけでなく、他のページへのリクエストも正常に処理されなくなった原因だと思われます。

惨劇を起こさないためにどうすればよかったか

コードの修正という意味では簡単で、\s\n に替えるなど、同じ文字に正規表現の()内の複数パターンがマッチしないように修正すれば問題は発生しなくなります。
とはいえ、今回のような障害の再発防止のためには、あらゆるテキストが渡ってくることを想定して大量のテストを実施するか、レビューの段階で怪しい正規表現に気付けるようにするかしかありません。前者は工数的にしんどいので、今後同じような障害を発生させないためには実装者もレビュワーも正規表現に対する理解を深める必要があると考えています。

今回懺悔記事を書いているのもその一環で、同僚やこの記事を読んでいるみなさんが、少しでも正規表現で事故らないようになってくれたら嬉しいです。