非 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: trueallowPrivilegeEscalation: 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 化を進めている方の参考になればうれしいです。