我记得很清楚,第一次被 Docker 安全问题狠狠上一课,是在一次内部巡检里:一个后端服务容器跑了快一年,里面进程是 root,宿主机/var/run/docker.sock被直接挂载进了容器,数据库密码写死在环境变量里。当时来排查的同事看了一眼就问了一句:"你们这容器还活着,纯属运气好。"
这不是段子,是我见过不少团队的真实状态。
很多人对 Docker 的认知停留在"装个环境、跑个服务、端口映射一下就完事",对 Docker 安全的了解还停留在"反正容器隔离,跟虚拟机差不多"的错觉上。结果就是镜像从网上随便拉、容器起来就是 root、端口 0.0.0.0 全暴露、密钥硬编码在镜像里、docker.sock 当普通文件随便挂。这些问题单看一个好像没啥,组合在一起,基本等于把服务器大门钥匙挂在了门口。
这篇东西,我打算从自己踩过的坑和平时做加固的经验出发,系统地把 Docker 安全这件事拆开讲清楚。不管你是刚用 Docker 部署 MySQL、Redis 的小白,还是已经在用 Docker Compose 编排服务的进阶用户,又或者是刚接手一套 Docker 环境准备做安全检查的运维,都应该能从里面找到用得上的东西。
1. 先认清 Docker 的安全边界:共享内核既是优势也是风险
1.1 容器和虚拟机在隔离性上的本质差异
我先说一个最容易被误解的事:容器不是虚拟机。
虚拟机里跑的是完整操作系统,有自己的内核,虚拟化层把硬件资源切开了,Guest 系统里的 root 权限再大,默认也影响不到宿主机里另一个虚拟机。而 Docker 容器不一样,所有容器和宿主机共享同一个 Linux 内核,靠 Namespace 做资源隔离,靠 Cgroups 做资源限制,靠 Capabilities、Seccomp、AppArmor 这些机制来限制容器内进程能做的事。
这个架构决定了安全模型的核心逻辑:Docker 的安全不是把容器关进了一个全封闭的房间,而是给容器里的进程戴上了各种限制的"手铐脚镣",让它在房间内活动,但出不去。
我做个生活化类比:虚拟机像是两层楼之间浇了混凝土楼板,楼下着火了楼上一般没事;容器像是同一层楼用木板隔出来的隔间,一个隔间出事,火势很容易顺着公共走廊烧到旁边。木板隔断也不是完全没用,但你不能指望它像混凝土一样防火。
理解了这层关系,你再看网上那些"容器逃逸"漏洞就会明白,为什么安全圈对容器逃逸这么紧张——因为一旦逃逸成功,攻击者拿到的就是宿主机内核层面的权限,所有共享这个内核的容器全部失守。
1.2 Docker 默认安全机制真正挡住了什么
Docker 其实给了不少默认保护,只是很多人不知道,更不知道怎么验证。
举几个实际存在的默认行为:
- 容器默认通过 Seccomp 策略禁用了 44 个以上的危险系统调用,比如
mount、ptrace、reboot、keyctl这类高风险操作,在没加--cap-add的情况下,容器里直接调用这些系统调用会被内核拒绝。 - 默认的 Capabilities 只给了白名单里的一小部分,像
CAP_SYS_ADMIN、CAP_NET_ADMIN、CAP_SYS_MODULE这些高危险能力,默认是剔除的。 - 容器内的 root 和宿主机 root 不是一回事(前提是没开 privileged 并且没挂载危险设备)。
这些机制能挡住什么?能挡住一部分粗粒度攻击。比如一个 Web 应用被注入命令执行,攻击者在容器里想挂载宿主机磁盘、加载内核模块、修改宿主机网络配置,这些默认就会被拦下来。
但要注意,默认保护挡不住的是:容器内进程滥用已赋予的能力、镜像里本来就带的后门、边界服务直接把弱口令暴露到公网、密钥直接从环境变量里泄露。
所以我给团队培训的时候经常说一句话:Docker 的默认安全配置相当于给你发了件雨衣,不是让你穿雨衣去游泳,而是让你在下小雨的时候不湿身。你要是把它当潜水服用,出事了别怪雨衣质量差。
2. 镜像安全:供应链上最容易栽跟头的环节
2.1 拉镜像前先想三件事:来源、标签、完整性
镜像安全是整个 Docker 安全里最前置的一道防线,但也是很多人最容易忽略的。
我在实际排查中见过太多问题镜像了:不校验来源就拉取、用latest标签做生产部署、镜像层里残留了历史命令生成的临时文件、镜像构建时把私钥复制进去后来删了但层还在、基础镜像几个月不更新导致一堆已知漏洞。
先说拉镜像时最容易踩的坑。
第一,来源不明就敢用。很多人图方便,直接拿docker pull从公共仓库拉,也不看镜像作者是谁、下载量多少、最近更新时间是什么时候、仓库描述是否含糊。公共仓库里有人投毒的情况是真实存在的,名字起得像官方镜像的仿冒镜像、包着正常功能实际藏了挖矿程序的镜像,这些年已经曝光过好几轮。我现在的习惯是:尽量从官方仓库、发行方认证渠道拉取,私有环境只拉自己构建或者信任镜像源构建产物,并且构建之后推送进私有仓库。
第二,latest标签是动态的。你以为今天部署的是昨天测试过的那个镜像,实际上latest可能在你睡觉的时候已经被推了新版本,你拉下来的是一个完全没验证过的新镜像。生产环境我最不建议用latest,正确做法是固定到不可变的镜像 ID 或者sha256digest。比如:
docker pull myimage@sha256:a1b2c3d4e5f67890...用 fixed digest 保证每次部署的镜像内容完全一致,内容寻址、无法篡改,这才是可以复盘、可以回溯的做法。
第三,完整性校验。在公网拉取镜像时,证书和签名校验依赖仓库平台,Docker Content Trust(DCT)开启后会强制要求镜像签名验证。很多企业内部并没有开这套机制。至少,拉取完毕后用docker inspect或者镜像指纹确认关键元数据,不要让敏感流程建立在"默认安全"假设上。
2.2 构建阶段就压缩攻击面:多阶段构建与基础镜像选型
镜像体积和安全其实是一回事:镜像里多一个没用到的程序,就给攻击者多递一把刀。
我见过一个做 Java 后端的团队,用官方 JDK 镜像直接打包,里面装了一堆编译工具、包管理器、调试器,还带一个 bash。结果明明是跑 Java 应用的容器,攻击面却包含了整个通用 Linux 基础环境。
基础镜像的选型思路,我一般建议按这个优先级考虑:
- 官方基础镜像,优先选择
alpine、slim、distroless这类精简镜像。 - 需要编译环境的项目,用多阶段构建把编译阶段和运行阶段拆开,运行阶段只拷贝产物和必要的运行时。
- 不要在运行镜像里装包管理器、调试工具、shell(除非业务必须)。
多阶段构建的收益是实打实的。比如一个 Go 服务,构建阶段用golang:1.xx,运行阶段用distroless,最后镜像只有一个可执行文件和一个非 root 用户,体积从 800MB 降到 15MB,攻击面从几百个工具缩到一个二进制。
这里给一个典型的多阶段构建示例:
# 构建阶段 FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o app . # 运行阶段 FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app COPY --from=builder /app/app . USER nonroot ENTRYPOINT ["./app"]注意最后那个USER nonroot,这是我想强调的:不要在 Dockerfile 里省略USER指令,因为默认是 root。很多人写的 Dockerfile 根本没有 USER,最后两行就是ENTRYPOINT完事,这种镜像跑起来容器内进程就是 root。
2.3 镜像层里的历史残留:删除文件不等于删除痕迹
还有一个特别容易忽略的镜像安全细节:Docker 镜像是分层存储的,每一层都是一个可回溯的层,你在后来的层里删掉某个敏感文件,它依然存在于前一层镜像文件系统里。
什么意思?假设你构建镜像时把私钥复制进来了,后来意识到不对,又用RUN rm删掉了。看起来镜像里没有了,但如果别人用docker history或者直接导出镜像层级,还是能从前面的镜像层里把私钥翻出来。
我建议的规避方案是:
- 敏感文件永远不要通过
COPY或ADD直接打进去。 - 构建时通过
--secret传递临时凭据,文件不会永久进入镜像层。 - 构建完一定要用漏洞扫描和层分析工具看一遍镜像层里有没有异常内容。
现在不少 CI/CD 平台里都内置了镜像扫描能力,但我观察下来,很多团队是扫描结果出来一堆高危漏洞也不处理,流于形式。扫描出来的高危项如果确认是可触达的,就得修;不可触达的,也要写清楚评估结论,不然扫描就是在做样子。
3. 容器运行时的加固动作:把权限关进笼子里
3.1 别让容器里的 root 真的变成宿主机 root
我先把这个事说透:容器内的 root 用户,在默认配置下映射的是宿主机的一个普通用户权限(当然它有了更多的能力,但仍然无法操作宿主机内核和部分不属于它的命名空间)。这个限制是 Namespace 和 Capabilities 机制一起合成的结果。但有两个常见操作会轻松打破这个限制,让容器里的 root 直接升级成宿主机 root。
第一个是--privileged参数。它等于把所有 Capabilities 全部交给容器,并且允许访问宿主机所有设备节点,Docker 的默认 Seccomp 策略也会被绕过。开了 privileged 的容器,等于在宿主机上开了一个完全不受限制的内核级后门。
第二个是挂载 docker.sock。只要容器里有 docker.sock 的读写权限,容器内的进程就可以通过 Docker API 创建任意容器、挂载宿主机根目录、覆盖宿主机的 cron 配置。原理上就等于拿到了宿主机 root shell。
我在排查加固项的时候,看到这两个问题同时出现的话,基本直接判定为严重漏洞,不用商量。我见过一个实际事故:攻击者通过某个 Web 应用漏洞进到容器里,发现容器挂了/var/run/docker.sock,随手执行了一条命令:
docker run -v /:/host -it ubuntu chroot /host宿主机直接被拿下了,整个过程不到十秒。
所以我的建议非常明确:
- 生产环境禁止
--privileged参数,特殊情况必须逐条说明理由。 - 禁止容器挂载
/var/run/docker.sock,凡是这么做的,一律当事故处理。 - 如果确实需要在容器内管理 Docker(比如 CI Runner),也尽量用
docker:cli镜像并且只挂载需要的 socket,同时做好该容器的其他加固和监控,因为它的安全级别直接等同于宿主机。
3.2 Capabilities 裁剪:删掉全部,再加回需要的那几个
Capabilities 是把 root 的大权限拆分成几十个小权限的一个机制。默认容器虽然已经裁了一部分,但默认白名单里仍然包含很多应用根本不需要的能力,比如CAP_CHOWN、CAP_NET_BIND_SERVICE、CAP_SETUID、CAP_SETGID、CAP_DAC_OVERRIDE、CAP_FOWNER等。
业内比较推荐的做法是:先用--cap-drop=ALL把容器能力全部删除,然后根据应用实际需求再加回必要的能力。
比如一个 HTTP 服务,监听 80 端口,需要绑定低端口就需要NET_BIND_SERVICE,典型配置就是:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE ...那怎么确定一个容器需要哪些能力呢?我通常的做法是:先全部 drop 跑起来,看报错,业务起不来了再加,加一个验证一个。虽然有点笨,但结果最可控。你也可以用docker inspect看容器的 CapAdd/CapDrop 配置,定期审计一遍。
顺带整理一张常用能力对照表,方便大家快速决策:
| 能力 | 作用 | 出现的典型场景 |
|---|---|---|
| NET_BIND_SERVICE | 绑定 1024 以下低端口 | Web 服务监听 80/443 |
| SETUID / SETGID | 设置进程用户 ID | 需要降权/切换用户的程序 |
| CHOWN | 修改文件属主 | 应用运行时需要 chown 文件 |
| DAC_OVERRIDE | 绕过文件读写权限检查 | 多数情况下不需要,谨慎添加 |
| SYS_ADMIN | 执行大量系统管理操作(挂载、命名空间等) | 几乎不需要,高危 |
| NET_ADMIN | 修改网络配置、iptables | 几乎不需要,高危 |
| SYS_MODULE | 加载内核模块 | 绝对不需要,高危 |
记住一条:能力给得越少,容器活得越久。那些为了一时省事加了--privileged或者--cap-add=ALL的,都是在给未来的事故买彩票。
3.3 只读文件系统和资源限额,这两个配置性价比超高
容器运行时加固里,有两项配置我认为性价比极高,但普及度很低。
第一项是只读根文件系统。
用--read-only启动容器,容器内没有修改系统路径的能力,除了临时目录。攻击者就算通过 Web 漏洞拿到容器内执行权限,也没法往系统目录写文件,不能放 webshell、不能改 crontab、不能写启动脚本,攻击链条直接折断一截。
配置方式:
docker run --read-only --tmpfs /tmp -d nginx很多应用正常跑并不需要写文件,写文件和缓存主要集中在/tmp,用--tmpfs放在内存里就够了;如果有持久化数据,单独挂数据卷到明确目录。
对于 Compose 编排的场景,在服务配置里加:
services: app: read_only: true tmpfs: - /tmp第二项是资源限额。Cgroups 的作用就是限制容器使用的 CPU、内存、并发进程数和磁盘 IO,这是防止一台宿主机里的某个容器发生内存泄漏或者被恶意占用资源后拖垮所有容器的关键手段。
之前有个朋友私信问我:为什么宿主机突然卡死,Docker 里某个容器把 32G 内存全吃光了,其他服务全部不可用。我看了一下他的配置,没有内存限额,也没有重启策略。这就是典型的"没有拉绳的容器"。
建议的最小配置:
docker run -m 512m --cpus=0.5 --pids-limit=100 ...-m 512m:内存上限 512MB--cpus=0.5:最多使用 0.5 个 CPU--pids-limit=100:限制容器进程数量,防止 fork 炸弹
Compose 里对应:
services: app: mem_limit: 512m cpus: 0.5 pids_limit: 100这里我提一个细节:很多内存类攻击(包括简单的 OOM 型 DoS)靠的就是不设资源上限,容器可以把整个宿主机拖垮,进而影响所有共享内核的服务。--pids-limit这个参数很多人不认识,但防 fork 炸弹非常管用,建议默认加上。
3.4 Seccomp 和 AppArmor:遇到有特殊系统调用需求的应用怎么处理
默认情况下 Docker 会给容器套一层 Seccomp 安全策略,挡住一部分危险系统调用。但注意,在没开启用户命名空间(userns-remap)的情况下,默认保护其实有限,因为还是有很多系统调用是放行的。
我在容器加固的实践中,通常会做一道自定义 Seccomp 策略。做法是:先用默认策略跑业务,把业务需要的系统调用全部放行,然后明确禁止和业务无关的高危系统调用,比如mount、ptrace、unshare、keyctl、reboot、swapon等。
一个简化的 Seccomp 配置片段长这样:
{ "defaultAction": "SCMP_ACT_ALLOW", "architectures": ["SCMP_ARCH_X86_64"], "syscalls": [ { "names": ["mount", "ptrace", "unshare", "keyctl", "reboot", "swapon", "swapoff"], "action": "SCMP_ACT_ERRNO" } ] }这份策略的定位是"白名单+黑名单"组合:默认放行,但对明确的高危调用直接返回错误。这样做的好处是业务兼容性高,同时挡住了绝大多数已知的系统调用层面逃逸技巧。
AppArmor 在 Ubuntu/Debian 系系统上也很常见,Docker 默认配置就带上了一个基础配置文件。虽然它没有 Seccomp 那么让人头疼,但它和 Seccomp 是两条独立的防御线。建议在有条件的情况下两者叠加使用,我给客户做加固的时候,策略上永远要求"纵深防御",而不是只靠一个点。
4. 网络、数据卷和密钥:风险常常藏在端口和数据里
4.1 端口映射暴露面:0.0.0.0 是所有事故的开始
Docker 最常用的命令之一就是-p 3306:3306、-p 6379:6379这种端口映射。很多人根本意识不到,这样写意味着容器端口绑在了宿主机所有网络接口上,公网 IP 一旦通到这个端口,这个服务就等于直接暴露在互联网。
我在安全巡检中碰到过几个真实案例:
- 有个同学的 Redis 容器这么映射了出来,没设密码或者用的是默认配置,结果被人扫到端口,直接拿到了 Redis 权限,然后通过 Redis 写公钥进服务器。这就是经典的 Redis 未授权访问攻击。
- 有人启动 MySQL 容器时图省事,用
3306:3306暴露到公网,安全组还不限制来源 IP,数据库明文连接密码被爆破。 - 还有人是 Docker Compose 里默认写了
"0.0.0.0:8080:8080",以为没事,结果被扫描器盯上,后台管理界面天天被撞库。
这里我给出的基本规则是:
- 端口映射优先绑定回环地址:
-p 127.0.0.1:3306:3306,只有本机进程能访问,需要外部访问的服务再单独开白名单。 - 如果需要对外提供服务,也要在宿主机安全组/防火墙级别限制来源 IP,不要把端口裸奔到公网。
- Compose 里尽量省略
ports或用expose做内部互通,只有必要对外服务才定义端口映射。 - 数据库内部组件(MySQL、Redis、MongoDB 等)除非业务必须,否则禁止映射到宿主机 0.0.0.0。服务之间的通信优先用 Docker 网络内部 DNS 解析。
网络模式选择上也值得说两句。Docker 常用的 bridge、host、none 三种模式里,--network=host表示容器直接共享宿主机的网络栈,容器里绑定的端口就等于宿主机端口,隔离性最差。服务之间通信建议用自定义 bridge 网络,它自带内部 DNS,能按服务名互相访问,同时默认隔离了外部扫描。
4.2 数据卷与 bind mount:挂载粒度决定风险边界
数据卷本身不是漏洞,但挂载方式会制造漏洞。
常见错误是把宿主机的关键目录直接挂进容器:
docker run -v /:/mnt ubuntu docker run -v /etc:/host_etc ubuntu docker run -v /var/run/docker.sock:/var/run/docker.sock ...一个普通容器如果拿到了这些挂载点,读/etc/shadow、覆盖/etc/crontab、操作/var/run/docker.sock,随便一个都是灾难级后果。
我给团队的挂载规范是:
- 容器只挂载明确的数据空目录或应用数据目录,比如
/data/app1对应容器的/var/data。 - 禁止挂载宿主机系统目录。
- 尽量使用命名的 Docker volume 而不是 bind mount 宿主路径,因为 volume 由 Docker 管理,权限边界更清晰。
- 挂载目录的权限要限制,不能把
/data直接可读写挂进去,能只读就只读,比如配置文件用:ro挂载。
命令示例:
docker run -v app_data:/var/lib/app -v /path/config.yaml:/etc/app/config.yaml:ro ...另外特别提醒一个 Windows 场景:用 Docker Desktop 时,你通过文件共享把 Windows 的某个盘符共享给了 WSL2/VM,容器里 bind mount 就能读写整个共享盘。我在 Windows 上做配置时,建议只共享确实需要的目录,别为了省事把整个 C 盘共享出去。这和 Linux 上挂载/的原理是同一回事,只是很多人没意识到 Docker Desktop 的"File sharing"设置就等于在配置挂载边界。
4.3 密钥管理的三种错误存放方式
我复盘过不少由密钥泄露引发的 Docker 安全事故,发现密钥存放犯的错误高度一致。
第一种:硬编码进环境变量。
environment: DB_PASSWORD: "admin123"docker inspect一下,明文密码直接展示出来。任何能进宿主机或者拿到镜像导出包的人,都能看到。
第二种:写进镜像层。
刚才已经说过,就算启动时删掉,前面的镜像层里躺着。我见过一份 Dockerfile,ENV MYSQL_ROOT_PASSWORD=xxx写在中间层,后来删了,但docker history一眼就能看到。
第三种:放到项目仓库里。
.env文件直接提交进 Git,公共仓库分分钟被扫描器揪出来。这些账号密码会进入各种泄露数据库,再被拿去撞库、暴力破解。
正确做法按优先级排列:
- 生产环境使用 Docker Swarm secrets 或者 Kubernetes secrets 管理密钥,配合权限控制。
- 使用外部密钥管理系统(Vault 等),运行时代入。
- Compose 场景下,用
docker secret文件挂载的方式,避免明文出现在环境变量里。
一个实际可用的 Compose secrets 示例:
services: app: image: myapp secrets: - db_password environment: DB_PASSWORD_FILE: /run/secrets/db_password secrets: db_password: file: ./secrets/db_password.txt这套做法的优势是:密钥以文件形式挂载到/run/secrets下,进程在启动时再读取,docker inspect看不到,镜像层里也没有痕迹。即便有人拿到运行中的容器镜像包,也无法直接拿到密钥原文。
5. Docker 守护进程与宿主机基线:一切都在 docker.sock 上博弈
5.1 docker.sock 为什么是宿主机提权入口
/var/run/docker.sock是 Docker 守护进程监听的 Unix Socket 文件。谁拥有读写这个 socket 的权限,谁就能调用 Docker API。
调用 Docker API 能干什么?可以创建容器、删除容器、查看所有镜像日志、读写所有挂载卷。换句话说,有了 socket 权限,就等于有了宿主机 root 的钥匙,因为你可以通过一个容器挂载宿主机文件系统来完全控制宿主机。
我见过一些 CI/CD 工具为了构建镜像,把/var/run/docker.sock直接挂载给了构建容器。这种做法的风险在于,构建工具本身如果不安全、执行了不可信代码,人家马上就能通过 socket 逃逸到宿主机。
我之前处理过一个告警:某个开发环境的使用者,因为容器里跑了一个命令,直接把 node_modules 里某个脚本执行了。那个脚本检测到 docker.sock 存在,然后往里写了一个反弹 shell,宿主机就这样被拿下了。这种利用在真实攻击里非常常见。
5.2 Docker API 暴露在 TCP 端口上,是另一个常见漏洞
默认情况下 Docker daemon 只监听本地 Unix socket,安全性相对可控。但如果有人为了远程管理,把 Docker daemon 暴露在了 TCP 端口比如2375上,又没有配置 TLS 认证,这就是一个裸奔的后门。
我在网络上做过测试,搜索开放 2375 端口的主机,能扫到一批直接返回 Docker API 的主机。访问/containers/json,直接能看到所有容器列表,创建新容器、重启宿主机的接口全开着。这种一般是被扫描到之后当成挖矿肉链用,或者干脆作为跳板机。
如果你确实需要远程管理 Docker,务必要用 TLS 双向认证,不能只用某个小端口混淆一下,更不能图省事只开 TCP 不开认证。更安全的做法是走堡垒机/SSH 隧道转发,不要直接暴露 Docker API。
5.3 审计日志与安全基线:平时不记录,出事找不到线索
我发现大多数运维排障场景里,Docker 环境出事以后最难的不是修复,而是复盘——不知道用户在哪个阶段干了什么,看不到容器启动历史的完整记录,日志被删除之后连个影子都没有。
Docker 默认的日志配置是 json-file,会记录容器 stdout/stderr 到宿主机文件,但容器重建后旧日志可能就没有了,而且日志不会默认保留很久。审计层面建议做这几件事:
- Docker daemon 配置全局日志轮转:
--log-driver json-file --log-opt max-size=10m --log-opt max-file=3,避免日志无限增长占满磁盘。 - 开启宿主机内核审计(auditd),记录容器相关的系统调用和文件访问行为,对关键路径
/var/lib/docker、/etc/docker加审计规则。 - 把容器日志统一转发到集中式日志平台,至少做保留策略,别让日志变成"随用随扔"。
- 在 Windows 环境里,除了 Docker 容器日志,还要关注 Windows 事件日志中的安全日志(Event ID 4625 等),我遇到过不少攻击者在 Windows 宿主机上试密码、建账号,这些会在 Windows 安全日志里留下痕迹,但很多人从不看这块。
- 给 Docker 运行环境做定期基线检查,用
docker version、docker info确认版本,定期更新 Docker Engine 和 Docker Desktop,老版本常带公开漏洞,这是最容易被利用的。
我把自己在排查时常用的命令整理成一个检查清单方便你抄:
# 查看危险容器配置 docker ps -a --no-trunc --format 'table {{.Names}}\t{{.Image}}\t{{.Command}}' docker inspect --format '{{.Name}} Privileged={{.HostConfig.Privileged}}' $(docker ps -q) # 查看挂载了 docker.sock 的容器 docker inspect --format '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' $(docker ps -q) | grep docker.sock # 查看容器的能力是否过大 docker inspect --format '{{.Name}} CapAdd={{.HostConfig.CapAdd}}' $(docker ps -q) # 查看容器是不是以 root 运行 docker inspect --format '{{.Name}} User={{.Config.User}}' $(docker ps -q) # 查找映射到公网的端口 docker port $(docker ps -q) | grep '0.0.0.0'这串命令我几乎每次做安全巡检都会跑一遍,输出结果基本能判断出一套环境的安全水位。
6. 风险优先级与兜底措施:先修要命的,再做体系化加固
6.1 分优先级排查,别一股脑全上
Docker 安全要处理的东西太多,如果从零开始做加固,一次全上是很容易被业务团队抵触的,因为会"影响业务起来的速度"。
我自己的经验是先按"严重程度 × 被利用概率"排个优先级,把最要命的问题先修掉:
| 优先级 | 问题类型 | 处理动作 |
|---|---|---|
| P0 | privileged 容器、docker.sock 挂载 | 立即停止,改架构,禁止再犯 |
| P0 | Docker API 裸露在公网 | 立即关闭或加 TLS/防火墙,改走隧道 |
| P1 | 容器以 root 运行 | 改造镜像,加 USER 指令,结合 userns-remap |
| P1 | 数据库/中间件端口映射公网 | 改为回环绑定+安全组白名单 |
| P1 | 镜像里存在已知高风险漏洞 | 修复基础镜像版本,重新构建部署 |
| P2 | 密钥硬编码/明文环境变量 | 切换到 secret 方案,轮换全部账号密码 |
| P2 | 无资源限制、无只读文件系统 | 按业务加 mem/cpu/pids 限制,关键容器加 read-only |
| P3 | 日志审计不完善 | 配置日志轮转和采集,开启 Daemon 审计 |
这个表格不是固定的,你可以按自己环境的实际情况调整,但核心逻辑是:先清理能直接导致宿主机被拿下的路径,再处理容易被利用的弱配置,最后才是审计和观测能力的补齐。
6.2 用户命名空间映射,把容器 root 再降一级
说完优先级,我提一个很多人没听说过但对 Docker 安全很关键的高级配置:User Namespace Remapping(用户命名空间映射)。
开启之后,容器内部看到的 root(UID 0),在宿主机实际对应的是一个普通用户(比如dockremapUID 1000)。这样就算攻击者通过漏洞拿到了容器内的 root 权限,在宿主机文件系统层面也没有任何特权,不能读写 root 能读的文件,最多在它实际拥有的目录里活动。这相当于给容器 root 又戴上一层身份转换的过滤网。
开启方式:修改/etc/docker/daemon.json:
{ "userns-remap": "default" }然后重启 Docker 服务。注意,开启之后原有镜像和容器因为 UID 映射变化,可能要做迁移和权限调整,这个改造建议在测试环境验证充分再上生产。
我不建议一上来就上这层配置,因为它会带来一定的兼容性成本(文件权限、数据卷权限都可能对不上)。但在高安全要求的场景,这是值得做的兜底措施,它能把"容器逃逸"的杀伤力强行压低到普通用户级别。
6.3 Docker Desktop 环境的特殊性
很多人是在 Windows/macOS 上用 Docker Desktop 做开发或者跑内网服务的,这里我多提醒几句,因为最近后台私信里问桌面版安全的也不少。
Docker Desktop 在 Windows 上依赖 WSL2 或 Hyper-V,它在宿主机和容器之间天然隔了一层轻量虚拟化。这套机制会让容器的逃逸面小一些,但不代表可以随便装。我见过的问题集中在几个地方:
- 共享文件目录太大,整个盘符共享给 Docker 使用,容器内恶意进程就等于拿到整个盘的文件访问权限。
- Docker Desktop 的端口映射默认监听所有网卡,有些开发环境在公网服务器上装了 Docker Desktop,又没改防火墙,端口直接对外。
- Windows 安全日志里的登录尝试往往不关联到容器环境,导致攻击者扫到某个映射端口后暴力破解成功,但排查时只看容器日志而忽视了 Windows 安全日志。
我的建议是:Docker Desktop 只用于本机开发调试,不要把它当作生产环境容器引擎;共享目录精确到项目目录;端口映射尽量改成本机地址;在 Windows 事件查看器里把安全日志的登录审核开启,保证以后排查时有迹可循。
6.4 总结我自己坚持的几条硬性规则
最后,把这几年做 Docker 安全加固的心得浓缩成几条我自己坚持的硬性规则,就当是给大家在实操过程中的一份备忘录:
- 永远不要把 docker.sock 挂进任何容器,永远。
- 生产环境禁止 privileged,禁止 cap-add=ALL。
- 镜像不使用 latest 标签,部署必须固定 digest。
- 容器必须设置
USER,禁止默认 root 运行。 - 端口映射默认只绑
127.0.0.1,需要对外再单独开放。 - 所有容器必须加内存、CPU、进程数限制。
- 密钥一律走 secret 文件或外部密钥系统,不写环境变量和镜像层。
- 关键敏感操作要记录日志,Docker 版本及时更新。
这些规则看起来有点啰嗦,但每一条背后都是真实事故换来的。我见过团队因为这些规则保住了业务,也见过团队一条不守最后被攻击者从 Redis 容器一路打通拿到宿主机的事情。规则是用来自律的,不是用来发给别人看的。
Docker 安全本身没有太多高深理论,关键在于你有没有把自己放在攻击者的角度去想:如果我拿到了这个容器里的执行权限,我下一步能做什么?把这些路径一条条堵死,你的 Docker 环境就安全了。希望这篇总结能给正在用 Docker、打算用 Docker,或者正准备做一次安全自查的朋友一些实在的参考。