Edited at

ハァイ、ジョージィ!reverse proxy立ててくれないのかい?

えーっ。Nginx とか立てようよ。バッファリングとかレスポンス圧縮とか静的ファイル配信とか、欲しいだろう?

疲れるので普通に書きます。食べログ DevOps チームの @weakboson です。

この記事は Advent Calendar の3日目の投稿です。

production ではアプリケーションの手前に reverse proxy として HTTP サーバを立てるといろいろメリットがあります。そのような構成にして疎通できないときによくある原因と診断・調査方法を解説します。


疎通しないときの原因と診断手法

Case
よくある原因
診断

a
Reverse proxy が port を間違えている
アプリのローカルから localhost に curl

b
アプリの bind が lo になっている
アプリの NIC Address に curl

c
Reverse proxy とアプリが同一ホストで設定があべこべ
アプリのインタフェースそれぞれに curl

d
Reverse proxy の送出する Host ヘッダと下流の期待するヘッダが合っていない
アプリ Nginx のログ確認
tcpdump で Host ヘッダを確認

Case a ~ c のサンプル構成は以下の通りとします。ロードバランサの下流に Web サーバがあり、その下にアプリケーションサーバ、アプリケーションは (HTTP サーバを介さず) port 3000 で直接 proxy リクエストを待ち受けているとします。

case-abc.png


Case a: Reverse proxy が port を間違えている

そんな基本的な……と思いますが割とありがち。


アプリのローカルから localhost に curl

アプリホストのローカルから curl で proxy している port を叩いてみて、接続を拒否されたら port 間違いの可能性があります。

$ curl -I -X GET http://localhost:3000/

curl: (7) Failed to connect to localhost port 3000: 接続を拒否されました

設定は正しいはずなのになー、と思ったらホントに意図通りの挙動をしているか確認してみましょう。netstatlsof を使うのが定番です。


netstat での port 確認

netstat は出力量が多いので適宜 grep などでフィルタするとよいでしょう。port 3000 を listen しているプロセスが存在すれば Local Address の列に :3000 が登場します。(オプションと左側の 0.0.0.0 については後述します。)

$ netstat -an

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
# 中略
tcp 0 0 0.0.0.0:3000 0.0.0.0:* LISTEN
# 後略

上は port 3000 を listen しているプロセスが存在したときの出力例です。何も出てこなかったら残念賞。


lsof での port 確認

lsof の -i オプションに port を指定して狙い撃ちで確認できます。何も出てこなかったら残念賞。

今回の例では port 3000 なので一般ユーザで実行できますが、1024 未満の port を調べるときには特権が必要です。

$ lsof -PRnl -i:3000

COMMAND PID PPID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ruby 94442 92609 61551742 13u IPv4 330705330 0t0 TCP *:3000 (LISTEN)


Case b: アプリの bind が lo になっている

Linux には普通 NIC (Network Interface Card) と lo (local loopback device) の2つ (以上) のネットワークインタフェースがあります。Linux プロセスは同時に複数のインタフェースに bind できますが、意図して限定することもできます。充分に準備ができてないアプリをインターネットに公開して exploit されないようにという親心だと思うのですが、近年は development やデフォルトでは lo にのみ bind するサービス・フレームワークが多いです。Rails もこの例に漏れず development で何もオプションを付けず rails s で起動すると lo bind になります。この状態では Rails が動作しているホストの外からアクセスできません。

参考: developmentでWEBrick起動のRailsはbindオプションでanyアクセスを受け付けるようになる


アプリの NIC Address に curl

NIC の Address を curl で叩いてみます。

$ curl -I -X GET http://`hostname -i`:3000

curl: (7) Failed to connect to 10.249.0.39 port 3000: 接続を拒否されました

$ curl -I -X GET http://localhost:3000
HTTP/1.1 200 OK
Last-Modified: Thu, 01 Nov 2018 08:11:07 GMT
Content-Type: text/html
Content-Length: 0
Server: WEBrick/1.3.1 (Ruby/2.4.2/2017-09-14)
Date: Thu, 29 Nov 2018 03:34:59 GMT
Connection: Keep-Alive

このように localhost なら通るのに NIC Address では拒否されるとき、アプリはたぶん lo に bind しています。

bind 状況も netstat か lsof で確認できます。


netstat での bind 確認

netstat では Local Address 列コロンの左側が bind Address です。

以下の例では 127.0.0.1 に bind しています。この状態だとリモートから接続できません。lo も NIC も受け付けるときはどのように表示されるかというと…… 0.0.0.0 になります。

$ netstat -an

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
# 中略
tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN
# 後略


lsof での bind 確認

lsof ではNAME 列コロンの左側が bind Address です。

$ lsof -PRnl -i:3000

COMMAND PID PPID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ruby 75218 92609 61551742 13u IPv4 330674262 0t0 TCP 127.0.0.1:3000 (LISTEN)

lsof では lo も NIC も ok というときは * が表示されます。

$ lsof -PRnl -i:3000

COMMAND PID PPID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ruby 94442 92609 61551742 13u IPv4 330705330 0t0 TCP *:3000 (LISTEN)


オマケ: WEBRick で全インタフェースに bind させるオプション

WEBRick を開発以外で使うことはないと思いますが -b 0.0.0.0 で全インタフェースに bind して localhost からもリモートからもアクセスできるようになります。

$ bundle exec rails s -b 0.0.0.0


Case c: Reverse proxy とアプリが同一ホストで設定があべこべ

アプリと同一ホストに reverse proxy を配置するときは UNIX domain socket を採用することが多いので production でこのケースはあまりないでしょう。私がハマったのは production に近い構成を開発環境の同一ホストで再現したときです。同一ホストでは reverse proxy とアプリケーションの双方が lo と NIC を指定できるためあべこべにできます。

……いや「できます」って動かないからやっちゃダメです。

netstat, lsof でアプリケーション確認すると、NIC だけに bind していて lo アクセスできないプロセスは以下のように見えます。

$ netstat -an

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:445 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:57853 0.0.0.0:* LISTEN
tcp 0 0 10.249.0.39:3000 0.0.0.0:* LISTEN
# 後略

$ lsof -PRnl -i:3000

COMMAND PID PPID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ruby 101477 92609 61551742 13u IPv4 330716822 0t0 TCP 10.249.0.39:3000 (LISTEN)

このときアプリサーバのローカルから curl で localhost に接続確認すると port が合っていてもアクセスできません。hostname では接続できる。わかりづれぇ!

$ curl -I -X GET http://localhost:3000

curl: (7) Failed to connect to localhost port 3000: 接続を拒否されました

$ curl -I -X GET http://`hostname`:3000
HTTP/1.1 200 OK
Last-Modified: Thu, 01 Nov 2018 08:11:07 GMT
Content-Type: text/html
Content-Length: 0
Server: WEBrick/1.3.1 (Ruby/2.4.2/2017-09-14)
Date: Wed, 28 Nov 2018 11:10:31 GMT
Connection: Keep-Alive

まとめると以下の通りです。

状況
bind

localhost に接続できるがホストの外からアクセスできない
lo (127.0.0.1) のみ

hostname で接続できるが localhost で接続できない
NIC のアドレスのみ

ホスト内外からどのように指定しても ok
any (0.0.0.0)

Nginx では upstream にアプリサーバを記述します。ここで server をなんと書くかで運命が分かれます。

http {

upstreap app_server {
server 何か:3000;
}
}

server と Rails 起動コマンド、疎通の組み合わせは以下の通りです。

Nginx upstreamserver

Rails Server 起動コマンド
疎通

localhost:3000
rails s -b 127.0.0.1
OK

ホスト名:3000
rails s -b 127.0.0.1
NG

localhost:3000
rails s -b `hostname`
NG

ホスト名:3000
rails s -b `hostname`
OK

localhost:3000
rails s -b 0.0.0.0
OK

ホスト名:3000
rails s -b 0.0.0.0
OK

アプリを any でも lo でもなく NIC に bind するのはわかりづらいのでよほどの事情がない限りおすすめしません。


Case d: Reverse proxy の送出する Host ヘッダと下流の期待するヘッダが合っていない

さあ、後半戦ややこしくなってまいりました。Case d ではサンプルを変えて以下の構成で解説します。

case-d.png

アプリケーションサーバは HTTP サーバが port 80 を受け付けて UNIX domain socket でアプリに proxy しているとします。多段 reverse proxy 構成です。

Case d の肝は HTTP サーバのバーチャルサーバ設定です。ここまでは具体的な設定を省いて診断方法だけ解説してきましたが、これは設定例を先に挙げた方がわかりやすそうです。


多段 reverse proxy の設定例

これは運用プラクティスなのですが production で監視をするとき HTTP サーバプロセス死活監視のエンドポイントとアプリケーションサービスは異なるバーチャルサーバにするとよいかもしれません。

Nginx は下層ブロックで設定継承する仕様に癖があり、重要設定は http か server コンテキストに記述してしまい location では触らないのが一つの方針かなと考えています。これについては 12/5 の Advent Calendar で触れます。


Webのnginx.conf

http {

upstream app_server {
server 10.249.0.39;
server 10.249.0.40;
# ...
}
server {
server_name example.com;
# サービスのためのバーチャルサーバ
# 公開用アクセス制御
include acl_for_public.conf;
location / {
proxy_pass http://app_server;
}
}
server {
server_name "" default;
# ヘルスチェック・モニタリングのためのエンドポイント
# 社内限定アクセス制御
include acl_for_internal.conf;
location = /healthcheck {
# ...
}
}
}


アプリのnginx.conf

http {

upstream rails {
server unix:/app/current/tmp/sockets/rails.sock;
}
server {
server_name app_server;
location / {
proxy_pass http://rails;
}
}
server {
server_name "" default;
location = /healthcheck {
# ...
}
}
}

curl でシミュレートすると各リクエストとバーチャルサーバの組み合わせは以下の通りです。

リクエスト
curl コマンド
バーチャルサーバ

Web のサービス
curl -H 'Host:example.com' http://10.255.0.40
example.com

Web Nginx のヘルスチェック
curl http://10.255.0.40/haealthcheck
default

アプリへの reverse proxy
curl -H 'Host:app_server' http://10.255.3.42
app_server

アプリ Nginx のヘルスチェック
curl http://10.255.3.42/healthcheck
default

ここまで来るとハマりそうなところがおわかりでしょうか?

Q: Host: app_server って一体誰だよ!?

A: Web サーバ Nginx の upstream 名だよ


Webのnginx.conf

upstream app_server { # これこれこれ

# ...
}

この例の場合アプリに proxy するバーチャルサーバの定義を


アプリのnginx.conf

server {

server_name example.com;
}

のようにすると NG です。このケースでは間違えたリクエストはデフォルトバーチャルサーバが受け付けるので接続拒否ではなくおそらく Not Found や Forbidden が返ります。


アプリ Nginx のログ確認

アプリの HTTP サーバまで到達してエラーレスポンスが返ってきているなら、HTTP サーバのエラーログを見るのが手っ取り早いです。Nginx では以下のように出力されます。

2018/11/28 23:05:56 [error] 56687#56687: *8 open() "/usr/local/openresty/nginx/html/hoge" failed (2: No such file or directory), client: 10.249.0.39, server: , request: "GET /hoge HTTP/1.1", host: "app_server"

server が振られたバーチャルサーバ、hostHost ヘッダで普通は一致します。この例は Host: app_server というリクエストが来て、マッチするバーチャルサーバがないのでデフォルトに回されたことを意味します。


tcpdump で Host ヘッダを確認

ところが Not Found エラーはログ出力しないのが定番だったりもします……疎通確認フェーズなんだし一時的に設定変更して reload すればよいのですが、サクッと今すぐ見たいのだ!というときは tcpdump をアスキーモードで実行して Host ヘッダを確認するのが手です。他にもいろいろわかるし、私もこういうときはログ見るより tcpdump することが多いです。

sudo tcpdump -A port 80

すでに死活監視が入っている、自分以外にもアクセスしているという状況だとものすごい出力があるので grep などで絞り込むとよいでしょう。

sudo tcpdump -A port 80 | grep Host

この状態で Web サーバかロードバランサにリクエストを投げると Host ヘッダが出力されるはずです。

Host:app_server

Host:app_server
...

以上です。

自分で振り返ると、思い込みせずに淡々と調べたらあまり時間を取られず解決できただろうになあと思います。

明日、4日目は @zettaittenani による「Ansible controller/target を Docker コンテナで構築する」です。


オマケ: コマンド実行例とオプションの解説

オマケで業務でよく使うオプションを解説します。


curl

オプション
解説・意図

-I
レスポンスヘッダのみ出力。ヘッダが見たいときのほか、ボディをだらっと流すのがいやなとき

-X
HTTP メソッドを指定。-I オプションを使うとデフォルトで HEAD になります。アプリが HEAD を受け付けない、挙動が変わるということもあるので

-k
TLS 証明書エラーを無視します。開発者のミカタ、オレオレ証明のお供

-H
HTTP ヘッダを追加します。バーチャルサーバが2種以上の HTTP サーバに直接リクエストを投げて検証するとき Host ヘッダを付与するのが有用


netstat

オプション
解説・意図

-a
すべての State を出力します。デフォルトは connected(ESTABLISHED) のみで、接続待ち、つまりリクエストを処理してないプロセスは出力されません

--numeric-ports

don't resolve port names port をプロトコルに変換しません。アプリが適当な port を使っていると使ってるつもりのないプロトコル名が出てきてびっくりする。というか port で grep できなくて困る

-n

don't resolve names host, port, user どれも変換しないで数値のまま出力。127.0.0.1 とか解決してくれなくていい、横に長い方がいやだという人におすすめ


lsof

オプション
解説・意図

-i
port を指定します。ナニワトモアレこれがなくては

-P
port をプロトコルに変換しません。netstat--numeric-ports と同じ

-n
host を解決しません。こういう記事でホスト名をボカしたいとき ※

-l
user を解決しません。シャイな人向け ※

-R
親プロセスIDをppidとして表示します。master/worker プロセスで処理するミドルウェアを使っていて、かつ master を停止する適切なコマンドを用意していないとき、kill 一発で鏖殺できる親プロセスIDが欲しいことがあります

※ もちろん適宜外した方が使いやすいでしょう


tcpdump

オプション
解説・意図

-A
アスキーで出力。Web アプリの検証では HTTP ヘッダを見たいことが多い

dst port

dst port xxxx でmデスティネーション (宛先) port xxxx に限定。絞らないとあらゆる tcp 通信が出力されて大変なんです、port を間違えている可能性もあるので付け外しして確認

src host

src host xxxx でソース (送り元) ホストを xxxx に限定。調査している自分自身以外も頻繁にアクセスしてる環境で監視する通信を絞り込めそう……と思うかもしれませんが、TCP/IP のソースフィールドは reverse proxy であってリモートホストではないので reverse proxy 疎通調査ではそんなに有用ではありません。直接リクエストが来てるときは有用よ

-i

-i lo でインタフェース lo を監視。指定しないとデフォルトになります。ローカルで TCP/IP reverse proxy しているとき通信は lo が用いられますが、lo はデフォルトにならないのでどんな環境でも指定が必須です


hostname

オプション
解説・意図

-i
IP アドレスを出力します。この記事みたいにコマンド実行例でホスト名を出したくないとき有効です