OpenResty 的核心:cosocket
API7.ai
October 28, 2022
今日は、OpenRestyのコア技術であるcosocketについて学びます。
これまでの記事で何度も言及してきたように、cosocketはさまざまなlua-resty-*
非ブロッキングライブラリの基盤です。cosocketがなければ、開発者はLuaを使って外部のウェブサービスに迅速に接続することができません。
OpenRestyの初期バージョンでは、Redisやmemcachedのようなサービスとやり取りするためには、redis2-nginx-module
、redis-nginx-module
、memc-nginx-module
といったCモジュールを使用する必要がありました。これらのモジュールは、OpenRestyのディストリビューションにまだ含まれています。
しかし、cosocket機能が追加されたことで、Cモジュールはlua-resty-redis
とlua-resty-memcached
に置き換えられました。現在では、外部サービスに接続するためにCモジュールを使用する人はいません。
cosocketとは何か?
では、cosocketとは一体何でしょうか?cosocketはOpenRestyにおける専門用語です。cosocketという名前は、coroutine + socket
から成り立っています。
cosocketは、Luaの並行処理機能とNGINXの基本的なイベントメカニズムを組み合わせて、非ブロッキングネットワークI/Oを実現します。cosocketはTCP、UDP、Unix Domain Socketをサポートしています。
OpenRestyでcosocket関連の関数を呼び出すと、内部的には以下の図のような処理が行われます。
この図は、以前の記事「OpenRestyの原理と基本概念」でも使用しました。図からわかるように、ユーザーのLuaスクリプトによってトリガーされるネットワーク操作ごとに、コルーチンのyield
とresume
が行われます。
ネットワークI/Oに遭遇すると、ネットワークイベントをNGINXのリスナーリストに登録し、制御をNGINXに移します(yield)。NGINXのイベントがトリガー条件に達すると、コルーチンを再開して処理を続行します(resume)。
上記のプロセスは、OpenRestyが現在提供しているcosocket APIを構成するconnect、send、receiveなどの操作をカプセル化するための設計図です。ここでは、TCPを扱うAPIを例に挙げます。UDPやUnix Domain Socketを制御するインターフェースもTCPと同じです。
cosocket APIとコマンドの紹介
TCP関連のcosocket APIは以下のカテゴリに分類できます。
- オブジェクトの作成:
ngx.socket.tcp
- タイムアウトの設定:
tcpsock:settimeout
とtcpsock:settimeouts
- 接続の確立:
tcpsock:connect
- データの送信:
tcpsock:send
- データの受信:
tcpsock:receive
、tcpsock:receiveany
、tcpsock:receiveuntil
- コネクションプーリング:
tcpsock:setkeepalive
- 接続のクローズ:
tcpsock:close
また、これらのAPIが使用できるコンテキストにも特に注意する必要があります。
rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_
さらに、NGINXカーネルのさまざまな制限により、多くの環境でcosocket APIが使用できない点も強調したいです。例えば、set_by_lua*
、log_by_lua*
、header_filter_by_lua*
、body_filter_by_lua*
ではcosocket APIは使用できません。また、init_by_lua*
とinit_worker_by_lua*
では現在使用できませんが、NGINXカーネルはこれらのフェーズを制限していないため、将来的にサポートが追加される可能性があります。
これらのAPIに関連するlua_socket_
で始まる8つのNGINXコマンドがあります。簡単に見てみましょう。
lua_socket_connect_timeout
: 接続タイムアウト、デフォルトは60秒。lua_socket_send_timeout
: 送信タイムアウト、デフォルトは60秒。lua_socket_send_lowat
: 送信閾値(低水位)、デフォルトは0。lua_socket_read_timeout
: 読み取りタイムアウト、デフォルトは60秒。lua_socket_buffer_size
: データ読み取り用バッファサイズ、デフォルトは4k/8k。lua_socket_pool_size
: コネクションプールサイズ、デフォルトは30。lua_socket_keepalive_timeout
: コネクションプール内のcosocketオブジェクトのアイドル時間、デフォルトは60秒。lua_socket_log_errors
: cosocketエラーが発生したときにログに記録するかどうか、デフォルトはon
。
ここでも、APIとコマンドが同じ機能を持つものがあることがわかります。例えば、タイムアウトやコネクションプールサイズの設定などです。ただし、両者が競合する場合、APIがコマンドよりも優先され、コマンドで設定された値を上書きします。したがって、一般的にはAPIを使用して設定を行うことを推奨します。これにより、より柔軟な設定が可能です。
次に、具体的な例を見て、これらのcosocket APIの使用方法を理解しましょう。以下のコードの機能はシンプルで、ウェブサイトにTCPリクエストを送信し、返された内容を出力します。
$ resty -e 'local sock = ngx.socket.tcp()
sock:settimeout(1000) -- 1秒のタイムアウト
local ok, err = sock:connect("api7.ai", 80)
if not ok then
ngx.say("failed to connect: ", err)
return
end
local req_data = "GET / HTTP/1.1\r\nHost: api7.ai\r\n\r\n"
local bytes, err = sock:send(req_data)
if err then
ngx.say("failed to send: ", err)
return
end
local data, err, partial = sock:receive()
if err then
ngx.say("failed to receive: ", err)
return
end
sock:close()
ngx.say("response is: ", data)'
このコードを詳しく分析してみましょう。
- まず、
ngx.socket.tcp()
を使用して、sock
という名前のTCP cosocketオブジェクトを作成します。 - 次に、
settimeout()
を使用してタイムアウトを1秒に設定します。ここでのタイムアウトは、接続と受信を区別せず、一律で設定されます。 - その後、
connect()
APIを使用して指定されたウェブサイトのポート80に接続し、失敗した場合は終了します。 - 接続が成功した場合、
send()
を使用して構築したデータを送信し、失敗した場合は終了します。 - データの送信が成功した場合、
receive()
を使用してウェブサイトからのデータを受信します。ここで、receive()
のデフォルトパラメータは*l
で、最初の1行のデータのみを返します。パラメータを*a
に設定すると、接続が閉じられるまでデータを受信します。 - 最後に、
close()
を呼び出してソケット接続を明示的に閉じます。
このように、cosocket APIを使用してネットワーク通信を行うのは、わずか数ステップで簡単です。さらに、いくつかの調整を加えて、この例を深く探ってみましょう。
1. ソケット接続、送信、読み取りの各アクションに対してタイムアウト時間を個別に設定する。
settimeout()
を使用してタイムアウト時間を単一の値に設定しました。各アクションのタイムアウト時間を個別に設定するには、settimeouts()
関数を使用する必要があります。例えば、以下のように設定します。
sock:settimeouts(1000, 2000, 3000)
settimeouts
のパラメータはミリ秒単位です。このコードは、接続タイムアウトを1
秒、送信タイムアウトを2
秒、読み取りタイムアウトを3
秒に設定します。
OpenRestyやlua-restyライブラリでは、時間関連のAPIのパラメータのほとんどがミリ秒単位です。ただし、呼び出す際には例外もあるため、特に注意が必要です。
2. 指定されたサイズの内容を受信する。
先ほど述べたように、receive()
APIは1行のデータを受信するか、連続してデータを受信することができます。しかし、10Kのサイズのデータのみを受信したい場合、どのように設定すればよいでしょうか?
そのようなニーズに対応するために、receiveany()
が設計されています。以下のコードを見てみましょう。
local data, err, partial = sock:receiveany(10240)
このコードは、最大10Kのデータのみを受信することを意味します。
もちろん、receive()
に対するもう一つの一般的なユーザー要件は、指定された文字列に遭遇するまでデータを取得し続けることです。
receiveuntil()
は、このような問題を解決するために設計されています。receive()
やreceiveany()
のように文字列を返すのではなく、イテレータを返します。これにより、ループ内で呼び出して、一致したデータをセグメントごとに読み取り、読み取りが完了するとnilを返します。以下はその例です。
local reader = sock:receiveuntil("\r\n")
while true do
local data, err, partial = reader(4)
if not data then
if err then
ngx.say("failed to read the data stream: ", err)
break
end
ngx.say("read done")
break
end
ngx.say("read chunk: [", data, "]")
end
receiveuntil
は、\r\n
の前のデータを返し、イテレータを通じて一度に4バイトずつ読み取ります。
3. ソケットを直接閉じるのではなく、コネクションプールに戻す。
コネクションプールがない場合、新しい接続を作成する必要があり、リクエストが来るたびにcosocketオブジェクトが作成され、頻繁に破棄されるため、不要なパフォーマンスの低下が発生します。
この問題を回避するために、cosocketの使用が終了したら、setkeepalive()
を呼び出してコネクションプールに戻すことができます。以下のように設定します。
local ok, err = sock:setkeepalive(2 * 1000, 100)
if not ok then
ngx.say("failed to set reusable: ", err)
end
このコードは、接続のアイドル時間を2
秒、コネクションプールのサイズを100
に設定します。これにより、connect()
関数が呼び出されると、まずコネクションプールからcosocketオブジェクトが取得されます。
ただし、コネクションプールを使用する際には、以下の2点に注意する必要があります。
- まず、エラーが発生した接続をコネクションプールに戻すことはできません。そうしないと、次回使用する際にデータの送受信に失敗します。これが、各API呼び出しが成功したかどうかを確認する必要がある理由の一つです。
- 次に、接続数を把握する必要があります。コネクションプールは
Worker
レベルであり、各Workerが独自のコネクションプールを持っています。10個のWorker
があり、コネクションプールのサイズが30
に設定されている場合、バックエンドサービスに対して300の接続が存在することになります。
まとめ
まとめると、cosocketの基本概念、関連コマンド、およびAPIについて学びました。実践的な例を通じて、TCP関連のAPIの使用方法に慣れました。UDPとUnix Domain Socketの使用方法もTCPと同様です。今日学んだ内容を理解すれば、これらの質問に簡単に対処できるでしょう。
cosocketは比較的使いやすく、うまく活用することでさまざまな外部サービスに接続できます。
最後に、2つの質問を考えてみましょう。
1つ目の質問は、今日の例ではtcpsock:send
が文字列を送信していますが、文字列で構成されたテーブルを送信する必要がある場合、どうすればよいでしょうか?
2つ目の質問は、cosocketが多くのフェーズで使用できないことがわかりますが、これを回避する方法を考えられるでしょうか?
ぜひコメントを残して、私と共有してください。この記事を同僚や友人と共有して、一緒にコミュニケーションを取って進歩しましょう。