本节摘要:安全加固与密钥管理解决"容器被攻破后损失有多大"的问题。核心做法有三条:用 secrets 文件方式管理数据库密码、API 密钥等敏感信息,而不是塞进环境变量;用非 root 用户运行容器,配合只读挂载缩小攻击面;镜像只从可信来源拉取并定期扫描依赖漏洞。本节给出 secrets 定义与读取的完整示例、非 root 用户的 Dockerfile 写法、综合加固配方,以及一张纵深防御分层图。
先看一个常见的反面教材:数据库密码直接写在 docker-compose.yml 里,或者更"聪明"一点,写进 environment 段。这两种做法都是把密钥和配置混在一起,而配置是要进版本库、要分发给所有人的。密钥一旦进了 Git 历史,删掉提交也抹不掉泄露事实。
Compose 提供的方案是 secrets。思路很简单:密钥内容放在单独的文件里,compose 只声明"这个服务需要哪个 secret",容器启动时 Docker 把文件内容挂载进去,应用从固定路径读取。先创建一个包含密钥内容的文件,例如 db_password.txt:
my_secure_password
然后在 compose 文件里定义并引用它:
version: "3.9" services: db: image: postgres:14 secrets: - db_password secrets: db_password: file: ./db_password.txt
三个关键点:顶层 secrets 段定义了一个名为 db_password 的 secret,file: ./db_password.txt 指定密钥内容来自哪个文件;db 服务通过 secrets 属性声明自己需要 db_password;容器启动后,secret 以文件形式挂载到 /run/secrets/db_password,应用读这个文件就能拿到密码。
应用侧读取的代码也很直观,以 Python 为例:
import os def get_db_password(): try: with open("/run/secrets/db_password", "r") as f: return f.read().strip() except FileNotFoundError: return "default_password" db_password = get_db_password()
注意读取后要 strip 掉换行符,文件方式写入时末尾会带一个换行。找不到文件时返回默认密码这种兜底逻辑,只建议在开发环境用,生产环境宁可直接抛异常,也不要让服务带着错误密码悄悄启动。
原素材里还提到一种环境变量方式的 secret 定义(secrets: api_key: environment: API_KEY),它适合本地开发或测试,生产环境我们一律推荐文件方式:文件与 compose 分离、便于轮换、不会在进程列表里暴露。
很多人觉得"环境变量比写死在文件里安全",这个直觉是错的。环境变量有三个具体风险。
第一,可见性。docker inspect 可以原样看到容器的环境变量,任何能执行 Docker 命令的人都能读到密钥;docker-compose 配置文件里的 environment 字段也是明文躺在服务器上。第二,继承扩散。容器里启动的子进程会继承全部环境变量,一个被攻破的 sidecar 进程能顺手把数据库密码带走;而 secrets 以文件形式存在,应用需要显式去读,默认不会扩散。第三,日志泄漏。很多框架和调试工具会把环境变量打进日志或错误报告,密钥就这样进了日志系统。
另外要提醒:不要把密钥写进 Dockerfile 的 ENV 指令。ENV 层会被写进镜像历史,docker history 一查就暴露,而且镜像要推到仓库,等于把密钥公开分发。
默认情况下,容器以 root 用户运行。这意味着容器一旦被攻破,攻击者拿到的是宿主机上的 root 权限——配合错误挂载,可以读写宿主机文件。把容器切换到非 root 用户,攻击者最多拿到一个普通用户的权限,很多破坏动作就做不成了。
在 compose 里直接指定用户。 最简单的方式是用 user 指令:
version: "3.9" services: app: build: . user: "1000:1000" # 或者 "myuser:myuser"
1000:1000 是用户 ID 和组 ID。我们更推荐用数字 ID 而不是用户名:如果镜像里没有叫 myuser 的用户,写用户名会直接启动失败,而数字 ID 不存在这个坑——内核只认数字。
在 Dockerfile 里创建专用用户。 更规范的做法是构建镜像时就建好用户并切过去:
FROM ubuntu:latest RUN apt-get update && apt-get install -y --no-install-recommends \ adduser \ && rm -rf /var/lib/apt/lists/* ARG USER_ID=1000 ARG GROUP_ID=1000 RUN groupadd -g ${GROUP_ID} myuser && \ useradd -u ${USER_ID} -g myuser myuser WORKDIR /app COPY . . RUN chown -R myuser:myuser /app USER myuser CMD ["./my_app"]
这段 Dockerfile 做了四件事:用 ARG 声明 USER_ID 和 GROUP_ID 两个构建参数,默认都是 1000,方便在 CI 里覆盖;创建 myuser 用户和同名组;把工作目录 /app 的所有权交给 myuser;用 USER myuser 把后续指令和运行时用户都切过去。chown -R myuser:myuser /app 这步不能省,否则应用没有权限写自己的目录。
直接用官方镜像自带的非 root 用户。 很多官方镜像已经内置了专用用户:nginx 镜像默认以 nginx 用户运行 worker,postgres 镜像默认用 postgres 用户,mysql 镜像用 mysql 用户,redis 镜像较新版本也默认非 root。用这些镜像时,只要不手动切回 root,就已经是非 root 运行了。
非 root 只是第一道防线,完整的加固思路是纵深防御——每道防线独立存在,攻破一道还有下一道。按从外到内的顺序,我们建议部署以下五道防线:

每道防线的具体做法:镜像可信——只用官方镜像或经过验证的镜像,固定版本号,定期用扫描工具检查镜像依赖漏洞,发现高危就更新基础镜像;运行时权限——非 root 运行,避免使用 privileged 模式,数据卷按需只读挂载;网络隔离——用自定义网络把服务分组,只让必要的服务互相通信,端口最小暴露;密钥管理——本节讲的 secrets 方案;最小权限——数据库建专用账号(比如 POSTGRES_USER 用 myuser 而不是默认的 postgres 超级用户),容器只具备完成任务所需的权限。
只读挂载是容易被忽略的一条,写法是在卷映射末尾加 ro:
version: "3.9" services: app: image: my-app volumes: - ./config:/app/config:ro
:ro 表示容器只能读这个目录。配置文件、证书这类只读资源全部用 ro 挂载,即使容器被攻破,攻击者也改不了挂载进来的东西。
密钥从定义到被应用读取的完整流转,用一张图就能说清:
把 secrets、非 root、专用账号组合起来,就是一份可以直接用的加固版应用:
docker-compose.yml:
version: "3.9" services: db: image: postgres:14 secrets: - db_password environment: POSTGRES_USER: myuser POSTGRES_DB: mydb volumes: - db_data:/var/lib/postgresql/data web: build: . ports: - "8000:8000" depends_on: - db environment: DATABASE_URL: postgres://myuser:${DB_PASSWORD}@db:5432/mydb secrets: - db_password user: "1000:1000" volumes: db_data: secrets: db_password: file: ./db_password.txt
Dockerfile(web 服务):
FROM python:3.9-slim-buster WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ adduser \ && rm -rf /var/lib/apt/lists/* ARG USER_ID=1000 ARG GROUP_ID=1000 RUN groupadd -g ${GROUP_ID} myuser && \ useradd -u ${USER_ID} -g myuser myuser COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN chown -R myuser:myuser /app USER myuser CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
解读几处关键设计。数据库密码存在 db_password.txt 里,通过 secrets 同时注入 db 和 web 两个服务,密码既不进 compose 文件也不进环境变量;数据库用户设置为 myuser,绕开默认的 postgres 超级用户,即使 web 容器被攻破,拿到的也只是普通数据库账号;web 服务用 user: "1000:1000" 以非 root 运行,与 Dockerfile 里创建的 myuser 对应(ID 一致才保证文件权限匹配);requirements.txt 先于源码 COPY,是给构建缓存用的——依赖没变时这一层不会失效。注意 web 的 DATABASE_URL 里引用了 ${DB_PASSWORD},这在示例中保留了原素材的写法,实际生产应改为从 secret 文件读取,避免密码出现在环境变量中。
敏感信息的存放方式,我们做了一张对比表:
| 存放方式 | 风险 | 适用场景 |
|---|---|---|
| 硬编码进 compose 文件 | 密钥随配置进版本库,永久泄露 | 任何情况都不该用 |
| 写进 Dockerfile ENV | 进镜像历史,docker history 可见 | 任何情况都不该用 |
| 环境变量注入 | docker inspect 可见,子进程继承扩散 | 仅限本地开发与测试 |
| secrets 文件方式 | 挂载到 /run/secrets,默认不扩散 | 生产环境首选 |
| 外部密钥管理服务 | 依赖额外组件,复杂度高 | 多环境、密钥轮换频繁的大团队 |
再补充一份高频疏漏清单,每一条我们都见过真实案例。
镜像从不明来源拉取。 生产环境只允许从可信仓库拉镜像:官方镜像、私有仓库、经过验证的第三方。镜像名要精确到版本,禁止拉 latest 后不核对来源。有条件就给镜像做依赖扫描,用 trivy 之类的扫描器检查已知漏洞,高危漏洞挡在 CI 里,别让带洞镜像上线。
容器暴露了不必要的端口。 数据库、Redis 这类内部服务不需要对外发布端口,compose 里只给入口服务写 ports,内部通信走自定义网络。端口每少暴露一个,攻击面就小一块。上线前用 docker compose ps 核对一遍端口映射,跟预期清单对账。
调试服务开在生产。 忘了关的开发容器、调试端口、管理后台,都是后门。上线前清点一遍运行中的容器,跟 compose 定义对账,多出来的直接删。
2375 端口裸奔。 Docker 的远程 API 端口一旦暴露到公网且未加密,等于把整台机器的 root 交出去。生产环境要么不开远程 API,要么走 TLS 认证,绝不能裸暴露。
日志里打密钥。 应用启动时把连接串、密码打进日志,密钥就进了日志系统,等于半公开。开发规范里加一条:日志禁止输出凭据,代码评审时重点看启动日志。
最后纠正一个常见误解:自定义网络不是安全机制。它隔离的是"未声明的连接",同一网络内的服务之间默认互通。所以最小权限的网络写法是:每个业务组一个网络,只把需要互相通信的服务放进同一个网络,对应纵深防御图里的第三道防线。
最后别忘了密钥文件本身的权限。db_password.txt 在宿主机上要 chmod 600 只允许属主读写,别用 644 让同机其他用户都能读;secret 文件所在目录也不该有宽松权限。compose 里引用的文件路径保持相对路径,部署时从密钥仓库同步过来,而不是随手创建在项目目录里——顺手创建的文件最容易忘了收紧权限。
⚠️ 别迷信"环境变量更安全"。docker inspect 一行命令就能看到全部环境变量,而 secrets 文件默认只在容器内部可见,两者暴露面完全不同。
💡 密钥轮换的成本决定了你会不会换。secrets 文件方式轮换只需改文件内容再重启服务;如果把密钥写死在镜像里,轮换就要重新构建、重新推送、重新拉取,团队嫌麻烦就会拖着不换——安全方案要选"换得起"的那种。
下一节我们把视角从"防攻破"转到"防拖垮"——资源限制与性能优化,先保住同机其他服务的命。