非 root の nginx が 80 に bind できたのは capability のおかげではなかった

目次

1. はじめに

コンテナイメージのビルドを Dockerfile から Cloud Native Buildpacks に移したのに合わせて、nginx を非 root で動かすようにしました。そのとき、ポート 80 で待ち受けられるのは CAP_NET_BIND_SERVICE のおかげだと思っていました。ところが実際に確かめると、capability を追加しなくても 80 に bind できていて、それを可能にしていたのはコンテナランタイムが設定する sysctl でした。

非 root 化そのものは、セキュリティのためによく勧められる作業です。ただ、nginx には、root で動いているかどうかで挙動が変わる部分があります。今回つまずいたのも、そうした「root のときは問題にならなかった」部分でした。

Kubernetes で nginx などを非 root 化したい SRE やインフラエンジニアの方を想定して、次の 3 点を、ソースとドキュメントを読みながら整理してみます。

  • listen を書かないと、非 root では *:8000 で待ち受けてしまうこと
  • 非 root でも 80 に bind できる本当の理由
  • 8080 にしておいたことが、結果としてよかった理由

2. listen を省略すると、非 root では 8000 になる

nginx の公式ドキュメントによると、listen を省略した server ブロックは、nginx が root で動いていれば *:80、そうでなければ *:8000 で待ち受けます。ソースでも、ngx_http_core_server() の中で listen のない server ブロックについて getuid() を見て、root なら 80、そうでなければ 8000 を選んでいることを確認しました。

たとえば、listen 80 default_server; を書いたブロックと、listen を書かずに server_name app.example.com; だけを書いたブロックがあるとします。root で動かしている間は、後者も暗黙に 80 で待ち受けるので、app.example.com 宛てのリクエストは期待どおり後者に届きます。

ところが、設定を一切変えずに非 root にすると、後者は 8000 に移ります。80 に届いた app.example.com 宛てのリクエストは、80 で待ち受けている default_server のブロックが受け取ります(nginx がリクエストを振り分ける仕組み)。

返ってくるのは接続エラーではなく、default_server 側の応答です。そのブロックにそのホスト向けの設定がなければ、404 などが返ります。プロセスは起動しており、ポート 80 も開いていて、ヘルスチェックも設定によっては成功してしまいます。「つながるのに 404」になるので、原因に気づきにくいのが厄介です。気づくきっかけとしては、コンテナの中で待ち受けているポートを ss -ltn などで一覧にして、想定していない 8000 が開いていないかを見ると、手早く確認できます。

対策としては、すべての server ブロックで listen を明示しておくのが確実だと思います。root で動かしているうちから明示しておけば、実行ユーザーが変わっても挙動は変わりません。

3. 「capability のおかげ」という思い込み

当時の理解はこうでした。コンテナランタイムがデフォルトで付ける capability には CAP_NET_BIND_SERVICE が含まれている。だから非 root でも 80 に bind できる。前半は正しく、containerd のデフォルトの capability の一覧にもこの名前があります。デフォルトで入っていると分かってはいましたが、念のため Pod の capabilities.add にも NET_BIND_SERVICE を明示していました。間違っていたのは、後半の「だから」のところでした。

実測で分かったこと

capability を何も add していない Pod で、nginx のプロセスを調べました。確認したのは次の 3 点です。

  • nginx は 80 に bind できた
  • /proc/<pid>/status の CapBnd(bounding set)には NET_BIND_SERVICE が含まれていたが、CapEff(effective)は 0 だった
  • Pod の中の net.ipv4.ip_unprivileged_port_start は 0 だった

bounding set は、そのプロセスが持てる capability の上限です。カーネルが権限をチェックするときに見るのは effective のほうで、これが 0 なら、使える capability は一つもありません。つまり NET_BIND_SERVICE は「持とうと思えば持てる」だけの状態で、実際には使われていないのに、80 に bind できていたことになります。

それを可能にしていたのが、3 つ目の sysctl です。

80 に bind できた仕組み

カーネルのドキュメントによると、ip_unprivileged_port_start は「ここから上を非特権ポートとみなす」境界で、デフォルトは 1024 です。これより小さいポートに bind するには root か CAP_NET_BIND_SERVICE が要ります。0 にすれば、特権ポートという区別そのものがなくなります。

containerd 2.0 以降の CRI プラグイン(kubelet からの指示でコンテナを起動する部分)では、EnableUnprivilegedPorts という設定がデフォルトで有効で、containerd の設定を変えていなければ、hostNetwork を使わない Pod はこの値を 0 にして起動されます。1.7 までは、この設定はデフォルトで無効でした。

なお、Docker も 20.10 以降、ホストとネットワークを共有しないコンテナでは、dockerd 自身が同じ値を設定しています。

0 にしても影響が限られる理由

「特権ポートをなくす」と聞いて、私も最初は乱暴な設定だと感じました。調べてみると、影響の範囲は限られていました。

この sysctl はネットワーク namespace ごとの設定です。Pod の中で 0 にしても、影響はその Pod の namespace の中にとどまり、ノードには及びません。もともと Pod は自分専用のネットワーク namespace と IP アドレスを持っているので、その中で 80 を誰が使えるかは、Pod の外にはほとんど関係がありません。

ただし、hostNetwork の Pod はノードとネットワーク namespace を共有するので、ここで 0 にするとノード全体に影響します。containerd は、hostNetwork の Pod にはこの値を設定しません。また、Pod 側でこの sysctl を明示している場合は、その値を上書きしません。どちらも、先ほど挙げた containerd のソースで確認しました。

capability を追加しても effective に入らない理由(推定)

ここは Linux の capability を勉強しながら調べた部分なので、理解が足りていないところがあるかもしれません。

capabilities(7) を読んだ範囲では、非 root のプロセスが capability を effective に持つには、実行ファイルに file capability(setcap で付けるもの)が付いているか、ambient capability(非 root のプロセスでも、プログラムを起動し直したあとまで引き継げる capability)が設定されている必要があるようです。bounding set はあくまで「持てる上限」なので、そこに NET_BIND_SERVICE があるだけでは effective にならない、と理解しています。CapEff が 0 だったのも、このためだと思います。

Kubernetes の Pod spec の capabilities には add と drop しかなく、ambient を指定する項目はありません(API リファレンス)。なので、capabilities.add で明示していても、effective には入っていなかったのではないかと考えています。ただ、add していた期間の CapEff は測っていないので、ここは推定です。

なお、デフォルト設定の containerd 1.7 のノードでは sysctl が 0 にならないので、80 には bind できないはずです(1.7 では試していません)。Linux の権限まわりもまだまだ分かっていないことが多いので、引き続き勉強していきたいと思います。

4. 8080 に移す

非 root 化は、計画の段階から、80 と 8080 の共存期間を経て 8080 に移す予定にしていました。理由は、非特権ポートで待ち受けるというベストプラクティスに合わせるためです。

3 章で書いたことが分かったのは、その後です。8080 を選んだ理由はあくまでベストプラクティスでしたが、結果として、8080 にしたことで capability にも sysctl にも containerd のバージョンにも依存しない形になりました。

最終的な設定

8080 なら capability を何も追加せず、drop: ["ALL"] だけで済むので、Pod Security Standards の restricted(3 段階あるうちもっとも厳しいもの)が求める securityContext の条件にもそのまま合わせられます。最終的な設定は次のとおりです。

  • runAsNonRoot: true
  • allowPrivilegeEscalation: false
  • capability は drop: ["ALL"] だけ
  • seccompProfile は RuntimeDefault
  • 待ち受けるポートは 8080

どれも Kubernetes のデフォルトではないので、明示的に指定しています。capability を一つも追加せず、sysctl にも頼っていないので、どのノードでも同じように動くはずです。

移行の仕方

移行中は、同じ server ブロックに listen 80; と listen 8080; を並べて、両方で待ち受ける期間を挟みました。ローリングアップデートの間は、80 しか開いていない古い Pod と、新しい Pod が混在します。新しい Pod が両方のポートで応答できれば、Service の targetPort などを 80 から 8080 に切り替えるタイミングを、Pod の入れ替えと切り離して決められます。

5. ポート以外に直したもの

ポート以外にも、root を前提にしていた設定がいくつかありました。

  • 今回のイメージでは /var/run や /var/cache/nginx に非 root のユーザーが書き込めないため、pid を /tmp に移し、一時ファイルのパスを相対パスにした
  • 非 root で動くので、user ディレクティブを削除した

どちらも、root で動いていたときには意識しなくても済んでいたものです。ポートと同じく、非 root 化のときにまとめて見直しておくと安心だと思います。

6. まとめ

  • 非 root 化するなら、listen は明示しておくのがよさそうです。省略すると、実行ユーザーによって 80 にも 8000 にもなり、「つながるのに 404」という分かりにくい症状になります。
  • 「動いている」ことと「理由を理解している」ことは別です。実装を読んだうえで /proc を見てみて、自分の思い込みに気づけました。今回は、capability だと思っていたものが sysctl でした。
  • 暗黙の挙動に頼らず、最終形は 8080 で capability なしにしておくのが安心だと思います。コンテナランタイムのバージョンや設定に左右されません。

私自身もまだ勉強中ですが、同じように非 root 化を進めている方の参考になればうれしいです。

参照元