一份超实用的 Docker 容器非 root 启动实战教程
2023-12-11 08:1:40 Author: Docker中文社区(查看原文) 阅读量:6 收藏

本文总结了业务容器的非 root 启动改造实战经验,强调了非 root 启动的重要性和一些基础知识。

文章提供了一些建议,如设置容器内进程的最小权限,使用 USER 指令或者启动脚本来切换用户,以及处理机器码获取等技巧。还包括不同容器镜像的修改和构建过程,以满足非 root 启动要求,特别提到了 CoreDNS 和 Consul 镜像的处理方式。

由来

客户安全要求业务容器改为非 root 启动,很多容器需要操作 ipset iptables 之类的,并不是纯粹 rootless docker 就可以解决的。是尽可能的把(非 k8s 管理容器之类以外)业务容器改为非 root 启动(是容器内业务的所有进程)。

我们在之前的文章 《容器快了,却不安全了,Rootless 安排上》,介绍过在以 root 用户身份运行 Docker 会带来一些潜在的危害和安全风险,需要的读者可以翻阅查看。

改造

前提须知

这里列举些基础知识

使用 root 不安全的举例

虽然 linux 有 user namespace 隔离技术,但是 docker 不支持类似 podman 那样的给每个容器设置范围性的 uidmap 映射(当然 k8s 现在也不支持),并且容器默认配置下的权限虽然去掉了一些。但是容器内还是能对挂载进去的进行修改的,比如帖子 rm -rf * 前一定一定要看清当前目录[1] 老哥的操作:

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 只能针对于一些简单的进程,例如大部分 exporter 和一些只是用 http API 的进程,这几天我测试后也提交了一些 pr:

  • • danielqsj/kafka_exporter[2]

  • • ClickHouse/clickhouse_exporter[3]

  • • kubernetes addonresizer[4]

对于很多挂载目录持久化数据的,例如各种中间件,例如 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 镜像的官方启动脚本[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 并且执行的用户为 root
if [ "$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#L37
um="$(umask)"
if [ "$um" = '0022' ]; then
    umask 0077
fi

exec "$@"

例如下面执行流程:

$ 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[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 官方镜像。

案例实战

这列梳理一些我做的案例。先说一些知识点:

  • • 产生 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 执行会报错:

$ 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 main

import (
    "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%X\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],
            )
        }
    }
}

机器上测试:

$ 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/tables 挂载到 /rootfs/sys/firmware/dmi/tables 里,在 gosu 之前 chmod a+r /rootfs/sys/firmware/dmi/tables/DMI,业务使用上面的库 hack 后,从指定路径的 DMI 信息即可获取到机器码。

etcd

没啥说的,加了 gosu 后再加启动脚本:

#!/bin/bash

set -e

if [ "${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} "$@"
    fi
fi

exec "$@"

为了不影响其他分支,这里我用了 env 作为开关,wurstmeister/kafka-docker[9] 也是一样:

#!/bin/bash

set -e

if [ "${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} "$@"
    fi
fi

exec "$@"

其他的,例如 promtail 啥的都是一样,不再举例,自行制作

coredns

coredns 1.11.0 才开始非 root 启动,我们业务使用的是 1.10.1 的,不升级避免客户现场出现问题,所以重做镜像最稳妥:

ARG DEBIAN_IMAGE=debian:stable-slim
ARG BASE=gcr.io/distroless/static-debian12:nonroot
FROM coredns/coredns:1.10.1 as bin

FROM  ${DEBIAN_IMAGE} AS build
SHELL [ "/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:nonroot
EXPOSE 53 53/udp
ENTRYPOINT ["/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.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

去掉 dumb-init 是因为客户要求容器内所有进程都是非 root,不去掉 pid 为 1 的就是 root 用户 dumb-init sh 进程

docker.sock 文件

有些进程是需要挂载 /var/run 为了使用宿主机的 /var/run/docker.sock 和宿主机 docker 通信的,这里我们使用 cadvisor 举例:

ARG VER=v0.37.5
FROM 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/sh
set -e

[ -z "$D_SOCK" ] && D_SOCK=/var/run/docker.sock

if [ "${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 "${D_SOCK}" ];then
            group_id=`stat -c "%g" "${D_SOCK}"`
            if ! getent group | cut -d: -f3 | grep -wq $group_idthen
                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 $@
    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=):

/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 * 前一定一定要看清当前目录: https://www.v2ex.com/t/976554
[2] danielqsj/kafka_exporter: https://github.com/danielqsj/kafka_exporter/pull/410
[3] ClickHouse/clickhouse_exporter: https://github.com/ClickHouse/clickhouse_exporter/pull/83
[4] kubernetes addonresizer: https://github.com/kubernetes/autoscaler/pull/6242/files
[5] mysql docker 镜像的官方启动脚本: https://github.com/docker-library/mysql/blob/master/5.7/docker-entrypoint.sh
[6] gosu: https://github.com/tianon/gosu
[7] su-exec: https://github.com/ncopa/su-exec
[8] 内核设置的权限: https://github.com/torvalds/linux/blob/master/drivers/firmware/dmi-id.c#L61
[9] wurstmeister/kafka-docker: https://github.com/wurstmeister/kafka-docker
[10] go-crond: https://github.com/webdevops/go-crond
[11] k8s 社区关于支持 user namespace 提议: https://github.com/kubernetes/enhancements/issues/127
[12] dmi 信息规范: https://www.dmtf.org/sites/default/files/standards/documents/DSP0134_3.3.0.pdf
[13] dmidecode 源码: https://github.com/mirror/dmidecode/blob/master/dmidecode.c#L448

本文转载自:「张馆长的博客」,原文:https://url.hi-linux.com/YNXm0,版权归原作者所有。


推荐阅读 点击标题可跳转

《Docker是什么?》

《Kubernetes是什么?》

《Kubernetes和Docker到底有啥关系?》

《教你如何快捷的查询选择网络仓库镜像tag》

《Docker镜像进阶:了解其背后的技术原理》

《教你如何修改运行中的容器端口映射》

《k8s学习笔记:介绍&上手》

《k8s学习笔记:缩扩容&更新》

《Docker 基础用法和命令帮助》

《在K8S上搭建Redis集群》

《灰度部署、滚动部署、蓝绿部署》

《PM2实践指南》

《Docker垃圾清理》

《Kubernetes(k8s)底层网络原理刨析》

《容器环境下Node.js的内存管理》

《MySQL 快速创建千万级测试数据》

《Linux 与 Unix 到底有什么不同?》

《浅谈几种常见 RAID 的异同》

《Git 笔记-程序员都要掌握的 Git》

《老司机必须懂的MySQL规范》

《Docker中Image、Container与Volume的迁移》

《漫画|如何用Kubernetes搞定CICD》

《写给前端的Docker实战教程》

《Linux 操作系统知识地图2.0,我看行》

《16个概念带你入门 Kubernetes》

《程序员因接外包坐牢456天,长文叙述心酸真实经历》

《IT 行业老鸟,有话对你说》

《HTTPS 为什么是安全的?说一下他的底层实现原理?


免责声明:本文内容来源于网络,所载内容仅供参考。转载仅为学习和交流之目的,如无意中侵犯您的合法权益,请及时联系Docker中文社区!



文章来源: http://mp.weixin.qq.com/s?__biz=MzI1NzI5NDM4Mw==&mid=2247496483&idx=1&sn=e05ba86d43352cf897a54bcf9e01018e&chksm=ea1b1a63dd6c9375434c91da76191547d1d7d7a549a2ef4a9448afa0aad5acf36c5611bc8b6d&scene=0&xtrack=1#rd
如有侵权请联系:admin#unsafe.sh