CSSと画像だけが503を返す — OpenLiteSpeed + Bottle構成でハマった話

openlitespeed

静的ファイル(CSS・JS・画像)をWSGIアプリ経由で配信していたのが原因だった。 OpenLiteSpeedに静的コンテキストを2つ追加し、LSAPIの接続数設定を修正したら直った。作業時間は原因特定を含めて1時間弱。

以下、同じ構成の人が最短で辿り着けるように、症状から解決までを順に書いていく。


症状

ある日、サイトの見た目が完全に崩れた。

  • HTMLは正常に200で返る
  • CSS・JS・画像がすべて503 Service Unavailable
  • サーバー負荷は 0.01(ほぼゼロ)
  • Cloudflare経由だが、オリジンに直接叩いても同じ

「サーバーが重くて落ちている」わけではない。負荷計はぴくりとも動いていないのに、静的ファイルだけが全滅する。この組み合わせが最初は理解できなかった。

環境

  • さくらのVPS(AlmaLinux)
  • OpenLiteSpeed 1.9.1
  • Bottle(Python)+ Jinja2、LSAPI(lswsgi)経由で動作
  • Cloudflare CDN

まずやったこと:オリジンに直接叩く

Cloudflareが原因かどうかを切り分ける。Hostヘッダを付けてオリジンIPを直接叩けばいい。

curl -I -H "Host: example.com" https://<オリジンIP>/assets/css/style.css -k

ここで503が返ればオリジン側、200ならCloudflare側。今回はオリジン側だった。

ちなみにブラウザ側で確認するなら、レスポンスヘッダの cf-cache-status も見ておくといい。HTMLが HIT だった場合、それはCloudflareのキャッシュから返っているだけで、オリジンは全落ちしている可能性がある。「HTMLは生きている」が錯覚だったというパターンだ。

決め手はエラーログ

推測で設定をいじる前に、ログを読むべきだった。ここに全部書いてあった。

tail -100 /usr/local/lsws/logs/error.log
tail -100 /var/vhost/<vhost名>/logs/error.log

出てきたのがこれ。

LSAPI Packet header is invalid,('L','S','.','.','.','.','.','.')
Connection error: Input/output error, adjust maximum connections to 15!
→ 5! → 4! → 3! → 2!
Available connections are dropped below half of configured value:35,
restart too frequently, wait till timeout!

oops! 503 Service Unavailable
request line: 'GET /assets/css/bootstrap.min.css HTTP/1.1'
request line: 'GET /lp/img/lp_minofa_031_1125.webp HTTP/1.1'
(以下、画像が20本以上続く)

読み取れることが3つある。

  1. 503になっているのは /assets//lp/img/ 配下だけ
  2. LiteSpeedがLSAPIへの書き込みでI/Oエラーを検出し、最大接続数を自動的に切り下げている(35 → 15 → 5 → 2)
  3. 2まで落ちた結果、事実上すべてのリクエストが503になっていた

原因1:静的ファイルが全部Pythonを通っていた

WebAdminのコンテキスト設定を見たら、こうなっていた。

コンテキストタイプ URI
App Server (WSGI) /

これだけ。つまりCSSも画像も含めた全リクエストがBottleを経由していた。 LiteSpeedは静的ファイルを一切自前で返しておらず、Pythonにファイルを読ませて返していたことになる。

構造にするとこうだ。

変更前:
ブラウザ → Cloudflare → LiteSpeed → Bottle(Python) → ディスク
                                     ↑ 子プロセス10個しかない

CSS1本、画像1枚ごとにPythonのプロセスを1つ消費する。

原因2:接続数の設定が食い違っていた

App Serverコンテキストの設定はこうなっていた。

  • 最大接続数:35
  • 環境変数:LSAPI_CHILDREN=10

受け手の子プロセスは10個しかないのに、LiteSpeedは最大35本の接続を張りにいく。溢れた接続が壊れたレスポンスを読み、LSAPI Packet header is invalid が発生する。するとLiteSpeedは「このアプリは不安定だ」と判断して最大接続数を自動的に切り下げる。35が2まで落ち、さらに「再起動が頻繁すぎる」として回復待ちに入る。

これが503の直接のメカニズムだった。

なぜ「ある日突然」だったのか

ここが個人的に一番腑に落ちたポイント。

引き金は画像点数の多いランディングページを公開したことだった。

そのLPは1ページに20枚以上の画像を持つ。ブラウザはこれを一斉に取りにいく。さらにCloudflare経由だと接続元IPが分散するため、HTTP/2の多重化も効きにくい。結果、1ページを開くだけで数十本の同時接続がWSGIに殺到した。

トラフィックが増えたわけではない。1ページあたりの同時リクエスト数が閾値を超えただけだ。

それまでは1ページあたりの静的ファイルが35本以内に収まっていたから、たまたま動いていた。負荷が0.01のまま503が出続けていたのも、CPUが忙しいのではなく接続枠だけが枯渇していたからだ。

つまりこの構成は最初から壊れていて、発火するのを待っていた状態だった。

解決策

1. 静的コンテキストを追加する

WebAdmin → バーチャルホスト → コンテキスト → 追加 → Static を選択。

URI(ブラウザから見えるパス) 場所(ディスク上の実パス)
/assets/ $VH_ROOT/<vhost>/html/static/assets/
/lp/img/ $VH_ROOT/<vhost>/html/views/lp/img/

ここが最大の落とし穴だった。 URIとディスク上の実パスは一致していない。

  • URL /assets/css/style.css → 実体は html/static/assets/css/style.css
  • URL /lp/img/xxx.jpg → 実体は html/views/lp/img/xxx.jpg

Bottleのルーティングがこのズレを吸収していたので、URLだけ見ていると気づけない。静的コンテキストを作るときは、必ず先に実パスを確認すること。

# URLに含まれるファイル名で実体を探す
find /var/vhost/<vhost名> -name "実際のファイル名.jpg" 2>/dev/null

これをやらずに存在しないパスを指定すると、コンテキストがマッチせず / のApp Serverに落ちる。設定したのに何も変わらない、という状態になる。実際、自分は一度これで無駄な再起動をした。

2. 最大接続数をLSAPI_CHILDRENと揃える

App Serverコンテキストの「最大接続数」を LSAPI_CHILDREN と同じ値にする。今回は 35 → 10 に変更した。

逆に LSAPI_CHILDREN を35に上げる手もあるが、Pythonプロセスが増える分メモリを食う。個人サイト規模なら接続数を下げる方が素直だと思う。

3. グレースフル再起動

WebAdmin上部のボタンから実行。SSHで .conf を手編集するのは避けたほうがいい。OpenLiteSpeedはWebAdminから保存するたびに設定ファイルを再生成するので、手編集した内容が消える。

確認

curl -I https://example.com/assets/css/bootstrap.min.css
curl -I https://example.com/lp/img/sample.jpg
tail -30 /var/vhost/<vhost名>/logs/error.log

200が返り、新しい oops! 503 が出なくなれば完了。

変更後の構造

HTML         → LiteSpeed → Bottle(Python) → ディスク
静的ファイル → LiteSpeed → ディスク  (Pythonを通らない)

Pythonが処理するのはHTMLだけになった。静的ファイルが何本同時に来ても接続枠を消費しないので、この種の枯渇は構造的に起きなくなる。

副次的な効果として、静的ファイルの配信速度も上がった。Pythonでバイト列を読んで返す処理が丸ごと消えたので当然といえば当然だ。

遠回りしたポイント(反省)

同じ轍を踏まないように書いておく。

1. ログを読む前に対策を考えた

最初、なんとなく /static/ が怪しいと思って静的コンテキストを作った。だが実際に503を出していたのは /assets//lp/img/ で、ログを開けば1行目で分かることだった。推測でGUIをいじる前にログ。これに尽きる。

2. ログの Redirect: #1, URL: /_wsgi.py を誤読した

503の行にはもれなくこの表記が付いていて、「Rewriteルールが全リクエストを書き換えているのでは」と疑った。だがこれはOpenLiteSpeedが内部でApp Serverコンテキストへ振り分ける際の通常表記であって、Rewriteとは無関係だった。

正しい読み方は「このリクエストは静的コンテキストにマッチせず / に落ちている」というサインである。むしろこれこそが、コンテキスト設定が効いていない証拠だった。

3. ディスク上の実パスを確認しなかった

前述の通り。ls を1回叩けば済む話を、推測で埋めようとした。

ついでに見つかった別件

stderr.log に大量のDeprecationWarningが出ていた。

jinja2/runtime.py:318: DeprecationWarning:
  Using or importing the ABCs from 'collections' instead of from
  'collections.abc' is deprecated since Python 3.3,
  and in 3.10 it will stop working

DeprecationWarning: 'soft_unicode' has been renamed to 'soft_str'.
  The old name will be removed in MarkupSafe 2.1.

from collections import MappingPython 3.10以降で動かない。同梱しているJinja2が古すぎる。今は動いているが、Pythonをアップデートした瞬間にサイト全体が落ちる時限爆弾になっている。

ライブラリを html/ 直下に直置きしている構成(ベンダリング)が原因なので、venvを切ってpipで管理する形に移行する予定。

なお OSError: LSAPI: File error も大量に出ていたが、これは今回の障害の症状であって原因ではない。LiteSpeedが接続を切った後にPython側が書き込もうとして失敗した記録なので、復旧後は出なくなる。

まとめ

  • HTMLは通るのに静的ファイルだけ503 → 静的ファイルがアプリサーバー経由になっていないか疑う
  • 負荷が低いのに503 → リソース不足ではなく接続枠の枯渇
  • LSAPI_CHILDREN と最大接続数は必ず揃える → 食い違うと接続数が自動的に切り下げられて自滅する
  • URIとディスクの実パスは別物 → 静的コンテキストを作る前に find で実体を確認する
  • 推測より先にログ → 今回の答えは全部エラーログに書いてあった

WSGIアプリを立てるとき、静的ファイルの配信をWebサーバー側に任せるのは基本中の基本だ。分かっていたつもりで、自分の環境がそうなっていないことに気づいていなかった。動いているうちは問題として認識されないので、同じ構成の人は一度コンテキスト設定を覗いてみることをおすすめする。

この記事に関する技術サポート・ご相談

「手順通りにいかない」「自社環境への構築を代行してほしい」など、
インフラ・サーバー周りでハマった際はお気軽にご相談ください。

相談・問い合わせフォームを開く

※ Googleフォームへ移動します(初回相談無料)

個人支援・寄付について

サイトラボでは個人支援・寄付を受けております。ご協力いただける方はお願いいたします。当サイトではビットコインで受け付けております。

  • ビットコイン:3LHnADwZwUbic2L45EnVJEykiG6KfbqrwS