容器提供隔离,但隔离不是免配置的安全边界。安全基线的思路很朴素:减少镜像内容、减少进程权限、减少可写位置、减少暴露端口,并让依赖版本可追踪。
安全的目标不是让容器“绝对无法被攻破”,而是降低漏洞出现概率、限制被利用后的能力、缩小横向移动范围,并保留发现与恢复所需的证据。
在加开关之前,先问我们保护什么、入口在哪里、攻击者成功后可能做什么。本项目至少有这些资产与边界:
安全配置应该能对应某个风险。若说不出它限制了哪条攻击路径,就容易出现“看似加固、实际破坏功能”或“开关很多、关键入口仍暴露”的情况。
Linux 容器共享内核。namespace 隔离视野,cgroup 限制资源,capability、seccomp 和其他安全模块进一步约束系统调用与权限,但内核仍是共同信任边界。容器逃逸漏洞虽然不是日常必然发生,却说明“在容器里”不能替代补丁、最小权限和分层防护。
不要在容器里运行不可信代码,就假设 Docker 默认隔离足以承担所有风险。高风险多租户或任意代码执行场景需要更强隔离、专门沙箱和独立基础设施设计。
优先选择有明确维护者和更新策略的基础镜像。标签便于阅读,digest 用于精确锁定内容:
FROM alpine:3.22@sha256:<完整摘要>锁定 digest 后,基础镜像不会因为同名标签变化而悄悄更新;代价是你需要主动更新 digest,才能获得安全修复。可靠流程应同时具备“可复现”和“定期更新”。
查看镜像摘要:
$ docker image inspect alpine:3.22 \
--format '{{index .RepoDigests 0}}'
alpine@sha256:14358309a308...摘要解决“今天与明天是否得到同一内容”,不解决“这份内容是否没有漏洞”。一直锁定旧摘要会得到高度可复现的旧风险;一直跟随浮动标签则可能在未验证时改变交付物。更成熟的流程是:锁定当前已验证摘要,定期发现上游更新,重新构建、扫描、测试,再更新锁定值。
多阶段构建移除编译器、源码和模块缓存,减少可被攻击者利用的工具和需要修补的软件包,也降低分发面。但极简镜像会减少排错工具,所以要用日志、结构化状态和临时诊断容器补足可观测性。安全和可运维不是二选一,而是分别设计。
基础镜像可信不等于整个镜像可信。依赖清单、下载地址、构建器、CI 凭证和推送权限都在供应链中。至少应做到:依赖有锁或校验文件;构建日志可追踪;秘密不进入层;发布物关联源码提交;推送后记录 digest;使用扫描与签名/证明能力时把结果纳入发布判断。
Dockerfile 已经创建 app 用户:
RUN addgroup -S app && adduser -S -G app app
USER app运行容器后确认身份:
$ docker compose exec api id
uid=100(app) gid=101(app) groups=101(app)非 root 不能消除漏洞,但能缩小应用进程被利用后的权限范围。容器内 root 也不是“安全的普通用户”,应避免默认使用。
namespace 会隔离一部分资源,但 UID 0 仍拥有容器内大量特权;一旦组合了危险挂载、额外 capabilities、privileged 或运行时漏洞,影响可能越过预期边界。使用非 root 让许多文件和内核操作在第一步就因权限不足而失败。
还要区分用户名和数字 UID/GID。数据卷与绑定挂载最终按数字权限判断,镜像之间叫同一个 app 的用户未必拥有同一个 UID。生产镜像应稳定声明运行 UID,并为需要写入的挂载准备匹配所有权。
USER app 只改变容器进程的用户;Docker 守护进程仍可能以 root 运行。Engine 的 rootless 模式则让守护进程和容器都位于非 root 用户的 user namespace 中,进一步降低守护进程漏洞影响。它也有平台和功能约束,需要按部署需求评估。
Compose 中的:
read_only: true阻止应用修改镜像提供的根文件系统。验证:
$ docker compose exec api sh -c 'touch /should-fail'
touch: /should-fail: Read-only file system如果应用确实需要临时目录,应显式添加 tmpfs,不要把整个根文件系统改回可写:
tmpfs:
- /tmp:size=16m,mode=1777只读根文件系统的价值在于把“哪些位置允许变化”从默认全部可写,改成明确列举。攻击者即使取得应用进程能力,也更难替换二进制、写入启动脚本或长期落地文件。
它不保护数据卷:卷仍按挂载模式可写。因此 API 不应该挂载 Redis 卷,备份工具应尽量只读挂载源卷,只有 Redis 服务拥有 /data 的写权限。
API 不需要修改网络、挂载文件系统或管理进程,因此可以移除全部 Linux capabilities:
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges 阻止进程通过 setuid 等机制获得新权限。
不同镜像的启动流程不同。Redis 官方镜像需要准备数据目录,盲目对它使用 cap_drop: ALL 可能导致数据卷权限错误。先了解进程需要什么,再收紧到最小权限。
Linux 把传统 root 的一部分能力拆成 capability,例如修改网络配置、切换文件所有者或执行某些系统管理操作。容器运行时通常已经默认移除一批高风险能力,但应用仍应显式删除不需要的部分。
cap_drop: [ALL] 适合像本项目 API 这样只监听高位端口、读配置并发出网络请求的进程。如果某项功能确实需要能力,应只加回明确的一项并记录原因,而不是使用 privileged: true 绕过问题。
no-new-privileges 各自限制什么默认 seccomp 配置会阻止一部分高风险系统调用;capabilities 限制特权操作;no-new-privileges 阻止通过执行 setuid/setgid 文件等方式提升权限。它们是互补边界,不是同一个开关的不同写法。
privileged: true 会显著扩大设备访问和内核能力范围,不应作为解决权限错误的通用办法。若某容器需要它,应单独进行威胁评估、隔离和审计。
项目只发布 API 的端口:
api:
ports:
- "${APP_PORT:-8080}:8080"
redis:
# 没有 portsexpose 或镜像中的 EXPOSE 不会把端口发布到外部。Redis 只在 Compose 网络中接受 API 连接,减少了直接攻击入口。
如果只需要从开发设备访问 API,可以进一步绑定回环地址:
ports:
- "127.0.0.1:${APP_PORT:-8080}:8080"网络最小化不只看 ports。还要确认应用监听地址、容器加入了哪些网络、是否配置代理或额外 host 映射、出站访问是否需要限制。默认 bridge 网络中的服务可以相互通信,所以更复杂项目可把前端、后端、数据层放进不同网络,让只有确实需要的服务同时加入两侧。
Redis 没有认证并不因为“在容器里”就自动安全;它的安全性依赖未发布端口和受控网络成员。真实部署若边界更复杂,仍需 Redis 自身认证、传输保护和上层网络策略。
失控进程可能占满内存或 CPU。直接运行容器时可设置:
$ docker run -d --name resource-demo \
--memory 128m \
--cpus 0.5 \
alpine:3.22 sleep 60
7a21c4...检查限制:
$ docker inspect resource-demo \
--format 'memory={{.HostConfig.Memory}} nano_cpus={{.HostConfig.NanoCpus}}'
memory=134217728 nano_cpus=500000000
$ docker rm -f resource-demo
resource-demo生产限制应根据真实负载和监控数据设置。过低会制造故障,完全不设则会放大故障影响。
内存上限可以防止一个容器无限挤占同一引擎资源,但超过上限时进程可能被 OOM 终止;CPU 限制会让任务变慢;进程数限制可以减轻 fork bomb 风险,却也可能阻止合理并发。限制值应来自压测与监控,并配合告警和重启策略。
还要为日志和数据增长设置边界。容器标准输出如果没有轮转策略,磁盘仍可能被日志填满;Redis 卷也会持续增长。资源基线应覆盖 CPU、内存、PIDs、日志和持久存储,而不是只写两个参数。
以下位置都会留下痕迹,不适合传递密钥:
ENV。docker build --build-arg 传入并写入镜像层的值。.env 文件。构建需要临时访问凭据时,使用 BuildKit secret mount;运行时密钥交给部署平台的 secrets 管理。即使后来删除文件,旧镜像层仍可能保留内容。
BuildKit secret 的核心是“构建步骤临时可读,但不复制进镜像层”。概念形式如下:
RUN --mount=type=secret,id=registry_token \
some-command --token-file /run/secrets/registry_token构建时再从受保护来源提供 registry_token。即便如此,命令也不能把秘密打印到日志或复制到输出。秘密管理的目标是缩短暴露时间、限制可见主体并可轮换,而不只是换一种传参语法。
把 /var/run/docker.sock 挂进容器,相当于让容器内进程向 Docker API 发管理请求。它可能创建高权限容器、挂载广泛路径或读取其他容器配置,影响远超普通文件挂载。
# 高风险示例,不要用于本项目
volumes:
- /var/run/docker.sock:/var/run/docker.sock有些管理工具确实需要访问 Docker API,但应使用经过评估的最小权限代理、专用引擎或隔离环境,并限制谁能向该工具发请求。不要因为“工具官方示例这样写”就忽略它获得的实际权限。
$ docker inspect docker-task-api-api-1 --format \
'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} cap_drop={{json .HostConfig.CapDrop}}'
user=app readonly=true cap_drop=["ALL"]再看发布端口:
$ docker compose ps
NAME SERVICE PORTS
docker-task-api-api-1 api 0.0.0.0:8080->8080/tcp
docker-task-api-redis-1 redis 6379/tcpAPI 具备非 root、只读根文件系统和移除全部权能三项限制。Redis 的 6379/tcp 只表示容器内端口,没有外部映射地址。
继续核对提权限制、挂载和网络,而不是只看 YAML:
$ docker inspect docker-task-api-api-1 --format \
'security={{json .HostConfig.SecurityOpt}} mounts={{json .Mounts}} networks={{json .NetworkSettings.Networks}}'
security=["no-new-privileges:true"] mounts=[] networks={...}API 不应挂载 Redis 数据卷,也不应出现 Docker socket。再从容器内做两次负向验证:
$ docker compose exec api sh -c 'id -u; touch /blocked'
100
touch: /blocked: Read-only file system安全设置的验收经常是某项危险操作应该失败。配置存在只证明声明被解析,负向测试才证明运行边界生效。同时还要重放正常业务请求,确认加固没有破坏 /health 和任务接口。
镜像扫描工具会把软件包和已知漏洞数据库匹配,结果会随数据库更新。扫描不是“零漏洞证书”,也可能缺少应用依赖、误报或无法判断某漏洞在当前配置下是否可利用。
处理结果时至少看:漏洞严重度、受影响组件是否实际存在和可达、是否有修复版本、镜像是否来自可更新基础、临时缓解措施以及接受风险的期限。高严重度不应被无条件忽略,低严重度也不代表永远无需处理。
发布门禁应有明确策略,例如阻止存在可修复的关键漏洞,同时允许经过记录、限定期限和补偿控制的例外。扫描结论必须关联具体镜像 digest,否则标签移动后报告可能对应错内容。
no-new-privileges。安全不是最后补上的一个开关。Dockerfile 决定镜像内容和默认用户,Compose 决定端口、文件系统、权能和运行配置,两边需要一起审查。