6312 字
32 分钟
Docker 安全与资源限制实战
2026-11-15

前面几篇我们把 Docker 装好、把容器跑起来、又让它能自动更新。但有个问题一直没正面讲:容器到底有多安全? 很多人下意识把容器当成虚拟机,觉得「就算容器里被攻破,也出不了这个盒子」。这篇就把这层窗户纸捅破:先讲清容器默认的权限到底有多大,再一条一条把笼子扎紧——非 root 运行、收紧 capabilities、只读文件系统、资源限额,最后讲三类最常见的真实风险:docker.sock、端口暴露、镜像供应链。文末有一份汇总好的加固版 Compose 模板,可以直接抄。

先认清:容器不是虚拟机#

虚拟机有自己的一套内核,宿主机和虚拟机之间隔着 hypervisor;容器不一样——所有容器共享宿主机的同一个内核,容器里的进程本质上就是宿主机上的进程,只是被 namespace 隔离了视野、被 cgroup 限制了资源。

更关键的是默认身份:容器里跑的进程默认是 root(uid 0)。由于容器与宿主机共用内核、默认也没有开启 user namespace 重映射,这个 root 虽然被关在隔离环境里,但它离宿主机 root 的距离,远比你想象的近:

  • 一旦出现内核漏洞或容器运行时漏洞,容器内的 root 就是第一个摸到逃逸门的人;
  • 挂载配置写错(比如把宿主机目录可读写地挂进容器),容器里的 root 就能直接改宿主机文件;
  • 给了过大的 capability 或者干脆开了特权模式,隔离基本等于不存在。

把容器和虚拟机的差别摆在一起看,会更直观:

虚拟机容器
内核每台虚拟机一套独立内核全部容器共享宿主机内核
隔离层hypervisor(硬件虚拟化)namespace(视野隔离)+ cgroup(资源限制)
默认身份虚拟机内的 root 与宿主机无关容器内 root 默认就是宿主机的 uid 0
逃逸难度要打穿 hypervisor 或虚拟机内核内核、运行时任一环节出洞都可能直达宿主机

容器的两根支柱里,namespace 负责「看不见」——看不见其他容器的进程、网络、挂载点;cgroup 负责「用不多」——CPU、内存、进程数都有配额。但请注意,这两根支柱管的都是「正常情况下」的行为,它们不是为对抗「容器内已经拿到 root 的攻击者」设计的。攻击者手里有了容器内 root,他要做的事情就是在内核的系统调用接口上找缝、在你配错的挂载和权限里找门。宿主机内核暴露给每个容器的系统调用有数百个,这就是容器逃逸新闻隔一段时间就出现一次的根本原因。

所以容器安全的核心思路不是「相信隔离」,而是最小权限:能不给 root 就不给 root,能不给的能力就不给,能不写的目录就不让写,能不占的资源就限死。下面逐项来。这几项措施还有一个共同点:它们防的不只是外部攻击者,也防应用自己——一个有 bug 的程序以 root 身份在可写文件系统里跑出破坏,损失和被攻击没有区别。

一、非 root 运行:把默认 root 换掉#

这是收益最大、成本最低的一项加固。分两种情况:镜像本身支持用普通用户跑的,你只需要在 Compose 里指定;镜像默认必须 root 启动的,就要在 Dockerfile 里自己建用户。

Compose 里直接指定 user#

services:
app:
image: myapp:latest
user: "1000:1000"

user 的格式是 uid:gid。这里的 1000 是宿主机上你普通用户的编号,先在宿主机上查清楚再写:

Terminal window
id
# 输出示例:uid=1000(anex) gid=1000(anex) groups=1000(anex),...

写成和宿主机用户相同的 uid/gid,还有个附带好处:容器往数据卷里写的文件,属主正好就是你本人,宿主机上管理起来不会出现一堆 root 属主的文件。

Dockerfile 里建用户#

自己打的镜像,在 Dockerfile 里建一个专用用户并切换过去:

FROM node:22-alpine
WORKDIR /app
COPY . .
# 建一个无登录权限的普通用户
RUN addgroup -S app && adduser -S app -G app \
&& chown -R app:app /app
USER app
CMD ["node", "server.js"]

注意 USER 要写在需要 root 权限的操作(装依赖、改属主)之后。上面用的是 Alpine 的 addgroup/adduser 写法;如果是 Debian 系基础镜像,对应命令是 groupadd / useradd:

RUN groupadd -r app && useradd -r -g app -s /usr/sbin/nologin app

很多官方镜像(PostgreSQL、Redis、Nginx 等)内部已经定义了普通用户,有的还提供入口脚本在启动时自动降权,这类镜像保持默认即可,不用强行再套一层 user。

还有一种常见模式值得了解:入口脚本先以 root 身份做准备工作(建目录、改属主、生成配置),再降权到普通用户运行主进程,工具通常是镜像自带的 gosu 或 su-exec。官方 PostgreSQL 镜像就是这么干的:初始化数据库必须先碰一堆 root 才能改的文件,初始化完立刻降权。这种镜像如果你强行在 Compose 里写 user: "1000:1000",入口脚本连准备工作都做不了,反而起不来。判断方法很简单:先看镜像文档有没有说支持指定用户;没把握时先不加 user 跑一遍,再用下面这条命令确认容器里主进程实际是谁在跑:

Terminal window
docker exec myapp ps aux
# 或只看 1 号进程的身份
docker exec myapp id

输出里主进程是普通用户,就说明镜像自己处理好了降权;全是 root,再考虑用 user 或换镜像。

卷权限问题:加了 user 之后最常见的坑#

容器改成 uid 1000 运行后,原先由 root 容器写过的数据卷目录,属主还是 root,容器一启动就报 Permission denied。解决办法是把卷目录的属主改成对应用户,在宿主机上执行:

Terminal window
# 数据卷是 bind 挂载的本地目录时
sudo chown -R 1000:1000 /opt/myapp/data
# 数据卷是命名卷时,先查它在宿主机上的实际路径
docker volume inspect myapp_data | grep Mountpoint
sudo chown -R 1000:1000 /var/lib/docker/volumes/myapp_data/_data
WARNING

不要图省事用 chmod -R 777 解决问题。那等于把刚扎好的笼子又拆了:任何用户、任何进程都能改你的数据。改属主(chown)才是正解。

二、Capabilities:把 root 的超能力拆开卖#

Linux 早就把 root 的权限拆成了四十多个独立的 capability(能力),比如绑定低位端口、改系统时间、加载内核模块,各是一项。Docker 默认给每个容器发了其中 14 项,包括 CHOWN、DAC_OVERRIDE(绕过文件权限检查)、SETUID、SETGID、NET_RAW、NET_BIND_SERVICE 等——对绝大多数应用来说,这份默认清单还是给多了:一个只提供 HTTP 接口的服务,既不需要绕过文件权限,也不需要发原始网络包。这些多余的能力平时躺着不动,可一旦进程被攻破,它们就全是攻击者手里的工具:DAC_OVERRIDE 让他能读容器里本不该读的密钥文件,NET_RAW 让他能伪造网络包横向探测内网。正确做法是先全部收回,再按需逐个加:

services:
app:
image: myapp:latest
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE

常用 capability 对照:

Capability作用什么时候需要加
NET_BIND_SERVICE绑定 1024 以下端口容器里直接监听 80/443(如 Nginx 不降权时)
CHOWN修改文件属主入口脚本启动时要 chown 数据目录
SETUID / SETGID切换用户身份入口脚本先用 root 做准备、再降权跑主进程(如官方 PostgreSQL 镜像)
NET_RAW发原始包(ping 用的 ICMP)容器里需要 ping 探活
SYS_TIME修改系统时间极少需要,容器不该改宿主机时间
NET_ADMIN改路由、防火墙规则VPN 类容器才需要,属于高危能力
SYS_ADMIN挂载文件系统等一大票操作接近特权模式,能不用就不用

一个判断原则:cap_add 之后容器起不来、日志报权限错误,再对照着补;一上来就把清单拉满,等于没做。

为什么永远别用 —privileged#

Compose 里它长这样:

services:
app:
image: myapp:latest
privileged: true

privileged: true(命令行是 --privileged)意味着:给容器全部 capability、放行访问宿主机的全部设备、关掉 seccomp 和 AppArmor 的限制。说白了,它不是「权限大一点」,而是把 namespace 之外的隔离基本全部放弃,容器里的 root 约等于宿主机 root,只剩一层薄薄的视野隔离。网上很多教程为了解决某个权限报错,直接让加 privileged,这是典型的用拆墙来解决开门问题。真遇到权限问题,先去查到底缺哪个 capability、哪个设备,用 cap_add 和 devices 精确补,不要碰这个开关。个别场景(比如在容器里再跑 Docker 的 DinD、某些需要操作硬件的工具)确实离不开它,那也要清楚自己在放弃什么,并且只给那一个容器。

三、只读文件系统:容器只能写该写的地方#

容器被攻破后,攻击者通常第一件事是往磁盘里塞东西:改二进制、放后门、写挖矿程序。如果把容器根文件系统设成只读,这条路直接堵死:

services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /var/run
volumes:
- app_data:/app/data

要点:

  • read_only: true 让镜像层只读,程序改不了自己的代码和配置;
  • 应用总要写临时文件,用 tmpfs 给 /tmp 这类目录开一块内存盘,容器删了就没了,正好符合临时文件的定位;
  • 真正需要持久化的数据,走命名卷或 bind 挂载单独放行,如上面的 /app/data;
  • 有的应用还要写缓存目录、日志目录,启动报错时看日志里缺哪个目录,逐个用 tmpfs 或卷补上,不要因为麻烦就把 read_only 撤掉。

只读文件系统还有个附带好处:倒逼你把「数据」和「程序」分清楚,备份、迁移时该带走哪些目录一目了然。

四、no-new-privileges:堵住提权后门#

Linux 允许进程通过执行 setuid 程序获得更高权限(比如普通用户执行 sudo)。容器里如果留着这条路,前面把进程降成普通用户的努力就可能被一个 setuid 二进制绕过去。加上这个开关后,进程无论如何都不能通过 exec 获得比父进程更高的权限:

services:
app:
image: myapp:latest
security_opt:
- no-new-privileges:true

成本几乎为零,没有特殊需求就默认加上。举个具体的画面:不少镜像里带着 passwd、su 这类 setuid 程序,普通用户执行它们时能临时获得 root 身份去改密码文件——这正是 setuid 机制的用途,也是它的风险:历史上多次出现过靠 setuid 程序的漏洞把普通用户提成 root 的案例。no-new-privileges 之后,这类程序在你的容器里会被「去势」:执行时不再赋予额外权限,漏洞的利用链当场断掉,而你的应用几乎不可能真的需要在运行时改密码,功能上毫无损失。注意:如果容器入口脚本本身依赖「先 root 准备、再用 gosu/su-exec 降权」这类流程,no-new-privileges 一般不影响它们(降权是往下走,不是提权);但如果应用在运行时需要调用 setuid 程序提权(很少见),就要斟酌。

五、资源限制:别让一个容器拖垮整台机器#

隔离不只是防攻击,还要防失控:一个容器内存泄漏吃光 RAM、或者 fork 炸弹刷满进程表,整台宿主机上所有服务一起陪葬。资源限额就是给每个容器划好自己的饭碗。

Compose 的两种写法#

写法一:deploy.resources(现行推荐)。 这是 Compose 规范的正式写法。早期它只在 Swarm 模式下生效,但现在的 Docker Compose V2 在普通的非 Swarm 环境下同样会应用其中的 limits:

services:
app:
image: myapp:latest
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 128M

limits 是硬上限,超了就被限制(CPU 被节流、内存超限触发 OOM Kill);reservations 是软性的资源预留,主要影响调度。日常使用把 limits 写好即可。

两个容易混淆的点顺带说清。第一,内存上限和 swap 的关系:只设 memory 不设 swap 上限时,容器能用的 swap 默认可以达到内存上限的同等大小(即总可用量约为上限的两倍)。想彻底封死,可以用老式字段 memswap_limit 把「内存+swap」总量设成和 mem_limit 一样大,等于禁用该容器的 swap。第二,cpus 和 CPU 权重的区别:cpus: "1.0" 是硬顶——无论机器多空闲,最多用满 1 个核;而 cpu_shares(权重,默认 1024)只在多个容器抢 CPU 时决定分配比例,不设上限。本文讲的是「限死」,用 cpus 就对了;权重适合那种「平时随便跑、忙起来按比例分」的场景。

写法二:老式兼容写法。 在 deploy 出现之前,大家用的是服务级字段:

services:
app:
image: myapp:latest
mem_limit: 512m
cpus: 1.0

这套写法在 Compose V2 里仍然有效(会被转换成对应的 cgroup 限制),存量项目不用急着改。但要注意:如果用 docker stack deploy 部署到 Swarm,这类非 deploy 字段会被直接忽略——同一份文件,本地跑有限制、上 Swarm 没限制,这种坑最阴。新写的配置统一用 deploy.resources,一份写法两边都认。

pids_limit:防 fork 炸弹#

内存和 CPU 之外,还有一个经常被忘的维度:进程数。一个失控(或被攻破)的进程无限 fork,能把宿主机的 PID 资源耗尽,让所有服务都起不了新进程。给它封顶:

services:
app:
image: myapp:latest
pids_limit: 200

普通应用几十到一两百个进程绰绰有余,按应用规模给,别留空。

ulimits:文件句柄等单项上限#

ulimits 用来调单个进程的资源上限,最常见的是打开文件数(nofile)。高并发服务(如反向代理、数据库)经常需要把它调大:

services:
app:
image: myapp:latest
ulimits:
nofile:
soft: 65535
hard: 65535
nproc: 4096

soft 是实际生效的上限,hard 是 soft 能往上调的天花板,一般两者写一致即可。

验证限制是否真的生效#

写完别靠感觉,用 docker stats 看实测:

Terminal window
docker stats

输出里 MEM USAGE / LIMIT 一列会直接显示每个容器的内存用量和上限——上限显示的是你设的 512MiB,而不是宿主机总内存,说明限制生效了。也可以查单个容器的 cgroup 限额:

Terminal window
docker inspect app --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'

输出的字节数对得上你的配置(512M = 536870912),就是真的落地了。

六、专项风险一:docker.sock 挂载#

/var/run/docker.sock 是 Docker 守护进程的控制入口。谁能访问这个 socket,谁就能创建任意容器、把宿主机根目录挂进特权容器读出来——挂 docker.sock 进容器,等于把宿主机 root 交给那个容器。容器一旦被攻破,攻击者一条命令就能在宿主机上开一个带完整权限的新容器,前面所有加固全部归零。

但偏偏有些工具离不开它:上一篇 《如何在Linux自动更新Docker容器》 里的 Watchtower 要靠它重建其他容器,日志工具 Dozzle 要靠它读容器列表。这类工具要用,但要按「不得不挂」来对待:

  • 只给真正需要的工具挂,业务容器永远不挂;
  • 别被「只读挂载」骗了:写成 /var/run/docker.sock:/var/run/docker.sock:ro 并不能让它变只读——ro 限制的是文件系统层面的改名删除,而通过 socket 发给守护进程的 API 请求(建容器、删容器)照样畅通无阻。指望靠 :ro 防写,是自欺欺人;
  • 加一层 socket 代理:用 tecnativa/docker-socket-proxy 这类工具夹在中间,只放行白名单内的 API(比如只允许 GET 查列表、禁止 POST 建容器),业务工具连代理、不直连真 socket;
  • 只绑本机、加认证:如果用的是 TCP 方式的 DOCKER_HOST,必须绑 127.0.0.1 并启用 TLS 双向认证,裸奔的 2375 端口在公网上等于公开送出一台宿主机。

最后提一个根子上的备选方案:Rootless Docker。它让 Docker 守护进程本身就以普通用户身份运行,并借助 user namespace 把容器里的 root 映射成宿主机上的一个无权用户——这样即使容器被攻破、即使攻击者摸到了 docker.sock,他在宿主机上也只是一个普通用户的权限,杀伤力小一个数量级。代价是部分功能受限(比如绑低位端口、某些网络模式需要额外配置)。新装机器、又特别在意这条线的,可以从一开始就按 rootless 模式部署;已有环境不必为此推倒重来,先把前面几节的常规加固做扎实,收益已经占了大头。

七、专项风险二:端口暴露面收敛#

docker ps 里 0.0.0.0:8080->80/tcp 这一行,意思是这个端口对所有网卡开放,包括公网网卡。很多数据库、面板就是这么被全网扫描扫到、然后被爆破的。收敛方法很简单:不需要公网直连的服务,只绑回环地址,再由反向代理统一对外:

services:
app:
image: myapp:latest
ports:
- "127.0.0.1:8080:8080"

这样端口只在本机可达,外面访问走 Nginx/Caddy 反向代理,加 HTTPS、加认证都在代理层统一做。养成定期巡检的习惯:

Terminal window
docker ps --format 'table {{.Names}}\t{{.Ports}}'

逐行看 PORTS 列,凡是 0.0.0.0 开头又不该对外的,当场改掉。再配合宿主机上 ss -tlnp 核对实际监听,两边对得上才算干净。

WARNING

一个常见误解:以为在云厂商防火墙或 ufw 里挡住就行。Docker 会自己写 iptables 规则转发端口,绕过 ufw 的默认策略,不少人就是这样「防火墙明明没放行、端口却全网可达」。最可靠的收敛点是绑定地址本身:写死 127.0.0.1,谁都绕不过去。

八、专项风险三:镜像供应链#

容器跑的代码,绝大部分不是你写的,是你从镜像仓库拉的。镜像来源不可信,前面所有加固都是在保护别人的后门。三条纪律:

  1. 只用可信来源:官方镜像、知名组织、或者自己构建的。来路不明的镜像——论坛里随手发的、教程里没头没尾的 latest——不要跑,尤其不要给它任何挂载和特权。
  2. 固定版本,别裸用 latest:nginx:latest 今天和下个月可能是两个东西,出问题都没法复现。写到明确版本号 nginx:1.27.3,更新时人工改版本号,过程可控、可回滚。
  3. 重要服务固定 digest:版本标签也可能被覆盖推送,摘要(digest)是内容哈希,锁死了就绝无二义:
services:
app:
image: nginx:1.27.3@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

digest 可以在 docker pull 后用 docker inspect --format '{{index .RepoDigests 0}}' nginx:1.27.3 查到。配合 自动更新那篇 的做法:定期更新镜像、修补已知漏洞,但更新动作要经过你的确认,而不是无声地换成某个「最新版」。另外定期用 docker scout 或 Trivy 扫一遍在用镜像的已知漏洞,做到心里有数。

还有一条选镜像的经验:基础镜像越小,攻击面越小。一个完整发行版镜像里塞着几百个你永远用不上的软件包,每一个都是潜在的漏洞载体。同样的应用,能选 Alpine 这类精简版就别选完整版;更进一步,Google 的 distroless 镜像连 shell 和包管理器都不带,攻击者就算进了容器,也没有趁手的工具可用。自己构建镜像时同理:多阶段构建把编译工具留在构建阶段,最终镜像只带运行必需的文件,这既是体积优化,更是实打实的安全优化。

九、加固版 Compose 模板(汇总)#

把上面各项拼到一起,就是一份可以直接抄走改名的模板:

services:
app:
image: myapp:1.4.2 # 固定版本,不用 latest
container_name: app
restart: unless-stopped
user: "1000:1000" # 非 root 运行
read_only: true # 根文件系统只读
tmpfs:
- /tmp
volumes:
- app_data:/app/data # 只有数据目录可写
ports:
- "127.0.0.1:8080:8080" # 只绑本机,对外走反向代理
cap_drop:
- ALL # 能力先全收
cap_add:
- NET_BIND_SERVICE # 再按需加
security_opt:
- no-new-privileges:true # 禁止提权
pids_limit: 200 # 防 fork 炸弹
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
ulimits:
nofile:
soft: 65535
hard: 65535
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
volumes:
app_data:

不是每个应用都要全套照搬:起不来就看日志,缺目录补 tmpfs、缺权限补 capability,一项项对着报错放宽,但每放宽一项都要知道自己在放什么。

给存量服务上这份模板,建议一项一项来、每上一项重启验证一次,而不是十几行同时砸下去——真出了问题,git 记录里能清楚看到是哪一项引起的,回滚也只需要撤那一项。顺序上按三批推进:

顺序措施说明
先上no-new-privileges、资源限制、端口绑定无副作用,这三项基本不会让正常应用起不来
再上cap_drop、read_only最容易触发权限报错,放在工作日手边有时间排障的时候做
最后user 非 root 化影响面最大(涉及数据卷属主),单独处理

十、排障:加了限制之后容器起不来#

一启动就退出、日志报 Permission denied。 大概率是 user 或 read_only 引起的:先对照日志里报的路径,目录问题用 chown 改属主或补 tmpfs/卷;不要第一反应就把限制撤了。

容器莫名其妙消失/重启,日志里没报错。 怀疑被 OOM Kill 了——内存超限时内核直接杀进程,应用自己来不及写日志。查容器的死因:

Terminal window
docker inspect app --format '{{.State.OOMKilled}} {{.State.ExitCode}}'

OOMKilled 为 true 就实锤了(退出码通常是 137)。宿主机层面也能在内核日志里找到记录:

Terminal window
dmesg | grep -i "out of memory"
# 或
journalctl -k | grep -i oom

确认是正常业务增长就适当调大 memory 上限;如果是内存泄漏,调上限只是续命,该修还得修。

CPU 限额设了但服务变卡。 docker stats 里看 %CPU 是否长期顶在限额附近(1.0 核的限额对应 100%)。顶格说明限额给小了或者应用有死循环,先分清再调。

cap 砍完后端口绑不上。 非 root 用户绑 1024 以下端口需要 NET_BIND_SERVICE,要么把这个 capability 加回来,要么让应用监听高位端口(如 8080)、由外层映射解决。

日志里刷 Operation not permitted。 这是 capability 被收走后的典型报错:进程想做一件它以前能做、现在没能力做的事。对照第二节的表逐项排查——改文件属主失败是缺 CHOWN,入口脚本降权失败是缺 SETUID/SETGID,ping 不通是缺 NET_RAW。一次只加一项验证,确认是哪一项再固定下来,别一口气把表全抄上。

退出码 137 是什么意思。 容器的退出码 137 等于 128+9,即进程是被 SIGKILL 强杀的。除了上面说的 OOM Kill,宿主机内存整体不足时内核也会挑容器下手,排查路径相同:先看 OOMKilled 标记,再翻内核日志确认凶手。与之相邻的 143(128+15)则是被 SIGTERM 正常终止,一般是 docker stop 的结果,不用慌。

小结#

  • 认知先行:容器共享内核、默认 root,隔离强度远不如虚拟机,加固的目标是最小权限;
  • 四件套默认全上:user 非 root、cap_drop: ALL 按需加、read_only + tmpfs、no-new-privileges;
  • 资源三限:deploy.resources 限 CPU/内存、pids_limit 限进程数、ulimits 调句柄,写完用 docker stats 验证;
  • 三个真实风险点:docker.sock 非必要不挂、端口只绑 127.0.0.1 再走反代、镜像固定版本或 digest 只用可信来源;
  • privileged: true 永远不要出现在你的配置里。

最后给个推进路线,不用一次到位:今天先把所有服务的端口绑定检查一遍、把裸奔的 0.0.0.0 收回 127.0.0.1,这是十分钟的事;这周内给新部署的服务全部套上第九节的模板;存量服务按「先无副作用项、后权限项」的顺序逐台改造。安全加固最怕的不是技术难,而是总觉得「等有空再弄」——而攻击者和失控的进程,从不挑你有空的日子。

笼子扎紧的代价,无非是部署时多写几行配置、多排一两次权限报错;换来的,是某个容器真出事的那天,损失能停在笼子里面。这笔账,划算。

Docker 安全与资源限制实战
https://blog.anexns.com/posts/docker-security-resources/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0