目录

Docker 安全最佳实践教程:让容器更安全的 10 种方法

容器提供了一定程度的隔离,但并不保证安全。以下是从开发到部署都应采取的基本防护措施,每一项都附有实际运行结果。

1. 不要以 root 身份运行

$ docker run --rm alpine:3.23 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

$ docker run --rm -u 10001:10001 alpine:3.23 id
uid=10001 gid=10001 groups=10001

$ docker run --rm node:24-alpine id node
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
  • 容器内默认以 root(uid=0)身份运行。如果程序存在漏洞并遭到入侵,攻击者在容器内就拥有最高权限,再结合其他漏洞,就有可能影响主机。
  • -u 10001:10001 可以在运行时指定用户,无需修改镜像。
  • 许多官方镜像已经内置普通用户,例如 node 镜像中的 node(uid 1000),以及 distroless 镜像中的 nonroot。

最佳做法是在 Dockerfile 中切换用户:

RUN adduser -D -u 10001 app      # Alpine
# RUN useradd --system --uid 10001 app   # Debian / Ubuntu
USER app

实际示例请参阅 Python 应用 和 Node.js 应用。

2. 使用可信的基础镜像并固定版本

  • 优先选择 Docker Official Images(例如 python、node、nginx)或知名组织发布的镜像。
  • 不要使用来源不明的镜像,尤其是名称与热门镜像非常相似的镜像(例如 ngnix)。
  • 固定版本,例如 node:24-alpine 或更精确的 node:24.21-alpine,不要使用 node 或 latest。要完全固定版本,可以加上 digest:node:24-alpine@sha256:...。

3. 尽量缩小镜像

镜像中的每个软件包都可能存在漏洞。使用 slim、alpine、distroless 或多阶段构建,只保留运行所需的内容。不需要的工具(编译器、curl、ssh)越少,攻击者可利用的东西就越少,漏洞扫描结果也会更干净。

4. 不要把机密放进镜像

实测:机密会留在镜像中

以下 Dockerfile 犯了三个常见错误:

FROM alpine:3.23
ARG NPM_TOKEN
ENV DB_PASSWORD=super-secret
COPY .env /app/.env
RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
CMD ["sh"]

最后一行删除了用过的 .env,看起来没问题。构建并检查:

$ docker build -q -f Dockerfile.bad --build-arg NPM_TOKEN=npm_abc123secret -t leaky .
sha256:c9523bb4d0484e3abf049d15dec57df3282ff8203574c03127a8dfc6ee37bf9b

$ docker run --rm leaky ls /app

$ docker inspect -f "{{.Config.Env}}" leaky
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DB_PASSWORD=super-secret]

$ docker history --no-trunc --format "{{.CreatedBy}}" leaky | head -n 4
CMD ["sh"]
RUN |1 NPM_TOKEN=npm_abc123secret /bin/sh -c echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env # buildkit
COPY .env /app/.env # buildkit
ENV DB_PASSWORD=super-secret
  • ls /app 没有输出,.env 看起来已经被删除了。
  • 但 docker inspect 直接显示了 ENV 设置的 DB_PASSWORD=super-secret。
  • docker history 显示了构建时传入的 NPM_TOKEN=npm_abc123secret:ARG 的值会记录在使用它的层中。

.env 呢?镜像由多个层叠加而成,rm 只会在新的一层标记“删除”,COPY 那一层的内容仍然存在。使用 docker save 导出镜像,再从各层中找出 .env:

$ docker save leaky -o leaky.tar && mkdir x && tar -xf leaky.tar -C x && for l in x/blobs/sha256/*; do tar -tzf $l 2>/dev/null | grep -q "app/.env" && tar -xzOf $l app/.env; done; rm -rf x leaky.tar
API_KEY=sk-live-1234567890abcdef

.env 的完整内容被提取出来了。任何能拉取这个镜像的人都可以这样做。推送到 Registry 的镜像,应假设所有人都能看到每一层的内容。

使用 docker build --check 尽早发现问题

docker build --check 会根据变量名称判断 ARG、ENV 是否看起来像机密:

$ docker build --check -f Dockerfile.bad . 2>&1 | sed -n "/Check complete/,\$p"
Check complete, 2 warnings have been found!

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN")
Dockerfile.bad:2
--------------------
   1 |     FROM alpine:3.23
   2 | >>> ARG NPM_TOKEN
   3 |     ENV DB_PASSWORD=super-secret
   4 |     COPY .env /app/.env
--------------------

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ENV "DB_PASSWORD")
Dockerfile.bad:3
--------------------
   1 |     FROM alpine:3.23
   2 |     ARG NPM_TOKEN
   3 | >>> ENV DB_PASSWORD=super-secret
   4 |     COPY .env /app/.env
   5 |     RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
--------------------

NPM_TOKEN、DB_PASSWORD 都被标记了。但它只检查 ARG、ENV 的名称,无法发现 COPY .env 这种把机密文件复制进去的问题,所以仍然需要 .dockerignore。

正确做法

  • 不要在 Dockerfile 中使用 ENV 或 ARG 传入密码、API 密钥。
  • 使用 .dockerignore 排除 .env、私钥、.git,避免它们被 COPY . . 复制进去。
  • 运行时需要的机密,通过环境变量、--env-file 或 Compose secrets 在运行时传入,参阅环境变量与 .env 文件。
  • 构建时需要的机密(例如安装私有软件包所需的 token),使用 BuildKit 的 secret 挂载:
FROM alpine:3.23
RUN --mount=type=secret,id=npm_token \
    test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
CMD ["sh"]
$ NPM_TOKEN=npm_abc123secret docker build --progress plain --no-cache --secret id=npm_token,env=NPM_TOKEN -f Dockerfile.good -t safe . 2>&1 | grep "token length"
#5 [stage-0 2/2] RUN --mount=type=secret,id=npm_token     test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
#5 0.261 token length: 16

$ docker history --no-trunc --format "{{.CreatedBy}}" safe | head -n 2
CMD ["sh"]
RUN /bin/sh -c test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)" # buildkit

$ docker run --rm safe ls /run/secrets
ls: /run/secrets: No such file or directory
  • 构建期间,RUN 可以从 /run/secrets/npm_token 读取 token(长度为 16)。
  • docker history 中只有命令本身,没有 token 的值。
  • 镜像中也没有 /run/secrets,secret 只在该 RUN 执行期间挂载。

5. 不要向外部开放不必要的端口

# 不好:数据库对全网开放
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:18

# 好:只有本机可以连接
docker run -d -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=secret postgres:18

在 Linux 上,Docker 会直接修改 iptables 规则,-p 开放的端口会绕过 ufw、firewalld 等防火墙的设置:即使 ufw 没有允许 5432,外部仍然可以连接进来。最安全的做法是数据库、缓存等内部服务完全不使用 -p,只允许其他容器通过 Docker 网络连接,例如在 Nginx 反向代理 示例中,只有 Nginx 配置了 ports。

6. 限制容器权限

只读文件系统

$ docker run --rm --read-only alpine:3.23 touch /tmp/x
touch: /tmp/x: Read-only file system

$ docker run --rm --read-only --tmpfs /tmp alpine:3.23 sh -c "touch /tmp/x && echo ok"
ok

--read-only 会将容器文件系统设为只读,攻击者就无法修改程序或写入恶意文件。对于程序需要写入的位置(例如 /tmp),可以另外使用 --tmpfs 挂载内存中的临时空间,或挂载 Volume。

移除 Linux capabilities

在 Linux 中,root 的权限被拆分为许多 capabilities,例如 CHOWN(更改文件所有者)、NET_BIND_SERVICE(绑定 1024 以下的端口)。Docker 默认会为容器提供一部分 capabilities:

$ docker run --rm alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok

$ docker run --rm --cap-drop ALL alpine:3.23 chown nobody /etc/hostname
chown: /etc/hostname: Operation not permitted

$ docker run --rm --cap-drop ALL --cap-add CHOWN alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok
  • 默认情况下可以执行 chown。
  • 使用 --cap-drop ALL 移除所有 capabilities 后,即使是 root 也会遇到 Operation not permitted。
  • 使用 --cap-add 只添加真正需要的 capabilities。

大多数 Web 应用不需要任何 capabilities,可以直接使用 --cap-drop ALL。

禁止提升权限

$ docker run --rm alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs:	0

$ docker run --rm --security-opt no-new-privileges alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs:	1

NoNewPrivs: 1 表示容器中的进程无法通过 setuid 程序(例如 sudo、su)获得更高权限。即使以普通用户运行,也建议加上此选项。

综合使用:

docker run -d \
  --user 10001:10001 \
  --read-only --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  myapp

Compose 的写法:

services:
  app:
    image: myapp
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

绝对不要随意使用 --privileged,它会为容器提供所有 capabilities,并允许容器访问主机的所有设备,几乎等于关闭了所有隔离。

7. 限制资源

$ docker run --rm --pids-limit 50 alpine:3.23 sh -c "for i in \$(seq 100); do sleep 5 & done; wait" 2>&1 | sort | uniq -c
      1 sh: can't fork: Resource temporarily unavailable

$ docker run -d --name limited --memory 512m --cpus 1 --pids-limit 200 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
2edbff0c81d73b83f0055b781d8d8ad20661b2b64ef1dfd5a6def334ef02723c

$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}" limited
NAME      CPU %     MEM USAGE / LIMIT   PIDS
limited   0.00%     3.406MiB / 512MiB   2

$ docker inspect -f "Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}}" limited
Memory=536870912 NanoCpus=1000000000 PidsLimit=200

$ docker rm -f limited
limited
  • 设置 --pids-limit 50 时,容器尝试创建 100 个进程,超过上限后会出现 can't fork: Resource temporarily unavailable。这可以防止 fork 炸弹拖垮整台主机。
  • --memory 512m --cpus 1 会限制内存和 CPU,docker stats 的 LIMIT 显示为 512MiB。超过内存上限的进程会被系统终止(OOM),避免整台主机内存耗尽。
  • WARNING: Your kernel does not support swap limit capabilities 表示主机内核没有启用 swap 限制。内存限制仍然有效,只是无法同时限制 swap,可以忽略此警告。

Compose 的写法:

services:
  app:
    image: myapp
    mem_limit: 512m
    cpus: 1
    pids_limit: 200

8. 保护 Docker socket

/var/run/docker.sock 是 Docker 的控制接口。能够访问它的人可以创建任何容器,包括挂载主机的根目录。下面实测将主机的 / 挂载到容器中:

$ docker run --rm -v /:/host alpine:3.23 sh -c "head -n 2 /host/etc/os-release; ls /host/var/lib/docker | head -n 5"
NAME="Alpine Linux"
ID=alpine
buildkit
containerd
containers
engine-id
image

容器直接读取到了 Docker 主机的 /etc/os-release 和 /var/lib/docker(本教程的测试环境是 Alpine Linux)。同样的方法也能读写 /etc/shadow、SSH 密钥等任何文件。因此:

  • 能够运行 docker 命令,就等同于拥有主机的 root 权限。将用户加入 docker 组,就等于赋予其 root 权限。
  • 不要随意将 -v /var/run/docker.sock:/var/run/docker.sock 挂载给容器,除非你完全信任该镜像(例如 Traefik、Portainer、Trivy 这类需要它的工具)。
  • 不要将 Docker daemon 的 TCP 端口(2375)开放到网络上,这等于公开主机的 root 权限。
  • 需要更强的隔离时,可以了解 Docker 的 rootless 模式。

9. 扫描镜像漏洞

开源工具 Trivy 可以扫描镜像中操作系统软件包和编程语言软件包的已知漏洞。通过容器运行,无需安装:

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx:1.30-alpine

Report Summary

┌───────────────────────────────────┬────────┬─────────────────┐
│              Target               │  Type  │ Vulnerabilities │
├───────────────────────────────────┼────────┼─────────────────┤
│ nginx:1.30-alpine (alpine 3.24.2) │ alpine │        3        │
└───────────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)


nginx:1.30-alpine (alpine 3.24.2)
=================================
Total: 3 (HIGH: 3, CRITICAL: 0)

┌──────────┬─────────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────────────┐
│ Library  │  Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                          Title                          │
├──────────┼─────────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ libexpat │ CVE-2026-93990  │ HIGH     │ fixed  │ 2.8.4-r0          │ 2.8.5-r0      │ expat: Expat: XML Injection via Malformed UTF-16 Input  │
│          │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-93990              │
├──────────┼─────────────────┤          │        ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ pcre2    │ CVE-2026-103111 │          │        │ 10.48-r0          │ 10.49-r0      │ pcre2: pcre2: Out-of-bounds write via crafted regular   │
│          │                 │          │        │                   │               │ expression                                              │
│          │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-103111             │
├──────────┼─────────────────┤          │        ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ tiff     │ CVE-2026-4775   │          │        │ 4.7.1-r0          │ 4.7.2-r0      │ libtiff: libtiff: Arbitrary code execution or denial of │
│          │                 │          │        │                   │               │ service via signed integer...                           │
│          │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-4775               │
└──────────┴─────────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────────────┘
  • --severity HIGH,CRITICAL 只显示高危及以上级别的漏洞,--quiet 会隐藏下载漏洞数据库时的进度。
  • 挂载 docker.sock 让 Trivy 读取本机镜像;trivy-cache Volume 保存漏洞数据库,下次扫描时无需重新下载。
  • 结果显示 nginx:1.30-alpine 的基础 Alpine 镜像有 3 个 HIGH 级别的漏洞,Status 都是 fixed,并且列出了 Fixed Version:也就是说 Alpine 已经发布了修复版本,但构建该镜像时还没有包含这些修复。

扫描结果会随时间变化:每天都会发现新的漏洞,镜像也会不断更新。你运行时看到的结果很可能不同。

Docker Desktop 和新版 CLI 也内置了 Docker Scout(docker scout quickview、docker scout cves),登录 Docker 账号后即可使用。建议在 CI 流程中自动扫描,发现严重漏洞时阻止部署。

10. 定期更新

基础镜像会持续修复漏洞。接着使用上面的扫描结果,在 Dockerfile 中升级软件包后重新扫描:

$ cat Dockerfile
FROM nginx:1.30-alpine
RUN apk upgrade --no-cache

$ docker build -q -t nginx-patched .
sha256:c88ea56e7062e7703dccc5eb2fedea74c333f33982d9735753056a50858646f2

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx-patched

Report Summary

┌───────────────────────────────┬────────┬─────────────────┐
│            Target             │  Type  │ Vulnerabilities │
├───────────────────────────────┼────────┼─────────────────┤
│ nginx-patched (alpine 3.24.2) │ alpine │        0        │
└───────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)

apk upgrade 将有漏洞的软件包升级到修复版本后,HIGH 和 CRITICAL 漏洞数量变成了 0。

在实际使用中,更好的做法是定期使用 docker build --pull 重新构建(--pull 会先下载基础镜像的最新版本),然后重新部署。可以使用 Dependabot、Renovate 等工具自动提醒你基础镜像有新版本。镜像只构建一次、之后几年都不更新,漏洞只会越来越多。

检查清单

  • Dockerfile 中有 USER,不以 root 身份运行
  • 基础镜像来源可信,版本固定
  • 使用精简镜像或多阶段构建
  • 镜像中没有密码、密钥、.env(检查 docker history 和 docker inspect)
  • 构建时使用 RUN --mount=type=secret 传入机密
  • 数据库等内部服务没有向外部开放端口
  • 根据需要使用 --read-only、--cap-drop ALL、no-new-privileges
  • 没有使用 --privileged,也没有随意挂载 docker.sock
  • 设置了内存、CPU 和 PID 上限
  • CI 中配置了镜像漏洞扫描
  • 定期使用 --pull 重新构建以获取安全更新

重点回顾

  • 在 Dockerfile 中添加 USER,以非 root 用户运行。
  • ARG、ENV 和已删除的文件仍会留在镜像中;构建时的机密应使用 --secret。
  • 不需要对外提供服务的容器不要使用 -p;-p 会绕过 ufw。
  • 能够访问 docker.sock 就等同于拥有主机 root 权限。
  • 定期扫描并使用 --pull 重新构建。

动手练习

练习 1:使用 docker history --no-trunc 检查你自己构建的镜像,看看是否出现密码或 token?

提示

查看 CREATED BY 栏中的 ARG、ENV、RUN 命令内容,以及 docker inspect -f "{{json .Config.Env}}" 镜像。如果发现机密,应改用 --secret 或运行时环境变量,并撤销已经泄露的密钥。

常见问题

容器里的 root 就是主机的 root 吗?

通常模式下它们是同一个 uid,只是受到 namespace、capabilities、seccomp 的限制。要让它不再是主机的 root,请使用 [Rootless 模式](/zh/docker/rootless-podman/)。

镜像扫描出很多漏洞怎么办?

先处理有 Fixed Version 的高危漏洞:重新构建(docker build --pull)或升级软件包。没有修复版本的漏洞,则评估是否真的会用到相关软件包。

Docker Scout 和 Trivy 有什么区别?

它们都是漏洞扫描工具。Scout 集成在 Docker Desktop 中,需要登录;Trivy 是开源工具,可以通过容器运行。

更新于 2026年10月14日