OpenSSLのSSL_sendfileとパッチを当てたnginxでLinuxのkTLSを試してみた

2021-10-31 追記

再度検証してみたので Linuxのkernel TLSでnginxのSSL_sendfileを試してみた · hnakamur’s blog のほうをご参照ください。

試したきっかけ

Can a Rust web server beat nginx in serving static files? : rust に以下のようなコメントがありました。

余談ですが Suboptimal block sizes · Issue #3 · seanmonstar/futures-fsThe lack of a zero-copy sendfile for ZFS is one of several reasons that we (Netf… | Hacker News を見ると ZFS では sendfile が使えないそうで、 Netflix ではこれが ZFS ではなく UFS を使っている理由の 1 つだそうです。

Linux にも Kernel TLS (kTLS) があるけどどうなんだろうと検索してみると nginx へのパッチを見つけました。

最初のパッチにコメントを受けて改善されたのが 2 番目のパッチです。が、さらにコメントを受けて対応されずそのままになっていました。

上のパッチでは OpenSSL の SSL_sendfile という関数を使っています。 ページ下部の HISTORY に OpenSSL 3.0.0 で追加されたと書いてあります。 これはドキュメントが先行していますが、実際は 3.0.0-alpha1 が 2020-04-23 に出たところです。 OpenSSL 3.0 Alpha1 Release - OpenSSL Blog 。 タグは打たれてないのですが Prepare for release of 3.0 alpha 1 · openssl/openssl@05feb0a のコミットが 3.0.0-alpha1 に対応します。

openssl/ssl/ssl_lib.c at 05feb0a · openssl/openssl を見ると SSL_sendfile は 13 か月前に追加されてから変更は入っていません。追加されたのは ssl: Add SSL_sendfile · openssl/openssl@7c3a756 のコミットでこれは 2019-04-13 でした。

上に貼ったパッチはこのコミット前後に作られていたんですね、早い。

ということで、今回は上記の 2 つ目のパッチを nginx に組み込んで試してみました。

わけもわからない状態から試行錯誤したのですが、全部書くとごちゃごちゃになるのである程度絞って書きます(自分用にはできれば試行錯誤の際に知ったことも記録しておきたいのですが、記事がごちゃごちゃしすぎるので省略)。

試行錯誤の結果、以下の 3 ステップで確認するのが良いことが分かったのでその順に書いていきます。

検証環境

今回上記以外でもう一つ非常に参考にさせていただいた記事が Playing with kernel TLS in Linux 4.13 and Go です。これによると Linux カーネル 4.13 以降なら kTLS が使えるらしいです。

私は今回以下の 2 つの環境で試しましたが、うまく動いたのは後者のみでした(前者で動かない原因は未調査)。

まずカーネルの tls モジュールがロードされているか確認します。

lsmod | grep tls

出力が空の場合は以下のコマンドを実行してロードします。

sudo modprobe tls

試行錯誤の時点では物理サーバーで試していましたが、この記事を書くために一から再検証する際は docker を使いました。

Install Docker Engine on Ubuntu | Docker Documentation の手順を試しましたが focal 用のレポジトリはまだないようでした。 Install Docker Engine from binaries | Docker Documentation から Index of linux/static/stable/x86_64/ を見ると upstream の最新版は 2020-04-29 時点で 19.0.3.8 ですが、 focal の Ubuntu の標準レポジトリでも同じバージョンが入るので、今回はそれを使いました。

sudo apt install -y docker.io

OpenSSL 同梱のテストコードでの動作確認

まず OpenSSL をビルドして test/sslapitest.c 内の test_ktls_sendfile のテストを実行してみます。

ただそのまま実行しても kTLS が使われたのか確認できなかったので、試行錯誤中は気になるところに printf を入れまくって実行しました。

その後 OpenSSL Tracing API というのを見つけたので printf の代わりにこちらを使うようにして見ました。 Add trace category for kTLS · hnakamur/openssl@09f7dd6

また test_ktls_sendfile だけ実行する方法がわからなかったので test/sslapitest.c の他のテストをコメントアウトしました。 Temporarily delete tests other than test_ktls_sendfile · hnakamur/openssl@63e6ece

この変更を加えた OpenSSL をビルドする手順を Dockerfile より抜粋します。 ./config の引数に enable-ktlsenable-trace を指定しています。

git clone https://github.com/hnakamur/openssl \
 && cd openssl \
 && git switch add_trace_category_ktls \
 && ./config enable-ktls enable-trace \
 && make \
 && make install

テストを実行するのは以下のようにします。

sudo docker run --rm -it sslsendfile bash -c 'cd /openssl; make tests TESTS=test_sslapi OPENSSL_TRACE=KTLS'

実行例です。 TRACE メッセージで BIO_set_ktls が成功し、 ktls_sendfile が呼ばれていることが確認できます。

$ sudo docker run --rm -it sslsendfile bash -c 'cd /openssl; make tests TESTS=test_sslapi NO_FIPS=1 V=1 OPENSSL_TRACE=KTLS'
make depend && make _tests
make[1]: Entering directory '/openssl'
make[1]: Leaving directory '/openssl'
make[1]: Entering directory '/openssl'
( SRCTOP=. \
  BLDTOP=. \
  PERL="/usr/bin/perl" \
  EXE_EXT= \
  /usr/bin/perl ./test/run_tests.pl test_sslapi )
90-test_sslapi.t ..
# The results of this test will end up in test-runs/test_sslapi
1..1
    # Subtest: ../../test/sslapitest
    1..1
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x55b9737ac8d0, which=18
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x55b9737ac8d0, which=18
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x55b9737aab10, which=33
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x55b9737aab10, which=33
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x55b9737aab10, which=34
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x55b9737aab10, which=34
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x55b9737ac8d0, which=17
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x55b9737ac8d0, which=17
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: ktls_sendfile ret=16384, s=0x55b9737aab10, wfd=5, fd=3, offset=0, size=16384, flags=0
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: ktls_sendfile ret=16384, s=0x55b9737aab10, wfd=5, fd=3, offset=16384, size=16384, flags=0
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: ktls_sendfile ret=16384, s=0x55b9737aab10, wfd=5, fd=3, offset=32768, size=16384, flags=0
TRACE[80:72:B2:D2:0E:7F:00:00]:KTLS: ktls_sendfile ret=16384, s=0x55b9737aab10, wfd=5, fd=3, offset=49152, size=16384, flags=0
    ok 1 - test_ktls_sendfile
../../util/wrap.pl ../../test/sslapitest ../../test/certs ../../test/recipes/90-test_sslapi_data/passwd.txt /tmp/qTAgg9B5PY default ../../test/default.cnf => 0
ok 1 - running sslapitest
ok
All tests successful.
Files=1, Tests=1,  1 wallclock secs ( 0.03 usr  0.01 sys +  0.66 cusr  0.08 csys =  0.78 CPU)
Result: PASS
make[1]: Leaving directory '/openssl'

Calling BIO_set_ktlsBIO_set_ktls succeeded のメッセージを出力しているのが以下の箇所です。

引用した最後の行に skip_ktls: のラベルがありますが、このコードの上のほうに様々な理由で goto skip_ktls; で飛んで BIO_set_ktls を呼ばないケースがあります。

ssl/t1_enc.c#L525-L534

    /* ktls works with user provided buffers directly */
    OSSL_TRACE2(KTLS, "Calling BIO_set_ktls, s=%p, which=%d\n", s, which);
    if (BIO_set_ktls(bio, &crypto_info, which & SSL3_CC_WRITE)) {
        OSSL_TRACE2(KTLS, "BIO_set_ktls succeeded, s=%p, which=%d\n", s, which);
        if (which & SSL3_CC_WRITE)
            ssl3_release_write_buffer(s);
        SSL_set_options(s, SSL_OP_NO_RENEGOTIATION);
    }

 skip_ktls:

そのうち 2 つを以下に引用します。 TLS のバージョンが 1.2 以外だったり cipher が AES_GCM_128 以外だと kTLS は使われないことが確認できます。

ssl/t1_enc.c#L445-L457

    /* check that cipher is AES_GCM_128 */
    if (EVP_CIPHER_nid(c) != NID_aes_128_gcm
        || EVP_CIPHER_mode(c) != EVP_CIPH_GCM_MODE
        || EVP_CIPHER_key_length(c) != TLS_CIPHER_AES_GCM_128_KEY_SIZE) {
        OSSL_TRACE2(KTLS, "Skip ktls because of cipher, s=%p, which=%d\n", s, which);
        goto skip_ktls;
    }

    /* check version is 1.2 */
    if (s->version != TLS1_2_VERSION) {
        OSSL_TRACE2(KTLS, "Skip ktls because of TLS version not 1.2, s=%p, which=%d\n", s, which);
        goto skip_ktls;
    }

openssl s_servercurl での動作確認

kTLS が使われるケースの検証

まず openssl s_server を以下のように起動します。

sudo docker run --rm -it sslsendfile bash -c 'cd /usr/local/nginx/html; OPENSSL_TRACE=KTLS openssl s_server -WWW -cert /usr/local/nginx/conf/example.com.crt -key /usr/local/nginx/conf/example.com.key -accept 443 -no_tls1_3 -sendfile'

以下のように出力されたらリクエストを受け付ける準備完了です。

Using default temp DH parameters
ACCEPT

別の端末で以下のように curl を実行します。

sudo docker exec -it $(sudo docker ps -q) curl -kv --tlsv1.2 --ciphers AES128-GCM-SHA256 https://localhost/index.html

openssl s_server の端末には以下のようにトレースメッセージが出力され kTLS が使われたことが分かります。

TRACE[80:82:CD:4F:31:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x555aa2d09b30, which=33
TRACE[80:82:CD:4F:31:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x555aa2d09b30, which=33
TRACE[80:82:CD:4F:31:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x555aa2d09b30, which=34
TRACE[80:82:CD:4F:31:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x555aa2d09b30, which=34
FILE:index.html
TRACE[80:82:CD:4F:31:7F:00:00]:KTLS: ktls_sendfile ret=612, s=0x555aa2d09b30, wfd=4, fd=5, offset=0, size=612, flags=0
KTLS SENDFILE 'index.html' OK

curl のほうの端末の出力のうち下記の 1 行で TLSv1.2 と AES128-GCM-SHA256 の cipher が使われたことが確認できます。

* SSL connection using TLSv1.2 / AES128-GCM-SHA256

以下のコマンドを実行して Docker コンテナーを終了します。

sudo docker kill $(sudo docker ps -q)

kTLS が使われないケースその1: TLSv1.2 だが cipher が AES_GCM_128 ではない

まず openssl s_server を以下のように起動します。

sudo docker run --rm -it sslsendfile bash -c 'cd /usr/local/nginx/html; OPENSSL_TRACE=KTLS openssl s_server -WWW -cert /usr/local/nginx/conf/example.com.crt -key /usr/local/nginx/conf/example.com.key -accept 443 -no_tls1_3 -sendfile'

別端末で curl を以下のように実行します。

sudo docker exec -it $(sudo docker ps -q) curl -kv --tlsv1.2 https://localhost/index.html

openssl s_server の端末には以下のように出力されました。

TRACE[80:42:EB:65:D1:7F:00:00]:KTLS: Skip ktls because of cipher, s=0x559306cdab30, which=33
TRACE[80:42:EB:65:D1:7F:00:00]:KTLS: Skip ktls because of cipher, s=0x559306cdab30, which=34
FILE:index.html

curl の端末には以下のように出力されました。 TLSv1.2 ですが cipher は ECDHE-RSA-AES256-GCM-SHA384 が選ばれています。 また openssl s_server-sendfile オプションを指定したのに ktls_sendfile が使えないパターンは想定してないようでエラーになってしまっています。

$ sudo docker exec -it $(sudo docker ps -q) curl -kv --tlsv1.2 https://localhost/index.html
*   Trying 127.0.0.1:443...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server did not agree to a protocol
* Server certificate:
*  subject: C=JP; ST=Osaka; L=Osaka City; CN=example.com
*  start date: Apr 29 11:47:10 2020 GMT
*  expire date: Apr 29 11:47:10 2021 GMT
*  issuer: C=JP; ST=Osaka; L=Osaka City; CN=example.com
*  SSL certificate verify result: self signed certificate (18), continuing anyway.
> GET /index.html HTTP/1.1
> Host: localhost
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
* HTTP 1.0, assume close after body
< HTTP/1.0 200 ok
< Content-type: text/html
<
Error SSL_sendfile 'index.html'
80:42:EB:65:D1:7F:00:00:error:SSL routines:SSL_sendfile:uninitialized:ssl/ssl_lib.c:2046:

以下のコマンドを実行して Docker コンテナーを終了します。

sudo docker kill $(sudo docker ps -q)

kTLS が使われないケースその2: TLSv1.3

まず openssl s_server を以下のように起動します。

sudo docker run --rm -it sslsendfile bash -c 'cd /usr/local/nginx/html; OPENSSL_TRACE=KTLS openssl s_server -WWW -cert /usr/local/nginx/conf/example.com.crt -key /usr/local/nginx/conf/example.com.key -accept 443 -sendfile'

別端末で curl を以下のように実行します。

sudo docker exec -it $(sudo docker ps -q) curl -kv https://localhost/index.html

今度は openssl s_server の端末にはトレースメッセージは何も表示されず、 curl の端末は以下のような出力になりました。 接続には TLSv1.3 と TLS_AES_256_GCM_SHA384 の cipher が使われ、今度も openssl s_server からエラーが返ってきています。

$ sudo docker exec -it $(sudo docker ps -q) curl -kv https://localhost/index.html
*   Trying 127.0.0.1:443...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server did not agree to a protocol
* Server certificate:
*  subject: C=JP; ST=Osaka; L=Osaka City; CN=example.com
*  start date: Apr 29 11:47:10 2020 GMT
*  expire date: Apr 29 11:47:10 2021 GMT
*  issuer: C=JP; ST=Osaka; L=Osaka City; CN=example.com
*  SSL certificate verify result: self signed certificate (18), continuing anyway.
> GET /index.html HTTP/1.1
> Host: localhost
> User-Agent: curl/7.68.0
> Accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* Mark bundle as not supporting multiuse
* HTTP 1.0, assume close after body
< HTTP/1.0 200 ok
< Content-type: text/html
<
Error SSL_sendfile 'index.html'
80:02:AD:3C:5F:7F:00:00:error:SSL routines:SSL_sendfile:uninitialized:ssl/ssl_lib.c:2046:
* Closing connection 0
* TLSv1.3 (OUT), TLS alert, close notify (256):

パッチを当てた nginxcurl での動作確認

記事の冒頭に書いたパッチは一か所 if の後を波括弧で囲む修正が必要でした。 また SSL_sendfile が使えるかどうかを auto/configure で判定するように改善して、パッチにもさらに少し手を入れてみました。

nginx.conf の ssl_protocols の設定を実行時に切り替えるために Docker上のNginxのconfに環境変数(env)を渡すたったひとつの全く優れてない方法(修正:+優れている方法) - Qiita の方法を参考にしました。

また OpenSSL Tracing API の出力を行うためには setup_trace の処理が必要なので、このコードをコピーして hnakamur/ngx_ssl_trace_module という nginx 用のモジュールを作成し、これを利用しました。

nginx を TLSv1.2 で起動します。

sudo docker run --rm -it -e SSL_PROTOCOLS=TLSv1.2 sslsendfile /bin/bash -c "envsubst '\$SSL_PROTOCOLS' < /usr/local/nginx/conf/nginx.conf.template > /usr/local/nginx/conf/nginx.conf && cat /usr/local/nginx/conf/nginx.conf"

TLSv1.2 で cipher を AES128-GCM-SHA256 にして接続すると

sudo docker exec -it $(sudo docker ps -q) curl -kv --tlsv1.2 --ciphers AES128-GCM-SHA256 https://localhost/index.html

nginx のログに以下のようにトレースメッセージが出力され、 kTLS が使われたことが分かりました。

TRACE[40:37:69:2D:1B:7F:00:00]:KTLS: Skip ktls because of count unprocessed records failed, s=0x559fec91acf0, which=33
TRACE[40:37:69:2D:1B:7F:00:00]:KTLS: Calling BIO_set_ktls, s=0x559fec91acf0, which=34
TRACE[40:37:69:2D:1B:7F:00:00]:KTLS: BIO_set_ktls succeeded, s=0x559fec91acf0, which=34
TRACE[40:37:69:2D:1B:7F:00:00]:KTLS: ktls_sendfile ret=612, s=0x559fec91acf0, wfd=3, fd=10, offset=0, size=612, flags=0
127.0.0.1 - - [30/Apr/2020:13:05:03 +0000] "GET /index.html HTTP/2.0" 200 612 "-" "curl/7.68.0"

curl の出力の接続部分は以下の通りでした。

* SSL connection using TLSv1.2 / AES128-GCM-SHA256

次に cipher を指定しない場合を試します。

sudo docker exec -it $(sudo docker ps -q) curl -kv --tlsv1.2 https://localhost/index.html

nginx のログのトレースメッセージは以下のようになり、 kTLS は使われていません。

TRACE[40:37:69:2D:1B:7F:00:00]:KTLS: Skip ktls because of cipher, s=0x559fec91acf0, which=33
TRACE[40:37:69:2D:1B:7F:00:00]:KTLS: Skip ktls because of cipher, s=0x559fec91acf0, which=34

curl の出力の接続部分は以下の通りでした。

* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384

nginx を一旦終了させ、次は以下のように TLSv1.3 で起動します。

sudo docker run --rm -it -e SSL_PROTOCOLS=TLSv1.3 sslsendfile /bin/bash -c "envsubst '\$SSL_PROTOCOLS' < /usr/local/nginx/conf/nginx.conf.template > /usr/local/nginx/conf/nginx.conf && cat /usr/local/nginx/conf/nginx.conf"

そして以下のように curl でアクセスします。

sudo docker exec -it $(sudo docker ps -q) curl -kv https://localhost/index.html

今回は nginx のログにはトレースメッセージは出力されませんでした(上記の openssl s_server のときと同じ)。

curl の出力の接続部分は以下の通りでした。

* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384

openssl s_server の場合と違って、今回のパッチを当てた nginx では kTLS が使われない場合もレスポンスは正しく返していました。

Linux の kTLS はその後 AES_GCM_256 と TLSv1.3 のサポートも追加されていた

コミットのタグを見ると Linux カーネル 5.1 以降で使えるようです。

ということで OpenSSL の SSL_sendfile も対応してほしいところですね。

(2021-10-29 追記) 記事中のリンクを修正しました

masterのコミットで行がずれてたり、自分のfork内のコミットへのリンクを間違って本家のリンクになってたりしたのを修正しました。