非阻塞I/O - OpenResty性能提升的关键

API7.ai

December 2, 2022

OpenResty (NGINX + Lua)

パフォーマンス最適化の章では、OpenRestyにおけるパフォーマンス最適化のすべての側面を解説し、前の章で触れた断片的な情報をまとめて、包括的なOpenRestyコーディングガイドを提供します。これにより、より高品質なOpenRestyコードを書くことができるようになります。

パフォーマンスを向上させることは簡単ではありません。システムアーキテクチャの最適化、データベースの最適化、コードの最適化、パフォーマンステスト、フレームグラフ分析などのステップを考慮する必要があります。しかし、パフォーマンスを低下させることは簡単で、今日の記事のタイトルが示すように、わずか数行のコードを追加するだけで、パフォーマンスを10倍以上も低下させることができます。もしOpenRestyを使ってコードを書いているのにパフォーマンスが向上しないのであれば、それはおそらくブロッキングI/Oが原因です。

したがって、パフォーマンス最適化の具体的な内容に入る前に、OpenRestyプログラミングにおける重要な原則を見てみましょう:非ブロッキングI/Oを優先する。

私たちは子供の頃から、火遊びをしないことやプラグに触らないことを親や先生から教えられてきました。これらは危険な行為です。OpenRestyにも同じような危険な行為が存在します。もしコード内でブロッキングI/O操作を行わなければならない場合、パフォーマンスが劇的に低下し、OpenRestyを使って高性能サーバーを構築する本来の目的が台無しになります。

なぜブロッキングI/O操作を使ってはいけないのか?

どのような行為が危険で、それを避けることがパフォーマンス最適化の第一歩です。まず、なぜブロッキングI/O操作がOpenRestyのパフォーマンスに影響を与えるのかを振り返ってみましょう。

OpenRestyが高いパフォーマンスを維持できるのは、NGINXのイベント処理とLuaのコルーチンを借用しているからです。したがって:

  • ネットワークI/Oなどの操作で、返りを待たなければならない場合、Luaコルーチンのyieldを呼び出して自身を中断し、NGINXにコールバックを登録します。
  • I/O操作が完了した後(またはタイムアウトやエラーが発生した後)、NGINXはresumeを呼び出してLuaコルーチンを再開します。

このようなプロセスにより、OpenRestyは常にCPUリソースを効率的に利用してすべてのリクエストを処理できます。

この処理フローにおいて、LuaJITはcosocketのような非ブロッキングI/O方式を使用せずにブロッキングI/O関数を使用してI/Oを処理すると、NGINXのイベントループに制御を渡しません。その結果、他のリクエストはブロッキングI/Oイベントが処理されるのを待たなければならず、レスポンスが遅れます。

まとめると、OpenRestyプログラミングでは、I/Oをブロックする可能性のある関数呼び出しに特に注意を払う必要があります。そうでなければ、たった1行のブロッキングI/Oコードがサービス全体のパフォーマンスを低下させる可能性があります。

以下では、いくつかの一般的な問題、よく誤用されるブロッキングI/O関数を紹介します。また、最も簡単な方法で「台無しにする」方法を体験し、サービスのパフォーマンスを10倍も低下させる方法を見ていきましょう。

外部コマンドの実行

多くのシナリオで、開発者はOpenRestyを単なるWebサーバーとして使用するだけでなく、より多くのビジネスロジックを付与します。この場合、外部コマンドやツールを呼び出して、いくつかの操作を完了する必要があるかもしれません。

例えば、プロセスを終了させる場合。

os.execute("kill -HUP " .. pid)

または、ファイルのコピーやOpenSSLを使用した鍵の生成など、より時間のかかる操作の場合。

os.execute(" cp test.exe /tmp ")

os.execute(" openssl genrsa -des3 -out private.pem 2048 ")

表面上、os.executeはLuaの組み込み関数であり、Luaの世界では確かに外部コマンドを呼び出す方法です。しかし、Luaは組み込みプログラミング言語であり、他のコンテキストでは異なる推奨される使用方法があります。

OpenRestyの環境では、os.executeは現在のリクエストをブロックします。したがって、このコマンドの実行時間が特に短い場合、影響はあまり大きくありません。しかし、コマンドの実行に数百ミリ秒や数秒かかる場合、パフォーマンスが劇的に低下します。

問題を理解したので、どのように解決すべきでしょうか?一般的に、2つの解決策があります。

1. FFIライブラリが利用可能な場合、FFI方式を優先する

例えば、上記のOpenSSLコマンドラインを使用して鍵を生成した場合、FFIを使用してOpenSSLのC関数を呼び出すように変更できます。

プロセスを終了させる場合、OpenRestyに付属するlua-resty-signalライブラリを使用して非ブロッキングで解決できます。以下のコード実装です。もちろん、ここではlua-resty-signalFFIを使用してシステム関数を呼び出しています。

local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")

また、LuaJITの公式サイトには、特定のページがあり、さまざまなFFIバインディングライブラリがカテゴリ別に紹介されています。例えば、画像処理、暗号化、復号化などのCPU集約的な操作を扱う場合、まずそこに行って、直接使用できるようにカプセル化されたライブラリがあるかどうかを確認できます。

2. ngx.pipeベースのlua-resty-shellライブラリを使用する

前述のように、shell.runでコマンドを実行し、非ブロッキングI/O操作を行うことができます。

$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
    shell.run([[echo "hello, world"]])
    ngx.say(stdout) '

ディスクI/O

次に、ディスクI/Oを扱うシナリオを見てみましょう。サーバーサイドアプリケーションでは、ローカルの設定ファイルを読み取ることは一般的な操作です。例えば、以下のコードです。

local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()

このコードはio.openを使用して特定のファイルの内容を取得しています。しかし、これはブロッキングI/O操作です。ただし、実際のシナリオでは考慮すべき点があります。したがって、initinit workerで呼び出す場合、これは1回限りのアクションであり、クライアントリクエストに影響を与えないため、完全に許容されます。

もちろん、すべてのユーザーリクエストがディスクの読み取りや書き込みをトリガーする場合、これは許容できません。その時点で、解決策を真剣に検討する必要があります。

まず、lua-io-nginx-moduleというサードパーティのCモジュールを使用できます。これはOpenRestyに非ブロッキングI/OのLua APIを提供しますが、cosocketのように自由に使用することはできません。なぜなら、ディスクI/Oの消費は理由もなく消えるわけではなく、単に異なる方法で行われるからです。

このアプローチが機能するのは、lua-io-nginx-moduleがNGINXのスレッドプーリングを利用して、ディスクI/O操作をメインスレッドから別のスレッドに移動し、メインスレッドがディスクI/O操作によってブロックされないようにするためです。

このライブラリを使用するには、Cモジュールであるため、NGINXを再コンパイルする必要があります。使用方法はLuaのI/Oライブラリと同じです。

local ngx_io = require "ngx.io"
local path = "/conf/apisix.conf"
local file, err = ngx_io.open(path, "rb")
local data, err = file: read("*a")
file:close()

次に、アーキテクチャの調整を試みます。この種のディスクI/Oに対して、ローカルディスクへの読み書きをやめる方法に変更できないでしょうか?

例を挙げて、類推して学べるようにします。数年前、私はローカルディスクにログを記録して統計やトラブルシューティングを行う必要があるプロジェクトに取り組んでいました。

当時、開発者はngx.logを使用してこれらのログを記録していました。以下のように。

ngx.log(ngx.WARN, "info")

このコード行はOpenRestyが提供するLua APIを呼び出しており、問題がないように見えます。しかし、欠点は、これを頻繁に呼び出すことができないことです。まず、ngx.log自体がコストの高い関数呼び出しです。次に、バッファがあっても、大量で頻繁なディスク書き込みはパフォーマンスに深刻な影響を与えます。

では、どのように解決するのでしょうか?元のニーズに戻りましょう。統計、トラブルシューティング、ローカルディスクへのログ書き込みは、目標を達成するための手段の1つに過ぎませんでした。

したがって、ログをリモートのログサーバーに送信し、cosocketを使用して非ブロッキングのネットワーク通信を行うこともできます。つまり、ブロッキングディスクI/Oをログサービスに投げて、外部サービスをブロックしないようにします。lua-resty-logger-socketを使用してこれを行うことができます。

local logger = require "resty.logger.socket"
if not logger.initted() then
    local ok, err = logger.init{
        host = 'xxx',
        port = 1234,
        flush_limit = 1234,
        drop_limit = 5678,
    }
local msg = "foo"
local bytes, err = logger.log(msg)

お気づきの通り、上記の2つの方法は同じです。ブロッキングI/Oが避けられない場合、メインワーカースレッドをブロックしないようにし、他のスレッドや外部サービスに投げるということです。

luasocket

最後に、luasocketについて話しましょう。これはLuaの組み込みライブラリで、開発者が簡単に使用でき、OpenRestyが提供するcosocketと混同されることがよくあります。luasocketもネットワーク通信機能を実行できますが、非ブロッキングの利点はありません。その結果、luasocketを使用すると、パフォーマンスが劇的に低下します。

しかし、luasocketにも独自の使用シナリオがあります。例えば、cosocketが利用できないいくつかのフェーズがあることを覚えているでしょうか?通常、ngx.timerを使用してこれを回避できます。また、init_by_lua*init_worker_by_lua*のような1回限りのフェーズでcosocketの機能をluasocketで使用することもできます。OpenRestyとLuaの類似点と相違点に詳しくなればなるほど、このような興味深い解決策を見つけることができます。

さらに、lua-resty-socketluasocketcosocketを互換性のあるものにするオープンソースライブラリの二次ラッパーです。この内容もさらに研究する価値があります。もし興味があれば、資料を用意していますので、引き続き学習してください。

まとめ

一般的に、OpenRestyでは、ブロッキングI/O操作の種類とその解決策を認識することが、良いパフォーマンス最適化の基礎です。では、実際の開発で同様のブロッキングI/O操作に遭遇したことはありますか?どのようにそれを見つけて解決しましたか?コメントで経験を共有してください。また、この記事を自由に共有してください。