最近业务容器的非 root 启动改造实战案例经验,后续有新的也更新进来
由来 客户安全要求业务容器改为非 root 启动,很多容器需要操作 ipset iptables 之类的,并不是纯粹 rootless docker 就可以解决的。是尽可能的把(非 k8s 管理容器之类以外)业务容器改为非 root 启动(是容器内业务的所有进程)。
改造 前提须知 这里列举些基础知识
使用 root 不安全的举例 虽然 linux 有 user namespace 隔离技术,但是 docker 不支持类似 podman 那样的给每个容器设置范围性的 uidmap 映射(当然 k8s 现在也不支持),并且容器默认配置下的权限虽然去掉了一些。但是容器内还是能对挂载进去的进行修改的,比如帖子 rm -rf * 前一定一定要看清当前目录 老哥的操作:
1 2 3 4 5 6 7 8 docker run --rm -v /mnt/sda1:/mnt/sda1 -it alpine cp /mnt/sda1/somefile.tar.gz . tar xzvf somefile.tar.gz cd somefile-v1.0 ls # 看了看内容觉得不是自己想要的,回上一级目录准备删掉: cd .. rm -rf *
嗯,alpine 默认的 workdir 是 /
,所以删除 rm -rf /*
。当然还有其他不安全的,所以在业务角度上,我们需要给容器内进程设置在非 root 下最小的运行权限。
设置 USER 还是使用 docker-entrypoint.sh 入口 Dockerfile 里设置 USER
或者 run 的时候设置 -u user:group
为非 root 用户启动,只能针对于一些简单的进程,例如大部分 exporter 和一些只是用 http API 的进程,这几天我测试后也提交了一些 pr:
对于很多挂载目录持久化数据的,例如各种中间件,例如 mysql,redis ,单纯设置 USER 的话,需要在容器启动之前设置目录的权限。other 权限为 7 的话,很不安全,所以只能是 owner、group 权限,但是容器内的用户名和宿主机用户名是不一致的,只能设置 uid、gid。使用这些需要数据持久化的容器,会存在:
直接 -v 挂载或者 docker volume
k8s 上使用 hostPath
固定 pv
sc 下使用 pvc
别人的 k8s 集群或者实例上去部署
如果你提前修改目录权限,上面最后俩场景根本无法自动化,而且说不定某天新版本官方镜像里 Dockerfile 里换基础镜像的同时忘记在添加用户时候设置 uid 和 gid ,uid 和 gid 就变了,只能是加启动脚本里处理。
对此,mysql docker 镜像的官方启动脚本 给了很好的参考,Dockerfile 制作镜像就创建了指定 uid、gid 的 mysql 用户,然后镜像没有指定 USER
也就是启动容器的时候容器内 pid1 的用户是 root ,然后以 ENTRYPOINT CMD
(k8s 里对应 command、args) 的形式启动:
1 2 docker-entrypoint.sh mysqld # 理解为脚本 脚本的 $1 执行
(以 root 启动的容器)启动脚本内会使用 gosu 切换到非 root 的 mysql 用户,mysql 脚本里包含对于权限以外的信息比较多,不方便举例,这里使用 redis 的启动脚本举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #!/bin/sh set -eif [ "${1#-} " != "$1 " ] || [ "${1%.conf} " != "$1 " ]; then set -- redis-server "$@ " fi if [ "$1 " = 'redis-server' -a "$(id -u) " = '0' ]; then find . \! -user redis -exec chown redis '{}' + exec gosu redis "$0 " "$@ " fi um="$(umask) " if [ "$um " = '0022' ]; then umask 0077 fi exec "$@ "
redis 容器以 root 身份运行,然后执行到了 gosu 切换到 redis 启动了 redis-server,例如下面执行流程:
1 2 3 4 5 6 7 8 $ docker run -d -name redis7 -v $PWD /redis-ctr-data:/data --net host redis:7 --port 7777 $ docker top redis7 UID PID PPID C STIME TTY TIME CMD systemd+ 1041135 1041116 1 15:47 ? 00:00:00 redis-server *:7777 $ docker exec redis7 id redis uid=999(redis) gid=999(redis) groups =999(redis) $ grep 999 /etc/passwd systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
docker top 显示的用户,是按照宿主机上 uid 显示的,gosu 是 golang 实现 su-exec ,切换指定用户执行命令,exec 是执行后面的命令,替换当前的 shell 进程,这样在 docker stop 给容器内 pid 为 1 的进程发送信号,业务进程能收到信号进行优雅退出,而没 exec 的话,pid 为 1 的进程是 shell 脚本,它不会转发信号的。
ENTRYPOINT
使用脚本当作入口的形式,最后业务切用户执行,即使使用 docker exec 还是使用镜像默认的 USER root,排查问题也方便。 也推荐使用镜像之前,先看官方的启动脚本,例如 mongodb 官方镜像是支持类似 redis 这种非 root 启动的,但是我们 k8s 里是:
1 2 3 4 5 6 ... - name: {{ NODE_NAME }} image: xxx/mongo:xxx command: - mongod - "--port"
这样覆盖了 entrypoint,没有使用官方启动脚本执行,就是 root 用户,改为下面的不覆盖就行:
1 2 3 4 5 - name: {{ NODE_NAME }} image: xxx/mongo:xxx args: # <--- 这里 - mongod - "--port"
要注意一个点,su-exec 在 alpine 里可以包管理安装,非 alpine 的基础镜像使用 gosu 可以参考 redis 官方镜像,以及 su-exec 不是静态编译的,可能某些系统上有问题,自行测试下看看
案例实战 这列梳理一些我做的案例。先说一些知识点:
产生 pid 和 sock 文件的,可以放 /tmp 下
业务进程非 root 对 /dev/stdxxx
没权限的,可以脚本里先 chmod a+w /dev/std*
如果自己业务镜像产生的数据会被其他容器挂载操作数据,你的业务进程最好创建用户的时候使用固定同样的 uid:gid
,例如我们的 mysql-backup 备份 mysql 数据用到的用户 uid:gid
保持和 mysql 官方镜像一致,这样不需要修改 mysql 数据目录权限和 owner
不要 chmod -R 777
目录
机器码处理 获取机器码一般是使用 dmidecode -s system-uuid
,但是容器内你以 root 执行会报错:
1 2 3 4 $ docker run --rm -ti debian:11 $ apt update && apt-get install -y dmidecode $ dmidecode -s system-uuid /dev/mem: No such file or directory
所以之前我们都是读取 /sys/devices/virtual/dmi/id/product_uuid
,但是非 root 后无法读取,因为该文件权限为 0400
:
1 2 $ ls -l /sys/devices/virtual/dmi/id/product_uuid -r-------- 1 root root 4096 Nov 3 08:48 /sys/devices/virtual/dmi/id/product_uuid
且该文件是内核设置的权限 ,无法被更改。
后面尝试发现一些信息:
1 2 3 4 5 $ strace dmidecode -s system-uuid ... openat(AT_FDCWD, "/sys/firmware/dmi/tables/smbios_entry_point", O_RDONLY) ... openat(AT_FDCWD, "/sys/firmware/dmi/tables/DMI", O_RDONLY)
发现读取了这俩文件,搜索资料发现是 dmi table,例如 root 下可以这样获取机器码:
1 2 $ dmidecode -t 1 < /sys/firmware/dmi/tables/DMI $ dmidecode -t 1 -u < /sys/firmware/dmi/tables/DMI
该文件内容按照 DMI 规范字节结构解析可以得到不少信息。然后找到了一个 go 库,在 linux 上尝试成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 package mainimport ( "fmt" "log" "github.com/digitalocean/go-smbios/smbios" ) func main () { rc, ep, err := smbios.Stream() if err != nil { log.Fatalf("failed to open stream: %v" , err) } defer rc.Close() d := smbios.NewDecoder(rc) ss, err := d.Decode() if err != nil { log.Fatalf("failed to decode structures: %v" , err) } major, minor, _ := ep.Version() for _, s := range ss { if s.Header.Type == 1 { d := s.Formatted if major > 0x02 || (major == 0x02 && minor >= 0x06 ) { fmt.Printf("UUID: %02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X\n" , d[7 ], d[6 ], d[5 ], d[4 ], d[9 ], d[8 ], d[11 ], d[10 ], d[12 ], d[13 ], d[14 ], d[15 ], d[16 ], d[17 ], d[18 ], d[19 ], ) } else { fmt.Printf("UUID: %02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X\n" , d[4 ], d[5 ], d[6 ], d[7 ], d[8 ], d[9 ], d[10 ], d[11 ], d[12 ], d[13 ], d[14 ], d[15 ], d[16 ], d[17 ], d[18 ], d[19 ], ) } } } }
机器上测试:
1 2 3 4 5 6 7 $ dmidecode -s system-uuid | tr a-z A-Z 66C0F667-71A0-xxxx-xxxx-4AC0A21F5428 $ go build -o /tmp/uuid-go test.go $ chmod a+r /sys/firmware/dmi/tables/DMI $ su - guanzhang guanzhang@guan:~$ /tmp/uuid-go UUID: 66C0F667-71A0-xxxx-xxxx-4AC0A21F5428
然后把宿主机的 /sys/firmware/dmi/
挂载到 /rootfs/sys/firmware/dmi/
里,在 gosu 之前 chmod a+r /rootfs/sys/firmware/dmi/tables/DMI
,业务使用上面的库 hack 后,从指定路径的 DMI 信息即可获取到机器码。但是好像某些低版本 linux 上是没有 tables 甚至 dmi 的,也就是下面路径可能不存在:
/sys/firmware/dmi/tables
/sys/firmware/dmi
所以最终我们是挂载 /rootfs/sys/firmware/
,然后我们启动脚本里 gosu 之前,先 cat /sys/devices/virtual/dmi/id/product_uuid
读取成环境变量X,然后代码内部逻辑是,文件 /sys/devices/virtual/dmi/id/product_uuid
没权限,没 /rootfs/sys/firmware/dmi/tables
目录且存在环境变量X 则使用环境变量的值作为机器码,以及兜底,该服务支持一个 env 设置使用 root 启动,不使用 gosu 且用户。
真的需要特权吗? 例如下面是一个操作 ipset 的容器(iptables, ipset 必须 uid 为 0 才可以操作,内核限制的),但是很多人如果自己部署的话,肯定是上来就给 privileged: true
,实际压根就不需要特权,给最小权限即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 services: ipset: image: 'reg.xxx.lan:5000/xxx/ipset:v1.1' container_name: ipset network_mode: host hostname: ipset restart: always working_dir: '/data/kube/' cap_drop: ["ALL" ] cap_add: ["NET_ADMIN" , "NET_RAW" ] volumes: - /run/xtables.lock:/run/xtables.lock:rw - '/data/kube/rule/:/data/kube/'
后面挂载进去的 /data/kube/
下有 owner 为 1000:0
的 0600
文件无法被容器内的 root 读取,下面是一个最小复现:
1 2 3 4 5 6 7 $ echo 123 > test $ chmod 600 test $ chown 1000:0 test $ docker run --rm -ti --cap-drop ALL --entrypoint cat -v $PWD :/v -w /v alpine test cat: can't open 'test': Permission denied $ docker run --rm -ti --entrypoint cat -v $PWD :/v -w /v alpine test 123
对比了默认 docker run 的 cap 列表为下面:
1 2 $ getpcaps 31772 Capabilities for `31772': = cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap+ep
最后排除找到是下面的权限造成的:
1 2 3 4 $ docker run --rm -ti --cap-drop ALL --entrypoint cat -v $PWD :/v -w /v alpine test cat: can't open 'test': Permission denied $ docker run --rm -ti --cap-drop ALL --cap-add DAC_OVERRIDE --entrypoint cat -v $PWD :/v -w /v alpine test 123
但是 DAC_OVERRIDE
很不安全,搜了好多逃逸问题都和它相关,找了下其他权限 FOWNER:
1 2 3 4 5 6 $ docker run --rm -ti --entrypoint sh --cap-drop ALL --cap-add FOWNER -v $PWD :/v -w /v alpine /v # cat test cat: can't open 'test': Permission denied /v # chmod 640 test /v # cat test 123
给 FOWNER
权限后,启动脚本里 chmod 640 即可。
etcd 没啥说的,加了 gosu 后再加启动脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #!/bin/bash set -eif [ "${1:0:1} " = '-' ]; then set -- etcd "$@ " fi if [ "$1 " = 'etcd' ] || [ "$1 " = '/usr/local/bin/etcd' ];then if [ "$(id -u) " = '0' -a -n "$RUN_USER " ]; then find /var/lib/etcd \! -user ${RUN_USER} -exec chown ${RUN_USER} '{}' + exec gosu ${RUN_USER} "$@ " fi fi exec "$@ "
为了不影响其他分支,这里我用了 env 作为开关,wurstmeister/kafka-docker 也是一样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #!/bin/bash set -eif [ "${1:0:1} " = '-' ]; then set -- start-kafka.sh "$@ " fi if [ "$1 " = 'start-kafka.sh' ] || [ "$1 " = '/usr/bin/start-kafka.sh' ];then if [ "$(id -u) " = '0' -a -n "$RUN_USER " ]; then find $(readlink -f ${KAFKA_HOME} ) \! -user ${RUN_USER} -exec chown ${RUN_USER} '{}' + find /kafka \! -user ${RUN_USER} -exec chown ${RUN_USER} '{}' + exec gosu ${RUN_USER} "$@ " fi fi exec "$@ "
其他的很多都是类似这样,不再举例,自行制作
coredns coredns 1.11.0 才开始非 root 启动,我们业务使用的是 1.10.1 的,不升级避免客户现场出现问题,所以重做镜像最稳妥:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ARG DEBIAN_IMAGE=debian:stable-slimARG BASE=gcr.io/distroless/static-debian12:nonrootFROM coredns/coredns:1.10 .1 as binFROM ${DEBIAN_IMAGE} AS buildSHELL [ "/bin/sh" , "-ec" ] RUN export DEBCONF_NONINTERACTIVE_SEEN=true \ DEBIAN_FRONTEND=noninteractive \ DEBIAN_PRIORITY=critical \ TERM=linux ; \ apt-get -qq update ; \ apt-get -yyqq upgrade ; \ apt-get -yyqq install ca-certificates libcap2-bin; \ apt-get clean COPY --from=bin /coredns /coredns RUN setcap cap_net_bind_service=+ep /coredns FROM ${BASE}COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /coredns /coredns USER nonroot:nonrootEXPOSE 53 53 /udpENTRYPOINT ["/coredns" ]
非 root 用户是无法监听 1024 以下端口的,coredns 监听 53 端口是因为使用了 setcap cap_net_bind_service=+ep /coredns
,但是这个属性属于扩展属性,docker 构建多层 COPY 会不支持而丢失,必须使用 buildkit 构建,否则 cap 信息丢失,部署上去无法监听 53 端口:
1 2 DOCKER_BUILDKIT=1 docker build --platform=amd64 . -t coredns/coredns:1.10.1 --load
suse12sp5 上发现无法容器镜像无法保持 cap_net_bind_service
属性,启动会报错 permission deny,可以启动脚本里执行下面的,包括结尾的 nginx 也一样:
1 setcap cap_net_bind_service=+ep /coredns
consul consul 镜像也支持,但是 chown 的时候没带 -R 选项。
1 2 3 if [ "$(stat -c %u "$CONSUL_DATA_DIR")" != "${CONSUL_UID}" ]; then chown ${CONSUL_UID}:${CONSUL_GID} "$CONSUL_DATA_DIR" fi
这里会存在一个问题,如果之前是覆盖了 entrypoint 使用 root 启动的,再切正确姿势下,因为 data 目录下子目录没被 chown,consul 在 data 下子目录写入 node-id 会报错没权限,所以我是这样 hack 重做镜像的:
1 2 3 4 5 6 ARG VER=1.8 .3 FROM consul:${VER}RUN sed -ri -e 's/(chown)(\s+consul:)/\1 -R\2/' \ -e '1s@/usr/bin/dumb-init\s+@@' \ /usr/local/bin/docker-entrypoint.sh; \ sed -ri "/CONSUL_DISABLE_PERM_MGMT/a \ \ \ \ find \$CONSUL_DATA_DIR \\! -user consul -exec chown consul:consul '{}' +" /usr/local/bin/docker-entrypoint.sh
去掉 dumb-init
是因为客户要求容器内所有进程都是非 root,不去掉 pid 为 1 的就是 root 用户 dumb-init sh 进程
docker.sock 文件 有些进程是需要挂载 /var/run
为了使用宿主机的 /var/run/docker.sock
和宿主机 docker 通信的,这里我们使用 cadvisor 举例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ARG VER=v0.37.5 ARG BASE_IMG=gcr.m.daocloud.io/cadvisor/cadvisorFROM ${BASE_IMG}:${VER}ARG GO_SU=1.17 RUN set -eux; \ case "$(uname -m) " in \ 'amd64' | 'x86_64' ) \ GOARCH='amd64' \ ;; \ 'mips64' | 'mips64le' | 'mips64el' ) \ GOARCH='mips64el' \ ;; \ 'aarch64' ) \ GOARCH='arm64' \ ;; \ 'loongarch64' ) \ GOARCH='loong64' \ ;; \ esac ; \ \ sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories; \ apk update; \ apk add --no-cache \ curl \ acl \ ; \ curl -o /usr/local/bin/gosu -sSL https://github.com/tianon/gosu/releases/download/${GO_SU} /gosu-${GOARCH} ; \ chmod a+x /usr/local/bin/gosu; \ gosu --version; \ rm -rf /var/cache/apk/* /tmp/* COPY docker-entrypoint.sh / ENTRYPOINT ["/docker-entrypoint.sh" ] CMD ["/usr/bin/cadvisor" , "-logtostderr" ]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #!/bin/sh set -eif [ "${1:0:1} " = '-' ]; then set -- cadvisor "$@ " fi if [ "$1 " = 'cadvisor' ] || [ "$1 " = '/usr/bin/cadvisor' ];then if [ "$(id -u) " = '0' -a -n "$RUN_USER " ]; then if [ -S /var/run/docker.sock ];then group_id=`stat -c "%g" /var/run/docker.sock` if ! getent group | cut -d: -f3 | grep -wq $group_id ; then if ! addgroup -g ${group_id} docker;then group_failed=true fi fi if [ -z "$group_failed " ];then group_name=$(stat -c "%G" /var/run/docker.sock) if ! id -nG ${RUN_USER} | grep -w ${group_name} ;then adduser ${RUN_USER} ${group_name} fi else setfacl -m u:${RUN_USER} :rw /var/run/docker.sock fi fi exec gosu $RUN_USER $@ fi fi exec $@
cadvisor 挂载了宿主机的 rootfs ,改为纯非 root 不行,但是 cadvisor 镜像内有个 operator
用户的 gid 是 0,利用启动脚本和 docker 权限来改造成非 root 启动。
docker.sock 权限是 0660
,利用 shell 把 operator 用户加到 docker 组里即可(必须取 gid)。这里要注意的是,不同版本 alpine 和其他 rootfs 的 adduser/addgroup 参数不一样,自行注意 shell 兼容
设置 “RUN_USER” 为 operator
,然后设置宿主机的 docker 的 data-root 下面权限(可以使用 systemd 的ExecStartPost=
):
1 2 3 /var/lib/docker/image:750 ok /var/lib/docker/image/overlay2:750 ok /var/lib/docker/image/overlay2/layerdb:750 ok
cadvisor 参数为:
1 2 3 4 5 ... args: - -docker_only=true - -housekeeping_interval=20s - -disable_metrics=accelerator,cpu_topology,tcp,udp,percpu,sched,process,hugetlb,referenced_memory,resctrl
promtail 官方在 Promtail should run as non-root in docker 推荐在 k8s 里设置安全上下文,有少部分老哥指定的 gid 为 0 可以运行,但是还是很多人有权限问题,所以应该也要像上面这样,用 gid 为 0 和配合脚本设置权限。
cron 非 root 无法使用 cron 启动,使用 go-crond
1 exec gosu user1 go-crond --default-user=user1 --include=/etc/cron.d --allow-unprivileged
nginx https://github.com/nginxinc/docker-nginx-unprivileged 最好 hack 下 stable/alpine/Dockerfile
构建,.checksum-deps
里面加入 libcap 和 setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx
一些权限举例 tcpdump 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 FROM alpine RUN apk update; \ apk add --no-cache \ tcpdump \ libcap \ su-exec \ ; \ rm -rf /var/cache/apk/*; COPY docker-entrypoint.sh /docker-entrypoint.sh CMD ["sh", "/docker-entrypoint.sh"] #!/bin/sh set -e setcap cap_net_raw,cap_net_admin=eip /usr/bin/tcpdump chmod a+w /dev/std* .... cap_add: - NET_ADMIN - NET_RAW
查看本机 root 启动的容器 1 2 docker ps -a --format '{{.Names}}' --filter status=running | \ while read ctr; do docker top $ctr| awk -vc=$ctr 'NR==2&&$1=="root"{print c}' ; done
参考