概要
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のステータスコードの定義を見てみましょう。
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は「アプリサーバーは応答は返しているけど、プロキシがレスポンスの内容が適切でないと判断して返すステータスコード」、また504は「アプリサーバーが制限時間内にレスポンスしなかった時に返すステータスコード」と理解すればよさそうです。
5xxを返すWebサーバーを実装
500系のステータスコードが実際にどんな時に返るかを体感するために、以下のGithubリポジトリに「敢えて500系を返すWebサーバー作成環境」を格納しました。
このリポジトリでは、リバースプロキシにNginx、アプリケーションサーバーにFlask(Pythonのパッケージ)を使っています。
Nginxの設定
Nginxのコンフィグは以下です。
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を使って書いたソースコードは以下です。
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)
ソースコード
@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)
ソースコード
@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)
ソースコード
@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のコンフィグで、以下のようにバックエンドサーバーにプロキシするポート番号を間違ってしまった場合を考えます。
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)
ソースコード
@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は「会いたくて会いたくていつまでも待っている」西野カナの「遠くても」、がそれぞれの振る舞いに近いかもしれません。