一、这个危险配置为什么如此普遍
很多团队用 Docker Compose 部署业务应用时,最喜欢图省事。一个 docker-compose.yml 文件里写好几个服务,environment 下面直接挂上 DB_PASSWORD=123456,API_KEY=xxxxx,然后容器起来就完事了。为了省得处理文件权限和目录属主的问题,还会在 Dockerfile 里直接用默认的 root 用户,或者在 Compose 里不写 user 字段,让容器以 root 身份跑。
这种情况在日常生活中太常见了,就像把家里的备用钥匙塞在门口地垫底下。你以为没人知道,可小偷早就把地垫翻了个遍。密钥写在环境变量里,就等于把钥匙固定在门框上;容器以 root 运行,就等于告诉小偷:进了门以后,整个房子你随便逛。
你可能觉得,容器只是一个隔离环境,就算被攻破,外面还有一层保护。但事实远没有那么乐观。尤其是当容器内运行着旧版本的基础镜像、带着一堆不必要的系统工具,又拥有 root 权限时,攻击者离宿主机往往只差一小步。这一小节先不往下深挖,我们先弄清楚一个问题:这种风险到底有多大。
二、容器被攻破到底有多严重
先说最直接的一点:如果容器里的进程是 root,那么攻击者通过 Web 漏洞、依赖库漏洞或者不安全的接口进入容器时,他拿到的就是 root 权限。root 这个词可能被说烂了,但它的含义是:可以安装软件、修改配置、读取所有能读取的文件、创建新的用户、加载内核模块。虽然容器有命名空间和 cgroup 做隔离,但内核是共享的,历史上出现过不少因为容器内 root 权限过大进而逃逸到宿主机的漏洞。
就算不逃逸,攻击者也能直接读取环境变量里的密钥。环境变量这个东西,压根没有加密能力。它只是为了给进程传递配置而存在的。任何人都能用一条简单的命令把它读出来。比如:
# 技术栈:Shell + Docker Compose
# 查看容器里一号进程的环境变量,密钥一目了然
docker compose exec web cat /proc/1/environ
你可能会说,服务器只有运维能登录,别人看不到。可攻击者一旦进了容器,他能看到的比你想象的多。他还能用 docker inspect 从宿主机这一侧看镜像配置,如果宿主机 Docker API 暴露了,那更不用说了。
密钥一旦泄露,攻击者就不会只停留在容器里。他会拿着数据库密码去导数据,拿着 API 密钥去调用云服务,拿着支付密钥去刷接口。到了这一步,你的整个业务就相当于裸奔了。更麻烦的是,环境变量还会被一些启动脚本、日志框架、错误监控系统顺手打出去,等于密钥在不知不觉中又复制了很多份。
所以,把 root 运行和明文环境变量组合在一起,等于把一个装了各种武器的小房间直接交到攻击者手里。接下来我们要做的,就是把房间里最危险的几样东西收走。
三、加固第一步:把容器改成普通用户运行
这个操作不难,但很多人因为调试时遇到过权限问题,就干脆放弃了。其实只要养成习惯,后面会很舒服。我们分几步来改。
3.1 Dockerfile 里创建普通用户
在构建镜像的时候,就不要让默认用户是 root。以 Node.js 应用的镜像为例,我们可以在 Dockerfile 中创建一个普通用户,然后把项目目录交给他。
下面这个 Dockerfile 示例的技术栈是 Shell + Docker Compose,代码块里用的是 dockerfile 语法:
# 技术栈:Shell + Docker Compose(Dockerfile 语法)
# 基础镜像,使用 Node 16 的布丁版本
FROM node:16-slim
# 创建用户组和用户,-r 表示系统账号,-m 创建家目录
# 登录 shell 设为 nologin,避免用户被用来登录系统
RUN groupadd -r appgroup && useradd -r -g appgroup -m -s /usr/sbin/nologin appuser
# 创建 /app 目录,并把它交给普通用户
# 注意:如果项目目录里还有 node_modules,也需要一并授权
RUN mkdir -p /app && chown -R appuser:appgroup /app
# 切换到普通用户,之后所有 RUN、CMD、ENTRYPOINT 都以该用户执行
USER appuser
# 设置工作目录
WORKDIR /app
# 复制依赖清单和源码
COPY --chown=appuser:appgroup package*.json ./
RUN npm install --production
COPY --chown=appuser:appgroup . .
# 启动服务
CMD ["node", "server.js"]
这里有几个关键点。USER appuser 之后,后面的指令就不再以 root 执行了。COPY --chown 可以确保复制进来的文件属于普通用户,否则启动时可能出现没有读取权限的问题。npm install 如果不需要在容器里安装全局包,普通用户就够了。
3.2 Docker Compose 中强制指定用户
有时候你用现成的镜像,不方便改 Dockerfile。没关系,Docker Compose 也支持直接指定用户。下面这个 Compose 片段的技术栈是 Shell + Docker Compose,使用 YAML 语法:
# 技术栈:Shell + Docker Compose(YAML 配置语法)
services:
web:
image: myapp:latest
# 强制以宿主机上 UID 1000、GID 1000 的用户运行
# 这样即使镜像里没有创建用户,也不会用 root
user: "1000:1000"
ports:
- "8080:8080"
# 环境变量里仅保留非敏感配置,真正的密钥不用这种写法
environment:
- APP_ENV=production
使用 user 字段时,要注意宿主机的磁盘权限。假如应用需要往挂载目录里写文件,而目录属于 root,那普通用户就写不了。这时候可以把宿主机目录的属主改成你的应用用户,或者使用后面的 tmpfs 方案来规避写入问题。
3.3 验证一下是否生效
改完配置以后,别急着开心,先进容器里看一眼。下面这些命令的技术栈是 Shell + Docker Compose:
# 技术栈:Shell + Docker Compose
# 重新创建容器,确保新的 user 配置生成了
docker compose up -d --force-recreate
# 进入容器,查看当前用户
docker compose exec web id
# 输出应该是:uid=1000(appuser) gid=1000(appgroup) groups=1000(appgroup)
# 也可以查看第一个进程是谁启动的
docker compose exec web ps -o user,pid,command
如果看到 uid=0(root),那说明配置没有生效,需要检查一下 Compose 文件里的语法,或者镜像是否覆盖了 USER 指令。确认是普通用户以后,至少攻击者在容器里搞破坏时,权限会被大大限制住。
四、加固第二步:把密钥从环境变量里挪走
光是非 root 运行还不够,密钥如果还躺在环境变量里,攻击者依然能轻松拿到。环境变量有一个坏毛病:它不只是给当前进程看的,还会被子进程继承,被调试工具打印,被日志系统记录。所以第二步,我们要学会几种真正的密钥管理方案。
4.1 环境变量为什么不能放密钥
先做一个比喻。环境变量像是办公室里的白板,谁路过都能看到。你说白板上的密码不写下来就记不住,但问题是,白板上的字一旦写上去,每个进去的人都会看见。docker inspect 这个命令就能直接看到容器配置里的环境变量:
# 技术栈:Shell + Docker Compose
# 直接查看容器配置,所有 environment 字段都会显示出来
docker inspect web
如果你把密钥写在 docker-compose.yml 里,那这个文件本身也要被保护。很多项目的 Compose 文件会放进 Git 仓库,一旦推送到公开仓库,密钥就全没了。就算放在私有仓库,团队成员、CI 系统、第三方工具也都可能看到。所以正确思路是:密钥要跟代码和配置文件分离。
4.2 能临时缓解但别依赖的 env_file
env_file 可以把环境变量挪到一个单独的文件里,至少不会直接出现在 Compose 文件中。下面是示例,技术栈是 Shell + Docker Compose:
# 技术栈:Shell + Docker Compose(YAML 配置语法)
services:
web:
image: myapp:latest
# 从 .env 文件读取环境变量,注意这个文件不要进 Git
env_file:
- .env
对应的 .env 文件内容:
# 技术栈:Shell + Docker Compose
# 这个文件建议放在 /etc/myapp/.env,并设置权限
DB_PASSWORD=SuperSecret123
API_KEY=abcdefghijk
然后你需要设置文件权限:
# 技术栈:Shell + Docker Compose
# 只允许当前用户和 root 用户读取
chmod 600 .env
这个方案只是把明文换了个地方,攻击者进入容器后还有办法通过 /proc/1/environ 读到。所以它只能算过渡,不是终点。
4.3 更合适的方式:Docker Secrets
Docker Secrets 是 Docker 提供的一种密钥管理方案。它把密钥以临时文件的形式挂载到容器内部,而不是放进环境变量。应用启动时从文件里读取密钥,用完以后什么都不留。这一套在 Docker Swarm 模式里支持得最自然,新版本的 Docker Compose 直接也支持 secrets 关键字。
先看一个完整的 Compose 示例,技术栈是 Shell + Docker Compose:
# 技术栈:Shell + Docker Compose(YAML 配置语法)
# 顶层定义 secrets,来源是本地文件
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
services:
web:
image: myapp:latest
user: "1000:1000"
# 把 secrets 挂载到容器
secrets:
- db_password
- api_key
environment:
- APP_ENV=production
在宿主机上创建密码文件:
# 技术栈:Shell + Docker Compose
# 创建 secrets 目录,并写入密码文件
mkdir -p secrets
printf 'MySuperSecretPassword\n' > secrets/db_password.txt
printf 'MyApiKey123\n' > secrets/api_key.txt
# 收紧权限
chmod 600 secrets/db_password.txt secrets/api_key.txt
容器启动后,这两个密钥会分别出现在:
/run/secrets/db_password
/run/secrets/api_key
应用启动脚本可以用下面的方式读取,技术栈同样是 Shell + Docker Compose:
# 技术栈:Shell + Docker Compose
#!/bin/sh
# 启动脚本:从 Docker Secrets 文件读取密钥
DB_PASSWORD=$(cat /run/secrets/db_password)
export DB_PASSWORD
# 如果你不希望把密钥传给所有子进程,也可以直接把它作为参数传给应用
# 这里为了演示,仍然使用环境变量
exec node server.js
需要注意,如果使用 Docker Swarm 模式,部署命令要用 docker stack deploy 而不是 docker compose up:
# 技术栈:Shell + Docker Compose
# 在 Swarm 集群里部署
docker swarm init
docker stack deploy -c docker-compose.yml myapp
这个方案的好处是密钥不会出现在镜像层里,也不会出现在 docker inspect 的环境变量部分,容器崩溃后密钥文件也随之消失。它适合单机或中小规模集群。
4.4 更复杂但更灵活:外部密钥管理服务
如果你们的团队用 Kubernetes,或者密钥需要频繁轮换、定时更新,Docker Secrets 就不太够了。这时可以引入外部密钥管理服务,比如 HashiCorp Vault、云服务商提供的密钥管理服务。应用启动时向这些服务发起请求,临时获取密钥。
这里给出一个用 Shell 脚本从 Vault 拉取密钥的示例,技术栈仍然是 Shell + Docker Compose:
# 技术栈:Shell + Docker Compose
#!/bin/sh
# start.sh
# 这个脚本在容器启动时执行,它不把密钥写入任何配置文件
# 从环境变量里读取 Vault 地址、角色 ID 和 Secret ID
VAULT_ADDR="${VAULT_ADDR:-https://vault.example.com}"
ROLE_ID="${ROLE_ID}"
SECRET_ID="${SECRET_ID}"
# 第一步:使用 AppRole 模式向 Vault 登录,拿到临时 client token
VAULT_TOKEN=$(curl -s --request POST "${VAULT_ADDR}/v1/auth/approle/login" \
--data "{\"role_id\":\"${ROLE_ID}\",\"secret_id\":\"${SECRET_ID}\"}" \
| sed -n 's/.*"client_token":"\([^"]*\)".*/\1/p')
# 第二步:用 token 获取指定路径的密钥数据
DB_PASSWORD=$(curl -s --header "X-Vault-Token: ${VAULT_TOKEN}" \
"${VAULT_ADDR}/v1/secret/data/db" \
| sed -n 's/.*"password":"\([^"]*\)".*/\1/p')
# 导出给应用进程
export DB_PASSWORD
# 启动业务进程
exec node server.js
这个方案的优势是密钥不会存在于本地镜像、Compose 文件或者 Secrest 目录里,并且可以做到动态续期、权限细分。缺点也很明显,需要额外维护一套 Vault 服务,登录方式、网络策略、证书管理都会增加复杂度。
五、额外加固:让容器更难攻破
非 root 用户和密钥管理解决了两个最大的问题,但我们还可以再补上几道防线,让容器即使被攻破,攻击者也只能在很小的圈子里打转。
5.1 丢掉所有多余权限
Linux 容器默认会带一堆 capability,就算你以普通用户运行,其中一些能力依然可能被利用。最保险的做法是直接 drop 掉所有 capability,再根据业务需要添加回去。下面这个 Compose 片段的技术栈是 Shell + Docker Compose:
# 技术栈:Shell + Docker Compose(YAML 配置语法)
services:
web:
image: myapp:latest
user: "1000:1000"
# 丢弃所有 Linux 能力,让容器更干净
cap_drop:
- ALL
# 如果确实需要绑定低端口,可以添加一个 net_bind_service
cap_add:
- NET_BIND_SERVICE
大部分业务应用都不需要额外能力,这么一改,攻击者想挂载文件系统、执行原始套接字操作就难了。
5.2 使用只读文件系统
容器运行以后,业务代码和系统文件理论上不应该被修改。把根文件系统设置成只读,可以防止攻击者在容器里写入自己的工具、替换二进制文件。同时用一个 tmpfs 提供临时目录,让应用正常写临时文件。示例继续沿用同一个 Compose 文件:
# 技术栈:Shell + Docker Compose(YAML 配置语法)
services:
web:
image: myapp:latest
user: "1000:1000"
cap_drop:
- ALL
# 根文件系统只读
read_only: true
# 给可写目录单独开一个内存盘
tmpfs:
- /tmp
这时候如果应用需要写日志,请把日志目录也挂载到 tmpfs 或者宿主机卷上,并且确保宿主机卷有正确的权限。
5.3 禁止提权
容器里有一项安全选项叫 no-new-privileges,开启以后,就算攻击者拿到普通用户权限,也没法通过 setuid 之类的机制提权到 root。配置非常简单:
# 技术栈:Shell + Docker Compose(YAML 配置语法)
services:
web:
image: myapp:latest
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
这一层非常划算,几乎是零成本把提权路线卡死了。
5.4 日志安全也别小看
日志系统是密钥泄露的另一个重灾区。很多框架启动时会打印环境变量、请求参数,甚至有开发人员把密码调试到日志里。建议在日志框架里过滤掉常见的敏感字段,比如password、token、secret。同时日志文件本身要设置权限,避免被容器内其他进程偷看。
六、应用场景与优缺点分析
非 root 运行适用于所有业务容器,特别是 Web 服务、API 服务、定时任务这些长期运行的进程。它的优点是把容器受损后的危害面降了下来,缺点是会带来文件和目录权限调整的成本,比如挂载卷需要正确设置属主。
Docker Secrets 适用于单体应用和小规模集群,尤其适合没有专职运维的团队。优点是简单、直接,和 Docker Compose 完全集成,密钥以文件挂载的方式进入容器,不在镜像层和环境变量里残留。缺点是不适合大规模密钥管理,密钥轮换不够灵活,而且需要 Swarm 模式或者较新的 Compose 版本支持。
外部密钥管理服务则适用于密钥数量多、需要动态轮换、涉及权限审计的大团队。优点是安全级别高、可控性强,可以和云环境或 K8s 深度集成。缺点是部署和维护成本高,需要掌握额外的工具链,对普通开发者来说有一定的学习曲线。
环境变量本身并不是完全不能用,它适合传递非敏感的配置项,比如环境标识、日志级别、功能开关。真正敏感的密钥,最好永远不要出现在环境变量里。
七、注意事项
第一个要注意的是文件权限。非 root 用户能不能读密钥文件,挂载目录能不能写,都需要提前验证。可以在启动脚本里加检查和报错。
第二个是密钥轮换。密钥不能一辈子不变。Docker Secrets 在 Swarm 模式下更新 secret 后,需要滚动更新服务才能让容器读取新值。Vault 这类服务则支持自动续期和动态生成,但你的应用代码需要能处理密钥变化。
第三个是不要在镜像构建过程中把密钥烧进去。比如 Dockerfile 里用 ARG 传密码,再把它写进某个配置,这种做法会在镜像层里留下痕迹,比环境变量还要隐蔽。所有构建过程都应该使用构建机上的密钥系统,而不是直接写在历史层里。
第四个是不同环境的差异。本地开发可以用 .env 文件,但生产环境必须使用 secrets 或密钥管理服务。尽量不要让开发和生产用同一套密钥体系,否则开发机被攻破时,生产环境也会一起沦陷。
第五个是关于安全“防身术”的顺序。先保证非 root 用户,再处理密钥,最后再考虑 cap_drop 和只读文件系统。不要一上来就试着把所有安全选项堆满,那样很容易让应用起不来,最后逼得你放弃整条防线。
八、总结
回过头看,我们做的事情其实很朴素:不让容器用 root 跑,不把密钥放在环境变量里,再加上几道简单的安全开关。这些步骤不需要重写业务代码,也不影响现有功能,但对安全性来说是质的提升。
现实中的攻击往往不是高深莫测的漏洞,而是把一个个低风险问题串起来。root 权限加明文密钥,就是最典型的致命组合。只要把这扇门关上,攻击者就算进了容器,也拿不到最有价值的东西,更不容易逃出隔离层。
建议你把这篇文章当作一份检查清单,找一个不那么重要的服务先练手,把 Dockerfile 里的 USER 加上,把密码从 environment 里挪到 secrets 里,再顺手加一行 cap_drop: - ALL。当你把所有这些变成习惯以后,部署安全就不再是墙上的一句口号,而是容器每次启动时实实在在的一道防线。
评论
围绕“使用 Docker Compose 部署业务应用时,如果容器以 root 用户运行且密钥明文写在环境变量里,一旦容器被攻破风险极大,需要从非 root 运行与密钥管理方案上做安全加固”参与讨论