今日はあまり気分が良くなく、家で一日休みました。最近、いくつかの小さな問題で、コンテナ内の 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
さっとやって、すぐに終わりましたね、簡単でしょう?ストレージの使用量を制限し、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)
// オプションが設定されていない場合、またはrootとしての実行が許可されている場合は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$ は最初にイメージの manifact からイメージの Running Username を取得します。もしイメージに Running Username が設定されていて、かつ runAsNoneRoot が設定されていて、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、特に異常はありません。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 UserNamespace 内では、UID のデフォルト範囲は 0 から 60000 です。UID 0 は Root の予約 UID です。理論的には、ユーザーの UID/GID の作成範囲は 1 から 60000 です。
しかし、実際にはもう少し複雑です。通常、各ディストリビューションに内蔵されているサービスは、特定のユーザーを持っている場合があります。例えば、クラシックな www-data(以前、ブログを立ち上げるのが好きだった人にはおなじみでしょう)。したがって、実際には User Namespace 内での 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 {
// デフォルトを設定します。
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
}
}
// setuid(2)およびsetgid(2)でエラーが発生するのを避けるため、ここでユーザーがマッピングされているか確認します。
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 {
// rootlessコンテナでは追加のグループを設定できないため、ユーザーがそれを要求した場合は中止します。
if len(addGroups) > 0 {
return errors.New("cannot set any additional groups in a rootless container")
}
}
// コンテナのユーザーに切り替える前に、プロセスのSTDIOが切り替えるユーザーによって正しく所有されていることを確認します。
if err := fixStdioPermissions(config, execUser); err != nil {
return err
}
setgroups, err := ioutil.ReadFile("/proc/self/setgroups")
if err != nil && !os.IsNotExist(err) {
return err
}
// Linux 3.19以降、特権のないユーザー名前空間ではこれが許可されていません。
// /etc/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
}
// まだHOMEが取得できていない場合、ユーザーの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)
}
// デフォルトをコピーします。
user := &ExecUser{
Uid: defaults.Uid,
Gid: defaults.Gid,
Sgids: defaults.Sgids,
Home: defaults.Home,
}
// Sgidsスライスはnilにできません。
if user.Sgids == nil {
user.Sgids = []int{}
}
// userArgが"user"構文またはオプションで"user:group"構文を持つことを許可します。
var userArg, groupArg string
parseLine([]byte(userSpec), &userArg, &groupArg)
// userArgとgroupArgを数値に変換し、各行を繰り返す際にAtoiを2回実行する必要がないようにします。
uidArg, uidErr := strconv.Atoi(userArg)
gidArg, gidErr := strconv.Atoi(groupArg)
// 一致するユーザーを見つけます。
users, err := ParsePasswdFilter(passwd, func(u User) bool {
if userArg == "" {
// ユーザーの現在の状態にデフォルトします。
return u.Uid == user.Uid
}
if uidErr == nil {
// userArgが数値の場合、常に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 {
// 最初の一致が勝ちます。たとえ複数の一致するエントリがあっても。
matchedUserName = users[0].Name
user.Uid = users[0].Uid
user.Gid = users[0].Gid
user.Home = users[0].Home
} else if userArg != "" {
// 指定されたユーザー名のユーザーが見つからない場合、唯一の他の有効なオプションは、passwdに関連するエントリがない数値のユーザー名です。
if uidErr != nil {
// 数値ではありません。
return nil, fmt.Errorf("unable to find user %s: %v", userArg, ErrNoPasswdEntries)
}
user.Uid = uidArg
// 有効なuid範囲内である必要があります。
if user.Uid < minID || user.Uid > maxID {
return nil, ErrRange
}
// さて、数値です。これで進めます。
}
}
ここは見た目が複雑ですが、実際には以下のように要約できます。
-
最初に
/etc/passwd
から既知のすべてのユーザーを読み取ります。 -
ユーザーが起動時にユーザー名を指定した場合、そのユーザー名と起動パラメータが一致するかどうかを判断し、一致しない場合は起動に失敗します。
-
ユーザーが起動時に UID を指定した場合、既知のユーザーの中に対応するユーザーがいれば、そのユーザーに設定します。いなければ、プロセスの UID を指定された UID に設定します。
-
ユーザーが何も指定しなかった場合、
/etc/passwd
の最初のユーザーを exec ユーザーとして使用します。デフォルトでは、最初のユーザーは通常 UID が 0 の root ユーザーを指します。
さて、私たちの Deployment に戻りましょう。次のような結論が導き出せます。
-
runAsUser を設定せず、イメージ内でも起動ユーザーが指定されていない場合、コンテナ内のプロセスは現在の user namespace 内で uid が 0 の root ユーザーとして起動します。
-
Dockerfile 内で起動時のユーザーが設定されていて、runAsUser が設定されていない場合、Dockerfile 内のユーザーとして起動します。
-
runAsUser が設定され、Dockerfile 内でも関連するユーザーが指定されている場合、runAsUser で指定された UID としてプロセスが起動します。
さて、ここまで来ると問題が解決したように見えます。しかし、新たな疑問が生じます。通常、ファイルを作成する際のデフォルトの権限は755
です。すなわち、現在のユーザーでもなく、現在のユーザーグループのメンバーでもない者には読み取りおよび実行権限があります。理論的には、前述の[Errno 13] Permission denied
の状況が発生するべきではありません。
私はコンテナ内を見て、エラーが発生したファイルを確認しましたが、やはり私の予想通り、権限は 755 でした。
では、問題はどこにあるのでしょうか?問題は~/.local/
というフォルダにあります。
そうです、ここでの.local
は 700 の権限を持っており、現在のユーザーでもなく、現在のユーザーグループのメンバーでもない者には、そのディレクトリへの実行権限がありません。ここで、皆さんはディレクトリの実行権限とは何か疑問に思うかもしれません。ここで公式文書Understanding Linux File Permissionsの説明を引用します。
実行 – 実行権限は、ユーザーがファイルを実行したり、ディレクトリの内容を表示したりする能力に影響します。
さて、もし対応するディレクトリの実行権限がなければ、そのディレクトリ内のファイルを実行することはできません。たとえファイルに実行権限があってもです。
私は pip のソースコードを調べました。pip はユーザーモードでインストールする際、.local
ディレクトリが存在しない場合、このディレクトリを作成し、権限を 700 に設定します。
さて、ここで私たちの問題の因果関係が完全に確立されました。
Dockerfile 内でユーザー tokei を作成し、uid 1000 を設定 -> pip が 700 の.local を作成 -> .local は UID 1000 のユーザーに帰属 -> runAsUser が非 1000 の数字に設定される -> .local の実行権限がない -> エラー発生
正直なところ、pip がなぜこのように設計したのか理解できますが、私はこのような設計がいくつかの慣習を破っていると感じています。その合理性には疑問があります。
まとめ#
この問題は実際には難しくはありませんが、発生場所が予想外でした。私の観点から見ると、根本的には pip が基本的なルールを守らなかったことが原因です 23333
ここで、皆さんが興味を持って考えることができる問題を残しておきます。私たちは Docker にdocker cp
というコマンドがあることを知っています。これはホストから実行中のコンテナにファイルをコピーしたり、コンテナからホストにファイルをコピーしたりするためのものです。-a
というパラメータがあり、これは元のファイルの UID/GID を保持します。もしこのパラメータを使用してホスト / コンテナからコンテナ / ホストにファイルをコピーした場合、ls -lh
を実行すると、どのような User/UserGroup 情報が表示されるでしょうか。
さて、この水文はここまでにします。水文を書くのは本当に楽しいです。週末に時間があれば、最近遭遇した非常に興味深い SSL トラフィックの特徴に基づくブロック手法について簡単に話す水文を書ければと思います。
それでは、失礼します。