TCPのKeepAliveと、HTTPの「Connection:Keep-Alive」、HTTP1.1のパイプライン、HTTP/2の「マルチストリーム?」、あとメジャーなブラウザは「コネクションを6本張るものが多い」っていう話がやっぱり分かってなかったので少し調べ始めたのですが、結果的には多少話を絞ってHTTP1.1のHTTPデーモンに見立てたElasticsearchにいくつかメジャーなHTTPクライアントライブラリで接続して見て様子を確認してみました... という自分メモです。
登場人物
node.js : v12.16.1
express.js: 4.17.1
Elasticsearch: 6.8
axios: 0.21.1
node-fetch: 2.0.2
Elastic社のElasticsearch JavaScript クライアント: 6.8.8
実験用プログラム(foo.js)
上記でいうところのnode.jsで稼働させる実験用プログラムです。
やっていることは単純なのですが、確認するための条件をとりはからうロジックと複数の試験パターンをぐるぐる回すためのロジックがフラットに現れてくることにご注意ください。
起動方法や実験内容
コマンドラインで次のように起動すると、node.jsのExpress.jsの常駐プロセスが起動します。
node foo.js ◆◆◆
◆◆◆: KA、KA_MS、NKA、それ以外の4種類が設定可能です。
KA: http.AgentのkeepAliveプロパティをtrue KA_MS:http.AgentのkeepAliveプロパティをtrue、同maxSocketsプロパティを指定(このコードでは、5を指定) NKA:http.AgentのkeepAliveプロパティをfalse それ以外:http.Agentを明示的に生成しない(各HTTPクライアントライブラリのデフォルトの挙動になることを期待)
起動したnode.jsサーバに対し、次でアクセスします。
http://localhost:3000/?cltype=●●&outer_or_inner=▲▲▲▲
●●: ax、ft、esのいずれかを指定します。それぞれaxios、node-fetch、Elastic社が公開しているElasticsearchのJavaScriptクライアントライブラリを利用して、Elasticsearchに複数回非同期アクセスします。
▲▲▲▲: inner か outerを指定します。前者の場合、●●のライブラリのクライアントインスタンスを、Express.jsの「getルーティング」内で初期化します。 後者の場合は、node.jsのこのアプリが起動する際に初期化して、実際にElasticsearchと検索クエリのやりとりをするのは「getルーティング」内としています。 後述のとおり、私が勝手に想定した挙動とは違う実験結果とはなったのですが、クライアントライブラリのインスタンスの初期化場所次第で、コネクションの使いまわされ具合がどのように変わるのだろうということを調べたく2パターン用意したものです。
なお、実際のところ、node-fetchは、インスタンスをnewする処理と実際にElasticsearchと検索クエリのやりとりをする部分を分ける方法が分からなかったので、「getルーティング」内で初期化と実際の「検索アクセス」を実行するパターンのみなので、innerとouterの指定による区別はありません。
確認方法の考え方は次のような感じです。
末端のクライアントからの1回目のアクセスを受けた、node.jsが、Elasticsearchへの複数回のアクセスを行う、このアクセスの前後で、当該マシン上のTCPコネクションの状況を前後比較し、コネクション数の増減などを見て、「コネクション」の使い回し状況を推し測ろうというものになっています。
実験結果
ということで、◆◆◆ や ●●、▲▲▲▲を変えてみながら、何が起きるか確認して、次のような結果に至っています。字が小さいですね...
3種で比べてみたものの、比較対象の3種のクライアントともに末端ではnode.js標準のhttp/httpsモジュールを利用しているようで、TCPコネクションの利用のされ方という意味では、初期化の際にうけとったhttp.Agentの「keepAlive」での方針に従う、ということでそれぞれ大きな違いがないというところでしょうか。 *1
あと、(私にとって意外なだけで、node.js/JavaScript界隈のシングルスレッドモデルなどもろもろの仕組みなどからするとそうでもないのかもしれませんが)意外なことに、AxiosとFetchについては上記の区分でいうところのグローバルとローカル(クエリパラメータだとinnerとouter)のどちらでクライアントのインスタンスを初期化しても、TCPコネクションの再利用のされぐあいは変わらないみたいです*2。
実験方法・プログラムの補足
上の方でそれとなくふれましたが、3種ともに、(おそらく)http/httpsモジュールを使うところの裏返しですが、http.Agentというクラスを元にした通信用のコンフィグが通るように見えます。
http.Agentの「keepAlive」オプションをtrueにして、httpモジュール(のおそらくrequestメソッド)が呼び出される形でHTTP通信を行うと、request送受信が終わった後も、TCPのコネクションを保持し、次回のHTTP通信に(新規にTCPセッションを開始するのではなく)このコネクションを使うという仕掛けになるようです。
なお、上記のプログラム例では、node.jsのサーバを3000番ポートでlistenさせています。 また、対向のElasticsearchは、9200番ポートで起動させています。
よって、netstat -n コマンドで9200番ポート関係のポートの状態を確認することで、コネクションの生き様がわかるでしょうという観測の仕方になっています。
tcp4 0 0 127.0.0.1.56105 127.0.0.1.9200 ESTABLISHED tcp4 0 0 127.0.0.1.56106 127.0.0.1.9200 ESTABLISHED tcp4 0 0 127.0.0.1.62108 127.0.0.1.9200 ESTABLISHED tcp4 0 0 127.0.0.1.62109 127.0.0.1.9200 ESTABLISHED ... tcp4 0 0 127.0.0.1.9200 127.0.0.1.56105 ESTABLISHED tcp4 0 0 127.0.0.1.9200 127.0.0.1.56106 ESTABLISHED tcp4 0 0 127.0.0.1.9200 127.0.0.1.62108 ESTABLISHED tcp4 0 0 127.0.0.1.9200 127.0.0.1.62109 ESTABLISHED tcp4 0 0 127.0.0.1.9200 127.0.0.1.62110 ESTABLISHED ...
※ node.jsプロセスは常駐するので、所定の試行の前後で、netstat -n のコマンド結果を見て、維持されているTCPセッションの状態を状況証拠的ですが確認できます。
TCPコネクションの有様の理解にあたって今回少し賢くなったこと
TCPコネクションについて、netstat で様子をみてみつつ、できるだけ理屈や仕組みも理解して解釈しようということで、新たに見知ったことなどをつらつらと。
1) UNIX系OSでは、 なんでもファイルで抽象化してくれているので、ソケット通信(この場合はTCPセッション)の1接続に対して、ファイルディスクリプタを1つ使う。 *3
2) なので、ファイルディスクリプタの様子をウォッチしていれば、見えてくるところもある。もちろん深く掘り下げるなら、netstatとかの方が良いでしょう。
3) HTTP1.1サーバに見立てた、Elasticsearch(以下Es)では、Esがオープンしている「ファイルディスクリプタ」、およびオープン可能な最大数が、次のAPIで確認できる。
今回、netstatを表示してみつつも、一応、kibanaでElasticsearchのアクティブなファイルディスクリプタ 数の増減も確認して、netstatの様子と呼応していることがみて取れました。
なお、最大オープン可能数は、Linux自体は、ulimit他で調整できますし、Elasticsearchとしては次の説明の設定で変更できます。
4) HTTP1.1 のコネクション制御モデル(Short-lived、Persistent、Pipeline)
これまで、ブラウザ界隈できくHTTP1.1の話かHTTP/2の話かはよくわからず、(HTTPはセッションごとにTCPコネクションを張るものの、そのオーバーヘッドが大きく、新しいHTTPではコネクションを再利用する云々...というところから)当初、HTTP1.1のPipeline(以下パイプライン)の様を見ることができるのかなと想像していましたが、先述のとおり、今回のサーバ間接続の実験モデルでは、どちらかと言えば、気ままにTCPコネクション数を増やして並行度をあげておき、以降はコネクションを貼り続けたままにするというアプローチに見える。... でした。
なお、ブラウザとサーバの間だけかもしれないが、「パイプライン」は失敗だったという評価らしいので、パイプライン関連のトラブルではという心配はしなくて良さそう。 (関連ワード HOL Blocking)
※「パイプライン」は思ったより使われていないということは、同じTCPコネクションを使うことになった複数のHTTPセッションについては、同じTCPコネクション上でシリアライズされるので、多分だが長めのタスクはいつもほどほどのボトルネックとして表に現れやすいので、下手にパイプライン化されて出たり出なかったりと覆いかぶされるよりは、比較的挙動が想像しやすい、対処すれば効果が出やすい、効果が出る期待があるので少し無理をしてみようという気にもなるな...と捉えられるなとも思ったりしました。
5)HTTP/2 で、ひとつのTCPセッション上で複数のHTTPのリクエストを同時に扱うために、「ストリーム」の考え方が導入されている。 HTTP1.1の「パイプライン」ではない! 別物。HTTP1.1より前のパケットには HTTP/2のパケット(フレーム)では、ストリーム識別用のStream Identifierというフィールドがある。
6) 時代はすでにHTTP/3か? ただ、LAN内であれば、太いコネクションを本数をほどほどに抑えてというアプローチでHTTP1.1ベースで掘り下げてチューニングするのもありかと思った。 ただ、「サーバレス」とか「コンテナ」みたいなマイクロサービスっぽいところだとサーバ間(サーバレスと言ったのに奇妙な言い方ですが...)でも、今までのブラウザとサーバのHTTPの使い方に似た形になってきそうな気もするので、頭を柔らかく保っておく必要がありそう。