127.0.0.1に縛ると外からは入れない
一言でいうと
ポートが開いているかという質問は、実は2つです。誰が聞いているかと、どのアドレスで聞いているか。サーバーの中でcurl localhostはできるのに、外からはできないという事故の大半が、2つ目の質問で分かれます。
なぜ必要なのか
アプリケーションをデプロイしました。サーバーに入ってcurl http://localhost:8080/healthを打つと、200が返ってきます。ところが、ロードバランサーは、ずっとヘルスチェックの失敗を報告します。ファイアウォールを確認しても、8080は開いています。
ss -ltnを打ってみると、答えが出ます。
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:*
127.0.0.1:8080は、ループバックアドレスにだけバインドされています。このソケットは、同じホストの中から来た接続だけを受け付けます。外から来るパケットは、カーネルがそもそも渡しません。ファイアウォールの問題ではなく、バインディングの問題です。
この区別は、セキュリティ設計でもあります。管理用ポートやデバッグエンドポイントは、わざとループバックだけにバインドして、外部への公開を根本から遮断します。逆に、サービスポートを誤ってループバックにバインドすると、上の状況になります。
どう動くのか
ssはnetstatの後継で、最新のディストリビューションの標準です。よく使う組み合わせは4つです。
ss -ltn # TCP 리스닝 소켓만, 이름 해석 없이
ss -ltnp # + 어느 프로세스가 잡고 있는지 (root 필요)
ss -tan state established
ss -s # 상태별 요약
リスニングソケットのRecv-QとSend-Qは、意味が違います。確立された接続では、それぞれ「まだ読まれていない受信データ」と「まだ確認されていない送信データ」ですが、リスニングソケットでは、Recv-Qがacceptを待つ完了キューの現在の長さ、Send-Qがバックログの最大値です。そのため、リスニングソケットのRecv-Qがずっと溜まっていれば、アプリケーションがacceptに追いついていないという意味で、ワーカー数やバックログを調整する必要があります。
ソケット状態で、実務的に重要なものは2つです。
- TIME-WAIT: 接続を先に閉じた側に残ります。遅れて届いたパケットが新しい接続に混ざるのを防ぐための安全装置で、カーネルが一定時間後に自動的に片付けます。fdを消費しません。
- CLOSE-WAIT: 相手がFINを送ったのに、こちらが
close()を呼んでいない状態です。カーネルは無限に待ち、タイムアウトがありません。溜まっているなら、それはアプリケーションのバグです。
この2つを混同して、net.ipv4.tcp_tw_reuseのようなパラメーターをいじることがよくありますが、CLOSE-WAITは、カーネルパラメーターでは解決しません。
/proc/net/tcpを読めるようになれば、ツールがない環境でも診断できます。アドレスとポートが16進数で書かれていて(8080は1F90)、状態も16進数のコードです(0A = LISTEN、01 = ESTABLISHED、06 = TIME_WAIT、08 = CLOSE_WAIT)。
現場での姿
「Address already in use」。ポートをすでに誰かが握っています。ss -ltnpでPIDを確認するのが最初の動作で、たいてい、以前のプロセスが完全に終了していない場合です。ここで、むやみにkill -9をする前に、そのプロセスが何なのかを確認する必要があります。
ソケットもfdです。前のコースで見たとおりです。接続が増えればfdも増え、fdの上限に先にぶつかることがあります。ls /proc/PID/fdをreadlinkすると、socket:[12345]の形で現れます。
コンテナの中では、ネームスペースが違います。コンテナの中のssは、そのネームスペースのソケットだけを見せます。ホストで見えるポートと、コンテナの中で見えるポートが違うのは、正常です。
ポートが残っているのに、なぜ使えないのか
bind: Address already in useは、たいてい、ポートを誰かが使っているからではなく、たった今死んだ自分が、まだその場所をつかんでいるから起きます。
TIME_WAITはバグではありません。接続を先に閉じた側は、最後のACKが相手に届いたかを確認する方法がありません。そのため、カーネルは、そのソケットの4タプルを、2*MSL(Linuxでは60秒)の間、確保しておきます。遅れて届いた古い接続のパケットが、同じ4タプルで新しく結んだ接続に混ざり込むのを防ぐ仕組みです。ss -tan state time-wait | wc -lで、いくつ溜まっているかを見られます。
SO_REUSEADDRとSO_REUSEPORTは、別の問題を解きます。前者は、TIME_WAIT状態のアドレスに、もう一度bindできるようにしてくれます。再デプロイのときに60秒を待たなくて済むように使うもので、ほとんどのサーバーフレームワークが、デフォルトでオンにしています。後者は、複数のプロセスが同じポートを同時に聞くようにします。カーネルが4タプルのハッシュで、入ってくる接続を分けてくれるので、前段にプロキシなしで、ワーカーを増やせます。2つの名前が似ていて、混ぜて使いやすいのですが、再デプロイの問題をSO_REUSEPORTで解くと、古いプロセスと新しいプロセスが、同時に生きていて、接続を分け合って受ける状態になります。
受けるキューは2つです。SYNが来ると、カーネルはSYNキューに入れて、SYN+ACKを送ります。ハンドシェイクが終わると、acceptキューに移します。アプリケーションのaccept()が遅いと、acceptキューがいっぱいになり、そのときから、カーネルは完了した接続を黙って捨てます。クライアント側からは、接続が結ばれたように見えるのに、応答がないという、原因を探すのが最も難しい症状になります。ss -tlnpのRecv-Q/Send-Qは、待ち受けソケットでは、それぞれ現在のacceptキューの長さとキューの上限を意味します。2つの数字が近い値なら、キューがあふれている最中で、nstat -az TcpExtListenDropsが、その証拠です。
出発ポートは有限です。あるサーバーが、別の1台のサーバーの1つのポートにだけ接続を結ぶなら、使える4タプルは、出発ポートの数だけです。net.ipv4.ip_local_port_rangeは、デフォルトが32768–60999なので、約2万8千個で、ここにTIME_WAITの60秒が重なると、1秒あたり約470接続で壁にぶつかります。コネクションプールを使う本当の理由が、これです。ポートを広げたり、tcp_tw_reuseをオンにしたりするのは症状の緩和で、接続を再利用することが答えです。
次のラボですること
ローカルにHTTPサーバーを2つ、別々のアドレスにバインドして起動し、ssでバインディングの範囲がどう違うかを確認します。接続を1つつかんでESTABLISHEDを観察し、/proc/net/tcpで16進数のポートを自分で探してみます。リスニングソケットのバックログの値を読み、最後に、「このポートを誰がつかんでいるのか」に答えるスクリプトを作ります。