共计 7715 个字符,预计需要花费 20 分钟才能阅读完成。
文章目录 [显示]
Docker 30 天实战系列 · Day 21
你有没有想过,你跑在服务器上的容器,可能正在以 root 身份裸奔?别笑,这事比你想象的普遍得多。很多人把容器当成 " 天然安全 " 的沙盒,觉得有了隔离就万事大吉。直到某天凌晨三点被运维电话叫醒——容器被黑了,数据全没了,才想起来 " 安全 " 这两个字怎么写。
今天这篇文章,就是要帮你在那通电话到来之前,把门窗都关好。
本文你将学到
- 为什么容器内的 root 用户是个大坑
- 如何用非 root 用户运行容器
- 只读文件系统的妙用
- 用 Trivy 扫描镜像漏洞
- Docker Secret 管理敏感信息
- 网络隔离策略
| 阅读时间 | 实操时间 | 难度等级 |
|---|---|---|
| 15 分钟 | 40 分钟 | 中级 |
一、容器安全的本质:最小权限原则
在聊具体技术之前,先说一个核心思想——最小权限原则。
这就好比你家的钥匙管理。你不会把家门钥匙、保险柜钥匙、车钥匙全挂在一个钥匙扣上,然后挂在门口让所有人随便拿吧?容器安全也是一样的道理:给每个容器只分配它 " 干活 " 所需的最小权限,多一点都不给。
Docker 安全防护体系(多层防御)+--------------------------------------------------+
| 宿主机(Host)|
| |
| +--------------------------------------------+ |
| | Docker Engine | |
| | | |
| | +----------+ +----------+ +----------+ | |
| | | 容器 A | | 容器 B | | 容器 C | | |
| | | | | | | | | |
| | | 非 root | | 只读 FS | | 网络隔离 | | |
| | | 用户 | | + tmpfs | | + Secret | | |
| | +----------+ +----------+ +----------+ | |
| | | |
| | [Trivy 镜像扫描] [Secret 管理] [网络策略] | |
| +--------------------------------------------+ |
| |
| [SELinux/AppArmor] [seccomp] [cgroups] |
+--------------------------------------------------+
接下来我们逐个击破。
二、非 root 用户运行容器
为什么 root 很危险
默认情况下,Docker 容器内的进程以 root 身份运行。虽然 Linux namespace 做了隔离,但如果容器逃逸漏洞被利用,攻击者直接就是宿主机的 root。这就好比银行把金库大门的钥匙藏在门垫下面——隔离层一旦被绕过,后果不堪设想。
实操:创建非 root 用户
创建一个安全的 Dockerfile:
FROM node:20-alpine
# 创建专用用户和组
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 设置工作目录
WORKDIR /app
# 先复制依赖文件(利用缓存)COPY package*.json ./
RUN npm ci --only=production
# 复制应用代码
COPY --chown=appuser:appgroup . .
# 切换到非 root 用户
USER appuser
# 暴露非特权端口
EXPOSE 3000
CMD ["node", "server.js"]
验证效果:
docker build -t secure-app .
docker run --rm secure-app whoami
预期输出:
appuser
再确认一下用户 ID:
docker run --rm secure-app id
预期输出:
uid=100(appuser) gid=101(appgroup) groups=101(appgroup)
完美,不是 root 了。
还有一个进阶技巧:在运行时通过 --cap-drop 剥离不需要的 Linux Capabilities。默认情况下,即使是非 root 用户,容器仍然保留了一些潜在危险的能力。我们可以把它们全部去掉,再按需加回来:
docker run --rm \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
secure-app
--cap-drop ALL 先把所有能力砍干净,--cap-add NET_BIND_SERVICE 只加回 " 绑定网络端口 " 这一个能力。这样即使攻击者获得了容器内的控制权,能做的事情也极其有限,连修改文件所有权、加载内核模块这些操作都做不了。
三、只读文件系统
思路
容器运行时,大部分文件其实不需要被修改。把文件系统设为只读,即使攻击者进来了,也没法往磁盘上写入恶意脚本或篡改配置。这就像把你家的墙壁都刷上防涂鸦涂层——想搞破坏?写不上去。
实操:启用只读文件系统
docker run -d \
--name readonly-app \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
nginx:alpine
这里有个关键点:--read-only 把整个根文件系统设为只读,但应用可能需要写临时文件,所以用 --tmpfs 挂载内存临时目录给它用。noexec 防止在临时目录执行二进制,nosuid 防止提权。
验证一下:
# 尝试在只读区域写文件——失败
docker exec readonly-app sh -c "touch /usr/share/nginx/html/hack.txt"
预期输出:
touch: /usr/share/nginx/html/hack.txt: Read-only file system
# 在 tmpfs 区域写文件——成功
docker exec readonly-app sh -c "touch /tmp/ok.txt && echo'write succeeded'"
预期输出:
write succeeded
攻击者就算拿到了 shell,也只能在 tmpfs 里折腾,而且还不能执行二进制文件。
四、Trivy 镜像漏洞扫描
为什么要扫描
你从 Docker Hub 拉下来的镜像,里面的基础系统包可能已经有几十个已知漏洞了。就像你买了套二手房,不验房就住进去——墙里的水管可能早就锈穿了。
Trivy 是目前最流行的开源镜像扫描工具,由 Aqua Security 维护,扫描速度快、漏洞库更新勤。
安装 Trivy
# 方式一:直接用 Docker 跑(推荐,不污染宿主机)docker pull aquasec/trivy:latest
# 方式二:Linux 安装
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
实操:扫描镜像
# 用 Docker 方式扫描 nginx 镜像
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image nginx:latest
预期输出(示例,实际结果取决于镜像版本):
nginx:latest (debian 12.4)
Total: 85 (UNKNOWN: 0, LOW: 52, MEDIUM: 25, HIGH: 7, CRITICAL: 1)
┌──────────────────┬────────────────┬──────────┬────────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │
├──────────────────┼────────────────┼──────────┼────────────────────┤
│ libssl3 │ CVE-2024-XXXX │ CRITICAL │ 3.0.11-1~deb12u1 │
│ curl │ CVE-2024-YYYY │ HIGH │ 7.88.1-10+deb12u4 │
│ ... │ ... │ ... │ ... │
└──────────────────┴────────────────┴──────────┴────────────────────┘
只看严重漏洞
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity HIGH,CRITICAL nginx:latest
集成到 CI/CD
在构建流水线里加一步扫描,发现 CRITICAL 漏洞直接阻断部署:
# 返回非零退出码 = 流水线失败
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --exit-code 1 --severity CRITICAL your-app:latest
这一步太重要了。宁可晚上线五分钟,也别上线一个带着已知漏洞的镜像。
另外一个好习惯是扫描你自己的 Dockerfile 配置问题,而不仅仅是系统包漏洞:
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest config ./Dockerfile
Trivy 的配置扫描能发现诸如 " 以 root 运行 "、" 使用了不安全的基础镜像 "、" 暴露了不必要的端口 " 等问题。把镜像扫描和配置扫描结合起来,才是完整的安全检查。
五、Docker Secret 管理敏感信息
问题:环境变量不安全
很多人用环境变量传递数据库密码、API Key。但环境变量有个致命问题——它对容器内所有进程可见,通过 docker inspect 也能直接看到明文。这就像把密码写在便利贴上贴在显示器旁边。
Docker Swarm Secret
Docker Swarm 内置了 Secret 管理功能,把敏感信息加密存储,只在需要的容器内以文件形式挂载到 /run/secrets/ 目录。
# 初始化 Swarm(如果还没有)docker swarm init
# 创建 Secret
echo "SuperStr0ng!P@ssw0rd" | docker secret create db_password -
# 查看 Secret 列表
docker secret ls
预期输出:
ID NAME DRIVER CREATED UPDATED
abc123def456... db_password 5 seconds ago 5 seconds ago
在服务中使用 Secret:
docker service create \
--name web-app \
--secret db_password \
nginx:alpine
在容器内,密码会出现在 /run/secrets/db_password 文件中,应用读取这个文件即可。
应用代码适配
const fs = require('fs');
const path = require('path');
function getSecret(name) {const secretPath = path.join('/run/secrets', name);
try {return fs.readFileSync(secretPath, 'utf8').trim();} catch (err) {
// 开发环境回退到环境变量
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret('db_password');
Docker Compose 中使用 Secret
version: "3.8"
services:
web:
image: your-app:latest
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt
注意:secrets/ 目录千万别提交到 Git 仓库,记得加到 .gitignore 里。
六、网络隔离策略
默认网络的问题
Docker 默认的 bridge 网络,所有容器都在同一个网段,互相之间可以随意通信。这就像一栋写字楼里所有办公室都不装门——任何人可以随意进出任何房间。
实操:创建隔离网络
# 创建前端网络
docker network create --driver bridge frontend-net
# 创建后端网络
docker network create --driver bridge --internal backend-net
--internal 参数是关键:后端网络里的容器无法访问外部互联网,只能内部通信。
# Nginx 只连前端网络
docker run -d --name nginx --network frontend-net -p 80:80 nginx:alpine
# 应用服务器连两个网络(前端 + 后端)docker run -d --name app --network frontend-net your-app:latest
docker network connect backend-net app
# 数据库只连后端网络
docker run -d --name db --network backend-net postgres:16-alpine
网络拓扑:
互联网
|
v
[Nginx] --frontend-net-- [App] --backend-net-- [DB]
|
(无法访问外网)
验证隔离效果:
# Nginx 无法直接访问数据库
docker exec nginx ping -c 2 db
预期输出:
ping: bad address 'db'
# App 可以访问数据库
docker exec app ping -c 2 db
预期输出:
PING db (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.089 ms
数据库被严密保护在内部网络中,外部完全不可达。
这里多说一句关于 DNS 解析的事。在 Docker 的自定义网络中,容器名会自动注册为 DNS 记录,所以 app 可以通过 db 这个主机名直接访问数据库。但在不同网络中的容器,DNS 记录是隔离的——这就是为什么 Nginx 容器 ping 不通 db,不仅仅是网络不通,连名字都解析不了。这种 " 连名字都不告诉你 " 的隔离,比单纯的防火墙规则更彻底。
还有一个实用技巧:如果你的应用需要访问外部 API,但你不希望数据库容器有任何对外访问能力,就用 --internal 网络。internal 网络没有默认网关,容器压根就路由不出去。这比在宿主机上配 iptables 规则简单多了。
七、综合实战:安全加固的 Docker Compose
把上面学到的所有技巧组合起来:
version: "3.8"
services:
nginx:
image: nginx:alpine
read_only: true
tmpfs:
- /tmp:size=32m,noexec,nosuid
- /var/cache/nginx:size=64m
- /var/run:size=16m
ports:
- "80:80"
networks:
- frontend
security_opt:
- no-new-privileges:true
app:
build: .
read_only: true
tmpfs:
- /tmp:size=64m,noexec,nosuid
user: "1000:1000"
networks:
- frontend
- backend
secrets:
- db_password
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
db:
image: postgres:16-alpine
networks:
- backend
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
security_opt:
- no-new-privileges:true
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
volumes:
db-data:
secrets:
db_password:
file: ./secrets/db_password.txt
这里面有几个值得注意的细节:
no-new-privileges:防止进程通过 setuid/setgid 提权resources.limits:限制 CPU 和内存,防止容器被利用来挖矿- 数据库使用
POSTGRES_PASSWORD_FILE读取 Secret 文件,而不是明文环境变量
常见问题 Q&A
Q1:非 root 用户运行容器后,应用无法监听 1024 以下的端口怎么办?
在容器内部使用高端口(如 3000、8080),然后通过 Docker 的端口映射 -p 80:3000 把宿主机的 80 端口映射过去。容器内不需要特权端口,端口映射由 Docker Engine(root 权限)来完成。
Q2:Trivy 扫描出一堆 LOW 级别的漏洞,需要全部修复吗?
优先处理 CRITICAL 和 HIGH 级别。LOW 和 MEDIUM 的漏洞如果没有可用补丁,可以先记录下来定期复查。关键是建立 " 扫描 - 修复 - 验证 " 的循环流程,而不是追求零漏洞(那不现实)。在 CI/CD 中建议只阻断 CRITICAL 级别。
Q3:只读文件系统会不会影响应用的日志写入?
会的。解决方案有两种:一是用 --tmpfs 挂载日志目录到内存(适合短期运行的容器);二是把日志输出到 stdout/stderr,让 Docker 的日志驱动来收集(推荐做法,也是 12-Factor App 的最佳实践)。
小结
今天我们从五个维度加固了 Docker 容器的安全:
- 非 root 用户 —— 降低容器逃逸后的攻击面
- 只读文件系统 —— 防止恶意文件写入
- Trivy 镜像扫描 —— 在部署前发现已知漏洞
- Docker Secret —— 安全管理敏感信息
- 网络隔离 —— 最小化容器间的通信权限
安全不是一次性的工作,而是持续的过程。把这些实践融入到你的日常开发流程中,特别是 CI/CD 流水线里的镜像扫描和安全检查,才是真正有效的安全策略。很多团队在项目初期觉得安全是 " 以后的事 ",结果到了要上线的时候才发现到处都是漏洞,改起来比重写还痛苦。所以,从第一天就把安全当成开发流程的一部分,而不是上线前的临时抱佛脚。
记住那句老话:安全问题,不是 " 会不会发生 " 的问题,而是 " 什么时候发生 " 的问题。早做准备,总比亡羊补牢强。
下一篇 Day 22,我们将进入 CI/CD 实战,用 GitHub Actions 把从代码提交到镜像构建、安全扫描、自动部署的整条流水线串起来。到时候,今天学的 Trivy 扫描就能直接派上用场了。敬请期待!