今日はあまり気分が良くなく、家で一日休みを取りました。最近、いくつかの小さな問題で、コンテナ内の UID に関することを見直したので、そのことについて簡単にお話しします。初心者向けの記事です。
はじめに#
最近、FrostMing の tokei-pie-cooker を私の K8S にデプロイして SaaS サービスにしました。Frost は最初に私にイメージのアドレスを教えてくれました。それから、私はさっと Deployment をコピー&ペーストしました。
apiVersion: apps/v1
kind: Deployment
metadata:
name: tokei-pie
namespace: tokei-pie
labels:
app: tokei-pie
spec:
replicas: 12
selector:
matchLabels:
app: tokei-pie
template:
metadata:
labels:
app: tokei-pie
spec:
containers:
- name: tokei-pie
image: frostming/tokei-pie-cooker:latest
imagePullPolicy: Always
resources:
limits:
cpu: "1"
memory: "2Gi"
ephemeral-storage: "3Gi"
requests:
cpu: "500m"
memory: "500Mi"
ephemeral-storage: "1Gi"
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
さっとやってしまいましたね、簡単でしょう?Storage の使用量を制限し、NonRoot を制限して、私が攻撃されないようにしました。よし、kubectl apply -f
を実行しました。おっと、
Error: container has runAsNonRoot and image has non-numeric user (tokei), cannot verify user is non-root (pod: "tokei-pie-6c6fd5cb84-s4bz7_tokei-pie(239057ea-fe47-40a9-8041-966c65344a44)", container: tokei-pie)
ああ、K8$ にブロックされました。ブロックポイントは pkg/kubelet/kuberruntime/security_context_others.go
にあります。
func verifyRunAsNonRoot(pod *v1.Pod, container *v1.Container, uid *int64, username string) error {
effectiveSc := securitycontext.DetermineEffectiveSecurityContext(pod, container)
// If the option is not set, or if running as root is allowed, return nil.
if effectiveSc == nil || effectiveSc.RunAsNonRoot == nil || !*effectiveSc.RunAsNonRoot {
return nil
}
if effectiveSc.RunAsUser != nil {
if *effectiveSc.RunAsUser == 0 {
return fmt.Errorf("container's runAsUser breaks non-root policy (pod: %q, container: %s)", format.Pod(pod), container.Name)
}
return nil
}
switch {
case uid != nil && *uid == 0:
return fmt.Errorf("container has runAsNonRoot and image will run as root (pod: %q, container: %s)", format.Pod(pod), container.Name)
case uid == nil && len(username) > 0:
return fmt.Errorf("container has runAsNonRoot and image has non-numeric user (%s), cannot verify user is non-root (pod: %q, container: %s)", username, format.Pod(pod), container.Name)
default:
return nil
}
}
要するに、K8$ は最初にイメージのマニフェストからイメージの実行ユーザー名を取得します。もしイメージに実行ユーザー名が設定されていて、かつ runAsNonRoot を設定していて、run uid を設定していない場合、エラーが発生します。理にかなっていますね。指定したユーザー名の uid が 0 であれば、実際には SecurityContext の制限を破ってしまいます。
Frost に Dockerfile を見せてもらいました。以下の通りです。
FROM python:3.10-slim
RUN useradd -m tokei
USER tokei
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY templates /app/templates
COPY app.py .
COPY gunicorn_config.py .
ENV PATH="/home/tokei/.local/bin:$PATH"
EXPOSE 8000
CMD ["gunicorn", "-c", "gunicorn_config.py"]
OK、特に問題はありません。では、Deployment を変更して新しいバージョンは以下の通りです。
apiVersion: apps/v1
kind: Deployment
metadata:
name: tokei-pie
namespace: tokei-pie
labels:
app: tokei-pie
spec:
replicas: 12
selector:
matchLabels:
app: tokei-pie
template:
metadata:
labels:
app: tokei-pie
spec:
containers:
- name: tokei-pie
image: frostming/tokei-pie-cooker:latest
imagePullPolicy: Always
resources:
limits:
cpu: "1"
memory: "2Gi"
ephemeral-storage: "3Gi"
requests:
cpu: "500m"
memory: "500Mi"
ephemeral-storage: "1Gi"
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 10086
ここでは私のマジックナンバー 10086 を選びました。これで問題はないでしょう。再度 kubectl apply -f
を実行しました。おっと、新しいエラーが出ました。
/usr/local/bin/python: can't open file '/home/tokei/.local/bin/gunicorn': [Errno 13] Permission denied
OK、マジックナンバーを捨てて、伝説の数字 1000 に変更してみました。OK、動作しました!
では、これはなぜでしょうか?次にその理由をお話しします(XD
簡単な紹介、完全な楽しみ#
コンテナ内の UID#
まず、前提知識を少しお話しします。まず、Linux における UID の割り当て規則についてです。Linux ユーザーネームスペース内では、UID のデフォルト範囲は 0 から 60000 です。UID 0 は Root のための予約 UID です。理論的には、ユーザー UID/GID の作成範囲は 1 から 60000 です。
しかし、実際にはもう少し複雑です。通常、各ディストリビューションに内蔵されているいくつかのサービスは、特別なユーザーを持っていることがあります。例えば、クラシックな www-data(以前ブログを立ち上げていた人にはおなじみでしょう)。したがって、実際には、ユーザー名前空間内の UID の開始は通常 500 または 1000 です。具体的な設定は、特定のファイルの設定に依存します。login.defs というファイルで、パスは /etc/login.defs
です。
公式ドキュメントでは次のように説明されています。
useradd または newusers によって通常のユーザーを作成するために使用されるユーザー ID の範囲。UID_MIN(および UID_MAX)のデフォルト値はそれぞれ 1000(および 60000)です。
私たちが useradd
を呼び出して Dockerfile を構築する際にユーザーを追加する場合、この時、関連する操作が完了した後、/etc/passwd
という特別なファイルに対応するユーザー情報が追加されます。Frost の Dockerfile の例では、最終的な passwd ファイルの内容は以下の通りです。
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
tokei:x:1000:1000::/home/tokei:/bin/sh
構築ファイルが終了した後、私たちがよく知っているコンテナランタイムの一つである Docker の関連処理を見てみましょう。
ここで前提知識を少し説明します。現在、Docker は実際には Daemon+CLI としか考えられず、そのコア機能はその背後にある containerd を呼び出すことです。そして、containerd は最終的に runc を通じて関連するコンテナを作成します。
では、runc がこの関連処理をどのように行うか見てみましょう。
runc がコンテナを作成する際には、runc/libcontainer/init_linux.go.finalizeNamespace
という関数を呼び出していくつかの設定を完了し、この関数内で runc/libcontainer/init_linux.go.setupUser
という関数を呼び出して Exec User の設定を行います。ソースコードを見てみましょう。
func setupUser(config *initConfig) error {
// Set up defaults.
defaultExecUser := user.ExecUser{
Uid: 0,
Gid: 0,
Home: "/",
}
passwdPath, err := user.GetPasswdPath()
if err != nil {
return err
}
groupPath, err := user.GetGroupPath()
if err != nil {
return err
}
execUser, err := user.GetExecUserPath(config.User, &defaultExecUser, passwdPath, groupPath)
if err != nil {
return err
}
var addGroups []int
if len(config.AdditionalGroups) > 0 {
addGroups, err = user.GetAdditionalGroupsPath(config.AdditionalGroups, groupPath)
if err != nil {
return err
}
}
// Rather than just erroring out later in setuid(2) and setgid(2), check
// that the user is mapped here.
if _, err := config.Config.HostUID(execUser.Uid); err != nil {
return errors.New("cannot set uid to unmapped user in user namespace")
}
if _, err := config.Config.HostGID(execUser.Gid); err != nil {
return errors.New("cannot set gid to unmapped user in user namespace")
}
if config.RootlessEUID {
// We cannot set any additional groups in a rootless container and thus
// we bail if the user asked us to do so. TODO: We currently can't do
// this check earlier, but if libcontainer.Process.User was typesafe
// this might work.
if len(addGroups) > 0 {
return errors.New("cannot set any additional groups in a rootless container")
}
}
// Before we change to the container's user make sure that the processes
// STDIO is correctly owned by the user that we are switching to.
if err := fixStdioPermissions(config, execUser); err != nil {
return err
}
setgroups, err := ioutil.ReadFile("/proc/self/setgroups")
if err != nil && !os.IsNotExist(err) {
return err
}
// This isn't allowed in an unprivileged user namespace since Linux 3.19.
// There's nothing we can do about /etc/group entries, so we silently
// ignore setting groups here (since the user didn't explicitly ask us to
// set the group).
allowSupGroups := !config.RootlessEUID && string(bytes.TrimSpace(setgroups)) != "deny"
if allowSupGroups {
suppGroups := append(execUser.Sgids, addGroups...)
if err := unix.Setgroups(suppGroups); err != nil {
return err
}
}
if err := system.Setgid(execUser.Gid); err != nil {
return err
}
if err := system.Setuid(execUser.Uid); err != nil {
return err
}
// if we didn't get HOME already, set it based on the user's HOME
if envHome := os.Getenv("HOME"); envHome == "" {
if err := os.Setenv("HOME", execUser.Home); err != nil {
return err
}
}
return nil
}
皆さんはコメントを見れば、このコードが何をしているのかだいたい理解できると思います。このコードでは、runc/libcontainer/user/user.go.GetExecUserPath
と runc/libcontainer/user/user.go.GetExecUser
を呼び出して Exec 時の UID を取得します。この部分の実装を見てみましょう(以下のコードは一部を簡略化しています)。
func GetExecUser(userSpec string, defaults *ExecUser, passwd, group io.Reader) (*ExecUser, error) {
if defaults == nil {
defaults = new(ExecUser)
}
// Copy over defaults.
user := &ExecUser{
Uid: defaults.Uid,
Gid: defaults.Gid,
Sgids: defaults.Sgids,
Home: defaults.Home,
}
// Sgids slice *cannot* be nil.
if user.Sgids == nil {
user.Sgids = []int{}
}
// Allow for userArg to have either "user" syntax, or optionally "user:group" syntax
var userArg, groupArg string
parseLine([]byte(userSpec), &userArg, &groupArg)
// Convert userArg and groupArg to be numeric, so we don't have to execute
// Atoi *twice* for each iteration over lines.
uidArg, uidErr := strconv.Atoi(userArg)
gidArg, gidErr := strconv.Atoi(groupArg)
// Find the matching user.
users, err := ParsePasswdFilter(passwd, func(u User) bool {
if userArg == "" {
// Default to current state of the user.
return u.Uid == user.Uid
}
if uidErr == nil {
// If the userArg is numeric, always treat it as a UID.
return uidArg == u.Uid
}
return u.Name == userArg
})
if err != nil && passwd != nil {
if userArg == "" {
userArg = strconv.Itoa(user.Uid)
}
return nil, fmt.Errorf("unable to find user %s: %v", userArg, err)
}
var matchedUserName string
if len(users) > 0 {
// First match wins, even if there's more than one matching entry.
matchedUserName = users[0].Name
user.Uid = users[0].Uid
user.Gid = users[0].Gid
user.Home = users[0].Home
} else if userArg != "" {
// If we can't find a user with the given username, the only other valid
// option is if it's a numeric username with no associated entry in passwd.
if uidErr != nil {
// Not numeric.
return nil, fmt.Errorf("unable to find user %s: %v", userArg, ErrNoPasswdEntries)
}
user.Uid = uidArg
// Must be inside valid uid range.
if user.Uid < minID || user.Uid > maxID {
return nil, ErrRange
}
// Okay, so it's numeric. We can just roll with this.
}
}
これを見ていると複雑に見えますが、実際には次のように要約できます。
-
まず
/etc/passwd
から既知のすべてのユーザーを読み取ります。 -
ユーザーが起動時にユーザー名を指定した場合、そのユーザー名が一致するかどうかを確認し、一致しなければ起動に失敗します。
-
ユーザーが起動時に UID を指定した場合、既知のユーザーの中に対応するユーザーがいれば、そのユーザーに設定します。いなければ、プロセスの UID を指定された UID に設定します。
-
ユーザーが何も指定しなければ、
/etc/passwd
の最初のユーザーを Exec ユーザーとして使用します。デフォルトでは、最初のユーザーは通常 UID が 0 の root ユーザーを指します。
では、Deployment に戻りましょう。次のような結論が得られます。
-
runAsUser を設定せず、イメージ内でも起動ユーザーが指定されていない場合、コンテナ内のプロセスは現在のユーザー名前空間内の uid が 0 の root ユーザーとして起動します。
-
Dockerfile で起動時のユーザーが設定されていて、runAsUser が設定されていない場合、Dockerfile で指定したユーザーとして起動します。
-
runAsUser を設定し、Dockerfile でも関連するユーザーが指定されている場合、runAsUser で指定された UID でプロセスが起動します。
ここまで来ると、問題は解決したように見えます。しかし、新たな疑問が生じます。通常、ファイルを作成する際のデフォルトの権限は 755
であり、非現在ユーザーや非現在ユーザーグループのメンバーには読み取りおよび実行権限があります。理論的には、前述の [Errno 13] Permission denied
の状況が発生するべきではありません。
コンテナ内でエラーが発生したファイルを確認したところ、やはり私の予想通り、755 の権限でした。
では、問題はどこにあるのでしょうか?問題は ~/.local/
というフォルダーにあります。
そうです、ここでの .local
は 700 の権限であり、非現在ユーザーや非現在ユーザーグループのメンバーには、そのディレクトリに対する実行権限がありません。ここで、ディレクトリの実行権限とは何かについて、公式ドキュメント Understanding Linux File Permissions の説明を引用します。
execute – 実行権限は、ユーザーがファイルを実行したり、ディレクトリの内容を表示したりする能力に影響します。
では、もし対応するディレクトリの実行権限がなければ、そのディレクトリ内のファイルを実行することもできません。たとえファイルに実行権限があってもです。
ここで、pip のソースコードを確認したところ、ユーザー状態でインストールする際に、.local
ディレクトリが存在しない場合、.local
ディレクトリを作成し、権限を 700 に設定することがわかりました。
これで、私たちの問題の因果関係が完全に確立されました。
Dockerfile でユーザー tokei を作成し、uid 1000 を設定 -> pip が 700 の .local を作成し、.local は UID 1000 のユーザーに属する -> runAsUser を非 1000 の数字に設定 -> .local に実行権限がない -> エラーが発生
正直なところ、pip がなぜこのように設計したのか理解できますが、こうした設計がいくつかの慣習を破っているように思います。その合理性には疑問があります。
まとめ#
この問題は実際には難しくはありませんが、発生場所が予想外でした。私の観点から見ると、根本的には pip が基本的なルールを守らなかったことが原因です。
ここで、興味がある方のために考えてみてほしいテーマを残しておきます。私たちは Docker に docker cp
というコマンドがあることを知っています。これはホストから実行中のコンテナにファイルをコピーしたり、コンテナからホストにファイルをコピーしたりするためのものです。-a
というパラメータがあり、元のファイルの UID/GID を保持します。では、このパラメータを使ってホストからコンテナ、またはコンテナからホストにファイルをコピーした場合、ls -lh
でどのようなユーザー / ユーザーグループ情報が表示されるでしょうか。
さて、この記事はここまでにします。水文を書くのは本当に楽しいです。週末に時間があれば、最近遭遇した興味深い SSL トラフィックの特徴に基づくブロック手法について簡単にお話ししたいと思います。
それでは、失礼します。