公众号关注「奇妙的 Linux 世界」
设为「星标」,每天带你玩转 Linux !
本文总结了业务容器的非 root 启动改造实战经验,强调了非 root 启动的重要性和一些基础知识。
文章提供了一些建议,如设置容器内进程的最小权限,使用 USER 指令或者启动脚本来切换用户,以及处理机器码获取等技巧。还包括不同容器镜像的修改和构建过程,以满足非 root 启动要求,特别提到了 CoreDNS 和 Consul 镜像的处理方式。
由来
客户安全要求业务容器改为非 root 启动,很多容器需要操作 ipset iptables 之类的,并不是纯粹 rootless docker 就可以解决的。是尽可能的把(非 k8s 管理容器之类以外)业务容器改为非 root 启动(是容器内业务的所有进程)。
我们在之前的文章 《》,介绍过在以 root 用户身份运行 Docker 会带来一些潜在的危害和安全风险,需要的读者可以翻阅查看。
改造前提须知
这里列举些基础知识
使用 root 不安全的举例
虽然 linux 有 user namespace 隔离技术,但是 docker 不支持类似 podman 那样的给每个容器设置范围性的 uidmap 映射(当然 k8s 现在也不支持),并且容器默认配置下的权限虽然去掉了一些。但是容器内还是能对挂载进去的进行修改的,比如帖子rm -rf * 前一定一定要看清当前目录[1]老哥的操作:
docker run --rm -v /mnt/sda1:/mnt/sda1 -it alpinecp /mnt/sda1/somefile.tar.gz .tar xzvf somefile.tar.gzcd somefile-v1.0ls# 看了看内容觉得不是自己想要的,回上一级目录准备删掉:cd ..rm -rf *
嗯,alpine 默认的 workdir 是/,所以删除rm -rf /*。当然还有其他不安全的,所以在业务角度上,我们需要给容器内进程设置在非 root 下最小的运行权限。
设置 USER 还是使用 docker-entrypoint.sh 入口
Dockerfile 里设置USER或者 run 的时候设置-u user:group只能针对于一些简单的进程,例如大部分 exporter 和一些只是用 http API 的进程,这几天我测试后也提交了一些 pr:
对于很多挂载目录持久化数据的,例如各种中间件,例如 mysql,redis ,单纯设置 USER 的话,需要在容器启动之前设置目录的权限。other 权限为 7 的话,很不安全,所以只能是 owner、group 权限,但是容器内的用户名和宿主机用户名是不一致的,只能设置 uid、gid。使用这些需要数据持久化的容器,会存在:
如果你提前修改目录权限,上面最后俩场景根本无法自动化,而且说不定某天新版本官方镜像里 Dockerfile 里换基础镜像的同时忘记在添加用户时候设置 uid 和 gid ,uid 和 gid 就变了,只能是加启动脚本里处理。
对此,mysql docker 镜像的官方启动脚本[5]给了很好的参考,Dockerfile 制作镜像就创建了指定 uid、gid 的 mysql 用户,然后启动容器的时候都是ENTRYPOINT CMD(k8s 里对应 command、args) 的形式启动:
docker-entrypoint.sh mysqld
或者可以通过 cmdline 设置 mysql 启动端口
docker run xxx mysql:5.7 --port 4306
mysql 脚本里包含对于权限以外的信息比较多,不方便举例,这里使用 redis 举例:
#!/bin/sh# 脚本某行报错就退出set -e# 脚本的第一个参数为 -开头的字符串,或者是 .conf 结尾的字符串if [ "${1#-}" != "$1" ] || [ "${1%.conf}" != "$1" ]; then # 重新设置 $@ 为 redis-server "$@" set -- redis-server "$@"fi# allow the container to be started with `--user`# 第一个参数为 redis-server 并且执行的用户为 rootif [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then # 更改当前目录下的 owner 为 redis find . ! -user redis -exec chown redis '{}' + # 使用 gosu 切换到 redis 执行本脚本,并带上此刻的 $@参数 exec gosu redis "$0" "$@"fi# set an appropriate umask (if one isn't set already)# - https://github.com/docker-library/redis/issues/305# - https://github.com/redis/redis/blob/bb875603fb7ff3f9d19aad906bd45d7db98d9a39/utils/systemd-redis_server.service#L37um="$(umask)"if [ "$um" = '0022' ]; then umask 0077fiexec "$@"
例如下面执行流程:
$ docker run -d -name redis7 -v $PWD/redis-ctr-data:/data --net host redis:7 --port 7777$ docker top redis7UID PID PPID C STIME TTY TIME CMDsystemd+ 1041135 1041116 1 15:47 ? 00:00:00 redis-server *:7777$ docker exec redis7 id redisuid=999(redis) gid=999(redis) groups=999(redis)$ grep 999 /etc/passwdsystemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
docker top 显示的用户,是按照宿主机上 uid 显示的,gosu[6]是 golang 实现su-exec[7],切换指定用户执行命令,exec 是执行后面的命令,替换当前的 shell 进程,这样在 docker stop 给容器内 pid 为 1 的进程发送信号,业务进程能收到信号进行优雅退出,而没 exec 的话,pid 为 1 的进程是 shell 脚本,它不会转发信号的。
ENTRYPOINT使用脚本当作入口的形式,最后业务切用户执行,即使使用 docker exec 还是使用镜像默认的 USER root,排查问题也方便。也推荐使用镜像之前,先看官方的启动脚本,例如 mongodb 官方镜像是支持类似 redis 这种非 root 启动的,但是我们 k8s 里是:
... - name: {{ NODE_NAME }} image: xxx/mongo:xxx command: - mongod - "--port"
这样覆盖了 entrypoint,没有使用官方启动脚本执行,就是 root 用户,改为下面的不覆盖就行:
- name: {{ NODE_NAME }} image: xxx/mongo:xxx args: # <--- 这里 - mongod - "--port"
要注意一个点,su-exec 在 alpine 里可以包管理安装,非 alpine 的基础镜像使用 gosu 可以参考 redis 官方镜像。
案例实战
这列梳理一些我做的案例。先说一些知识点:
机器码处理
获取机器码一般是使用dmidecode -s system-uuid,但是容器内你以 root 执行会报错:
$ 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:
$ 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
且该文件是内核设置的权限[8],无法被更改。
后面尝试发现一些信息:
$ 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 下可以这样获取机器码:
$ dmidecode -t 1 < /sys/firmware/dmi/tables/DMI$ dmidecode -t 1 -u < /sys/firmware/dmi/tables/DMI
该文件内容按照 DMI 规范字节结构解析可以得到不少信息。然后找到了一个 go 库,在 linux 上尝试成功:
package mainimport ( "fmt" "log" "github.com/digitalocean/go-smbios/smbios")func main() { // Find SMBIOS data in operating system-specific location. rc, _, err := smbios.Stream() if err != nil { log.Fatalf("failed to open stream: %v", err) } // Be sure to close the stream! defer rc.Close() // Decode SMBIOS structures from the stream. d := smbios.NewDecoder(rc) ss, err := d.Decode() if err != nil { log.Fatalf("failed to decode structures: %v", err) } for _, s := range ss { if s.Header.Type == 1 { d := s.Formatted fmt.Printf("UUID: %X%X%X%X-%X%X-%X%X-%X%X-%X%X%X%X%X%Xn", 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], ) } }}
机器上测试:
$ dmidecode -s system-uuid | tr a-z A-Z66C0F667-71A0-xxxx-xxxx-4AC0A21F5428$ go build -o /tmp/uuid-go test.go$ chmod a+r /sys/firmware/dmi/tables/DMI$ su - guanzhangguanzhang@guan:~$ /tmp/uuid-go UUID: 66C0F667-71A0-xxxx-xxxx-4AC0A21F5428
然后把宿主机的/sys/firmware/dmi/tables挂载到/rootfs/sys/firmware/dmi/tables里,在 gosu 之前chmod a+r /rootfs/sys/firmware/dmi/tables/DMI,业务使用上面的库 hack 后,从指定路径的 DMI 信息即可获取到机器码。
etcd
没啥说的,加了 gosu 后再加启动脚本:
#!/bin/bashset -eif [ "${1:0:1}" = '-' ]; then set -- etcd "$@"fi# RUN_USER 设置为 nobody 启动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} "$@" fifiexec "$@"
为了不影响其他分支,这里我用了 env 作为开关,wurstmeister/kafka-docker[9]也是一样:
#!/bin/bashset -eif [ "${1:0:1}" = '-' ]; then set -- start-kafka.sh "$@"fi# RUN_USER 设置为 nobody 启动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} "$@" fifiexec "$@"
其他的,例如 promtail 啥的都是一样,不再举例,自行制作
coredns
coredns 1.11.0 才开始非 root 启动,我们业务使用的是 1.10.1 的,不升级避免客户现场出现问题,所以重做镜像最稳妥:
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 cleanCOPY --from=bin /coredns /corednsRUN setcap cap_net_bind_service=+ep /corednsFROM ${BASE}COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/COPY --from=build /coredns /corednsUSER nonroot:nonrootEXPOSE 53 53/udpENTRYPOINT ["/coredns"]
非 root 用户是无法监听 1024 以下端口的,coredns 监听 53 端口是因为使用了setcap cap_net_bind_service=+ep /coredns,但是这个属性属于扩展属性,docker 构建多层 COPY 会不支持而丢失,必须使用 buildkit 构建,否则 cap 信息丢失,部署上去无法监听 53 端口:
DOCKER_BUILDKIT=1 docker build --platform=amd64 . -t coredns/coredns:1.10.1 --load
consul
consul 镜像也支持,但是 chown 的时候没带 -R 选项。
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 重做镜像的:
ARG VER=1.8.3FROM consul:${VER}RUN sed -ri -e 's/(chown)(s+consul:)/1 -R2/' -e '1s@/usr/bin/dumb-inits+@@' /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 举例:
ARG VER=v0.37.5FROM gcr.m.daocloud.io/cadvisor/cadvisor:${VER}RUN set -eux; sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories; apk update; apk add --no-cache curl su-exec; rm -rf /var/cache/apk/* /tmp/* COPY docker-entrypoint.sh /ENTRYPOINT ["/docker-entrypoint.sh"]CMD ["cadvisor", "-logtostderr"]#!/bin/shset -e[ -z "$D_SOCK" ] && D_SOCK=/var/run/docker.sockif [ "${1:0:1}" = '-' ]; then set -- cadvisor "$@"fiif [ "$1" = 'cadvisor' ] || [ "$1" = '/usr/bin/cadvisor' ];then if [ "$(id -u)" = '0' -a -n "$RUN_USER" ]; then if [ -S "${D_SOCK}" ];then group_id=`stat -c "%g" "${D_SOCK}"` if ! getent group | cut -d: -f3 | grep -wq $group_id; then addgroup -g ${group_id} docker fi group_name=$(stat -c "%G" "${D_SOCK}") if ! id -nG ${RUN_USER} | grep -w ${group_name};then # ensure user in docker group adduser ${RUN_USER} ${group_name} fi fi exec su-exec $RUN_USER $@ fifiexec $@
设置 “RUN_USER” 为operator,然后设置宿主机的 docker 的 data-root 下面权限(可以使用 systemd 的ExecStartPost=):
/var/lib//docker/image:750 ok/var/lib//docker/image/overlay2:750 ok/var/lib//docker/image/overlay2/layerdb:750 ok
cadvisor 参数为:
... args: - -docker_only=true - -housekeeping_interval=20s - -disable_metrics=accelerator,cpu_topology,tcp,udp,percpu,sched,process,hugetlb,referenced_memory,resctrl
cron
非 root 无法使用 cron 启动,使用go-crond[10]
引用链接
[1]rm -rf * 前一定一定要看清当前目录:
[2]danielqsj/kafka_exporter:
[3]ClickHouse/clickhouse_exporter:
[4]kubernetes addonresizer:
[5]mysql docker 镜像的官方启动脚本:
[6]gosu:
[7]su-exec:
[8]内核设置的权限:#L61
[9]wurstmeister/kafka-docker:
[10]go-crond:
[11]k8s 社区关于支持 user namespace 提议:
[12]dmi 信息规范:
[13]dmidecode 源码:#L448
本文转载自:「张馆长的博客」,原文:,版权归原作者所有。欢迎投稿,投稿邮箱: editor@hi-linux.com。
最近,我们建立了一个技术交流微信群。目前群里已加入了不少行业内的大神,有兴趣的同学可以加入和我们一起交流技术,在 「奇妙的 Linux 世界」 公众号直接回复 「加群」 邀请你入群。
你可能还喜欢
点击下方图片即可阅读
如何使用 k3sup 一分钟快速搭建轻量极 Kubernetes 集群
点击上方图片,『美团|饿了么』外卖红包天天免费领
限时特惠:本站每日持续更新海量各大内部网赚创业教程,会员可以下载全站资源点击查看详情
站长微信:11082411