500系のステータスコードを返すWebサーバーを作ってみた

# 概要

500系のステータスコードに関する理解を深めるために、敢えて500系を返すWebサーバーを作ってみました。

# Webサーバーが500系を返す場合の原因調査の難しさ

Webサーバーを運用していると、Load Balancerが502や504などのステータスコードを返すことがあります。最初に観測したときは戸惑いますが、ログやメトリクスを調査していくうちに、500系のステータスコードが返る際のパターンや傾向が何となく見えてきます。以下はその例です。

「504が返る時は、ストレージのスループットやIOPSが高い値で貼り付いてるよね」
「502が返る時は、複数台あるバックエンドのWebサーバーの一つが一瞬止まってたよね」
「503が返る時は、そもそもバックエンドのWebサーバーが動いていないよね」

こうした経験則が積み重なってくるのは、運用の上では決して悪いことではありません。例えば、504が増え始めたら「ストレージの負荷が高まっているかもしれない」と判断し、ストレージのログやメトリクスを優先的に調査することで、素早く原因にたどり着ける可能性があります。推測が的中していれば、ストレージのスペックを上げて504を返さないように対処できます。

しかし、もし「**ストレージの負荷は低いのに504が大量に返っていた**」という場合はどうでしょうか？

このケースでは、せっかく積み上げてきたノウハウがほぼ役に立たず、初心者のようにゼロから調査をやり直すことになります。マシン、ネットワーク、ストレージといった各種ログやメトリクスを一つひとつ確認していくことになりますが、このアプローチは原因がわかるまでに非常に時間がかかるうえ、最終的に特定できないまま終わってしまうことも珍しくありません。

「他のタスクをすべてストップして504対応の調査に多くの時間とエネルギーを注いだのに、結局問題の原因を特定できなかった…」という残念な結果に終わることも少なくないでしょう。

# 500系のステータスコードを理解する

調査の底なし沼に落ちてしまうのを回避する第一歩は、「500系の各ステータスコードが何を意味するのか？」を理解することです。各ステータスコードの意味がわかると、見当外れな調査をしなくて済みます。

そこで、RFC 9110に書かれた5xxのステータスコードの定義を見てみましょう。

https://tex2e.github.io/rfc-translater/html/rfc9110.html#15-6--Server-Error-5xx

500系は500から505までの定義がずらりと書かれていますが、502と504のエラーだけ抜粋します。

> 502 Bad Gateway
>
> サーバーがゲートウェイまたはプロキシとして機能している間、リクエストを満たそうとしている間にアクセスしたインバウンドサーバーから無効な応答を受け取ったことを示しています。


> 504 Gateway Timeout
>
> サーバーがゲートウェイまたはプロキシとして機能している間、リクエストを完了するためにアクセスする必要があるアップストリームサーバーからタイムリーな応答を受け取らなかったことを示しています。

この文言で「ゲートウェイまたはプロキシ」と言っているのは、NginxやApache、AWS ALBに相当するものです。また「インバウンドサーバー」や「アップストリームサーバー」は、Node.js、Django、Flaskなどのプロキシの背後にあるアプリケーションサーバーを指します。

試しに具体的なサービス名で、502と504の説明を言い換えてみます。

> 502 Bad Gateway
>
> NginxやApacheがプロキシとして機能している間、リクエストを満たそうとしている間にアクセスしたNode.jsやDjangoから無効な応答を受け取ったことを示しています。

> 504 Gateway Timeout
>
> NginxやApacheがプロキシとして機能している間、リクエストを完了するためにアクセスする必要があるNode.jsやDjangoからタイムリーな応答を受け取らなかったことを示しています。

NginxやNode.jsを知っている人であれば、502と504のイメージが少し掴めてきたかもしれません。
よりイメージを明確にするために、502と504の状況を図にしてみました。

![502.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/370519/b42ece06-32e0-4654-83da-dcd56d3404d3.png)

![504.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/370519/13eae456-094e-4a0e-bb4f-e073f5be18dc.png)

502は「アプリサーバーは応答は返しているけど、プロキシがレスポンスの内容が適切でないと判断して返すステータスコード」、また504は「アプリサーバーが制限時間内にレスポンスしなかった時に返すステータスコード」と理解すればよさそうです。

# 5xxを返すWebサーバーを実装

500系のステータスコードが実際にどんな時に返るかを体感するために、以下のGithubリポジトリに「敢えて500系を返すWebサーバー作成環境」を格納しました。

https://github.com/showchan33/http-error-simulator

このリポジトリでは、リバースプロキシにNginx、アプリケーションサーバーにFlask(Pythonのパッケージ)を使っています。

## Nginxの設定

Nginxのコンフィグは以下です。

```conf:default.conf
server {
    listen 80;

    location / {
        proxy_pass         http://app:8080;
        proxy_read_timeout 3s;
    }
}
```

Nginxはポートは80でリッスンし、``app``というホスト名で8080で待ち受けているFlaskのアプリケーションサーバーにそのままリクエストを転送します。
また、``proxy_read_timeout`` を ``3s`` にし、アプリケーションサーバーからのレスポンスデータの受信が3秒間途切れた場合にタイムアウトするようにしています。実運用ではこんな小さな値にしないと思いますが、今回は5xxのステータスコードを返すために敢えて3秒にしました。

## アプリサーバーのソースコード

続いて、PythonのFlaskを使って書いたソースコードは以下です。

```python:app.py
from flask import Flask
import time
import sys

app = Flask(__name__)

# 200が返る
@app.route("/")
def index():
  return "Hello, this is a simple HTTP error simulator!", 200

# 504が返る
@app.route("/slow")
def slow_response():
  time.sleep(5)
  return "This response took a long time", 200

# 502が返る
@app.route('/abort')
def abort():
  sys.exit(1)

# 500が返る(Flaskが500を返すので)
@app.route('/nothing')
def nothing():
  return None

if __name__ == "__main__":
  app.run(host="0.0.0.0", port=8080)
```

ネタバレしてますが、コメントにはNginx経由で各パスにリクエストした際に返るステータスコードを書いています。

## サーバ（コンテナ）の起動

実際にリポジトリをクローンして、
```
git clone https://github.com/showchan33/http-error-simulator.git
```
NginxとFlaskのコンテナを起動します。
```
cd http-error-simulator/docker
docker compose up -d
```

新たに2つのコンテナが起動することを確認します。
```
$ docker ps
CONTAINER ID   IMAGE                            COMMAND                   CREATED         STATUS         PORTS                             NAMES
c31226303119   nginx:latest                     "/docker-entrypoint.…"   7 seconds ago   Up 6 seconds   80/tcp                            nginx
c656fc6ab7e4   http-error-simulator             "python app.py"           7 seconds ago   Up 7 seconds   8080/tcp                          flask_app
```

## リクエストしてステータスコードの確認

curlでリクエストするために、nginxのコンテナに入ります。

```
docker exec -it nginx bash
```

この後、各パスにFlaskへの直接リクエストした場合と、Nginx経由でリクエストした場合でどのようなステータスコードが返るのかを確認してみます。

### ``/`` へのリクエスト(200)

#### ソースコード

```python
@app.route("/")
def index():
  return "Hello, this is a simple HTTP error simulator!", 200
```

#### Flask直接
```
# curl -I http://app:8080/ 2>/dev/null | head -n1
HTTP/1.1 200 OK
```
#### Nginx経由
```
# curl -I http://localhost/ 2>/dev/null | head -n1
HTTP/1.1 200 OK
```

当たり前ですが、両方とも200が返ります。

### ``/slow`` へのリクエスト(504)

#### ソースコード

```python
@app.route("/slow")
def slow_response():
  time.sleep(5)
  return "This response took a long time", 200
```


#### Flask直接
```
# curl -I http://app:8080/slow 2>/dev/null | head -n1
(5秒後)
HTTP/1.1 200 OK
```

#### Nginx経由
```
# curl -I http://localhost/slow 2>/dev/null | head -n1
(3秒後)
HTTP/1.1 504 Gateway Time-out
```

Flaskに直接リクエストしたら200が返ったのに、Nginx経由だと504が返ってしまいました。これはNginxのコンフィグで ``proxy_read_timeout`` が ``3s`` となっており、3秒間Flaskから何のデータも受信しなかったことが原因です。今回は極端に値を小さくして意図的に504を発生させましたが、実運用のWebサーバーでもプロキシ側のタイムアウト値が適切でない場合、アプリケーションは200で返せるはずのリクエストを504で返してしまう可能性があります。そのため504エラーが頻発する場合は、プロキシのタイムアウト値が小さすぎないかを確認しましょう。

### ``/abort`` へのリクエスト(502)

#### ソースコード

```python
@app.route('/abort')
def abort():
  sys.exit(1)
```

#### Flask直接
```
# curl -I http://app:8080/abort
curl: (52) Empty reply from server
```

#### Nginx経由
```
# curl -I http://localhost/abort 2>/dev/null | head -n1
HTTP/1.1 502 Bad Gateway
```

このケースでは、Flaskが ``sys.exit(1)`` でリクエスト処理を終了しています。そのため、Flaskに直接リクエストするとレスポンスが何も返らずに切断され、curlはエラー終了します。もちろんステータスコードも返りません。一方、Nginx経由でリクエストすると、Nginxは「Flaskが異常終了して応答を返さなかった」ことを検知し 、クライアントに "502 Bad Gateway" を返します。

尚、**Nginx(プロキシ)にリクエストした場合に502を返してしまう原因は、Flask(アプリケーションサーバー)の処理以外に起因する場合もあり**ます。例えばNginxのコンフィグで、以下のようにバックエンドサーバーにプロキシするポート番号を間違ってしまった場合を考えます。

```conf:default.conf
server {
    listen 80;

    location / {
        proxy_pass         http://app:8081; # 8080を8081と間違う
        proxy_read_timeout 3s;
    }
}
```
この場合、Flaskは問題なく動いていてもNginxにリクエストすると502が返ってしまいます。
```
## Flask直接
# curl -I http://app:8080/ 2>/dev/null | head -n1
HTTP/1.1 200 OK

## Nginx経由
# curl -I http://localhost/ 2>/dev/null | head -n1
HTTP/1.1 502 Bad Gateway
```

したがって、502が発生した場合にアプリケーションサーバーの問題だけを疑って調査しても原因を特定できない可能性があることに注意が必要です。プロキシとアプリケーションサーバー間のネットワーク的な問題がないかも確認してみましょう。


### ``/nothing`` へのリクエスト(500)

#### ソースコード

```python
@app.route('/nothing')
def nothing():
  return None
```

#### Flask直接
```
# curl -I http://app:8080/nothing 2>/dev/null | head -n1
HTTP/1.1 500 INTERNAL SERVER ERROR
```

#### Nginx経由
```
# curl -I http://localhost/nothing 2>/dev/null | head -n1
HTTP/1.1 500 INTERNAL SERVER ERROR
```

このケースは単純に、Flaskが500を返したのでNginxもクライアントにそのまま500を返しています。このことより、プロキシは「バックエンドの状態を自ら判断して500系のステータスコードを返す」ケースと、「バックエンドが返した500系のステータスコードをそのまま返す」ケースの2つがあることがわかります。

# おわりに

500系ステータスコードについて調べて実装してみて、私自身も少し理解が深まりました。最後に502と504の違いを恋愛にたとえられる!?と思いつき、生成AIに質問して返ってきたコメントを共有して本記事を締めくくります。

## 質問

502と504の違いを恋愛にたとえると、504はプロキシ側から切断するので「バックエンドがプロキシにフラれた」、502はその反対なので「プロキシがバックエンドにフラれた」という認識でいいでしょうか?

## 生成AIの回答

そのたとえ、なかなか面白いですね！方向性としては合っていますが、もう少し明確にするとこうなります。

* 502 Bad Gateway → 「プロキシがバックエンドにフラれた」
→ バックエンドが期待通りの応答を返さなかった（エラー応答や無効なレスポンスを返した、そもそもダウンしていた など）
  * 例:
プロキシ（Aさん）が恋人（バックエンドのBさん）に話しかけたが、意味不明な返事が返ってきた、または完全に無視された。Aさんは困ってしまい、第三者（クライアント）に「Bさん、ダメだった…（502）」と伝える。

* 504 Gateway Timeout → 「バックエンドがプロキシにフラれた」
→ プロキシがバックエンドからの応答を待っていたが、制限時間内に返事がなかったため諦めた
  * 例:
プロキシ（Aさん）が恋人（バックエンドのBさん）にメッセージを送ったが、何も返信が来ないまま時間切れに。Aさんは「Bさん、待ったけどダメだった…（504）」と第三者（クライアント）に報告する。

つまり、502はバックエンドが変な応答をしたり死んでいる場合、504はバックエンドが沈黙したまま時間切れになった場合 の違いですね！

---

(以下は筆者のコメントです)
日本の曲にたとえると、502は「好きだからウソついた」T-BOLANの「すれ違いの純情」、504は「会いたくて会いたくていつまでも待っている」西野カナの「遠くても」、がそれぞれの振る舞いに近いかもしれません。