Docker Secret安全实践:从Swarm到Compose的敏感信息管理

发布时间:2026/10/22 15:44:10
Docker Secret安全实践:从Swarm到Compose的敏感信息管理
这个系列写到第十二篇终于轮到不少读者催更的 docker secret 了。前几篇我们把镜像、容器、网络、卷和 Compose 都过了一遍看起来一套服务已经能跑得像模像样了但有个问题一直绕不过去数据库密码、Redis 口令、API Token 这些敏感信息到底怎么安全地交给容器如果你也经历过“密码直接写环境变量结果 docker inspect 一眼看光”“配置文件里藏着数据库账号传到代码仓库被同事围观”之类的尴尬那这篇内容应该能帮你把这块补上。Docker Secret 是 Docker 官方提供的一套敏感信息分发机制它把密码这类数据从镜像、环境变量和普通文件挂载中剥离出来按需、加密、临时地交给运行中的容器。这篇博文会从设计动机讲到 Swarm 和本地两种模式的底层差异再带你亲手跑通创建、挂载、轮换、删除的完整链路中间穿插我实际踩过的坑。适合已经会基础 Docker 操作、正准备把服务搬上生产的开发者也适合刚学完 Compose、想给单机部署加点安全感的同学。1. Docker Secret 到底解决了什么痛点1.1 常见传密方式的隐患我先说一个很典型的场景。假设你部署了一个内部系统前端 Nginx 反代后端 API 连接 MySQL 和 Redis。刚开始图省事很多人会这样干docker run -d --name api \ -e DB_PASSWORDroot123456 \ -e REDIS_PASSWORDredis123 \ myinternal/api:v1或者把密码写到 Compose 文件的 environment 下面。看起来方便实际上问题不少。首先docker inspect能直接看到容器全部环境变量。任何一个能碰到 Docker Socket 的人都能把密码捞出来。其次环境变量会被子进程继承如果你的应用里有个打印环境变量的调试逻辑或者某个依赖库异常时把环境变量带进日志密码就随着日志流出去了。更隐蔽的问题是环境变量修改起来只能重建容器还很容易出现“开发机一套密码、测试机一套密码、生产机又一套密码”的混乱局面。还有一种做法是把配置挂进去比如docker run -d -v /host-config/app.conf:/etc/app/app.conf myapp这种方式比环境变量好一些密码至少没进镜像层但它把“敏感数据”和“普通配置”混在了一起。宿主机上配置文件是明文谁拿到这台机器或者备份包谁就拿到全部密码。而且一旦挂载卷需要修改权限、属主、挂载方式都要重新核对维护成本不低。最糟糕的做法是把密码写死在镜像里。比如在 Dockerfile 里写ENV DB_PASSWORDroot123456或者在构建时 COPY 一个带密码的配置文件进去。这个隐患是致命的docker history可以把镜像每一层的变更记录翻出来密码等于直接印在镜像脸上。而镜像一旦推送到仓库相当于把密码分发到了所有能拉取这个镜像的地方。1.2 Secret 的本质与设计目标Docker Secret 就是为了解决上面这三类问题而设计的。它的核心思路是敏感信息不再随着镜像、环境变量、普通挂载文件流转而是作为一个独立的“密钥对象”存在 Docker 里只在服务调度到某个容器时才把内容以只读文件的形式挂进容器内部。你可以把它类比成写字楼的门禁二维码物业不会把整栋楼的钥匙一串串挂在公共墙上环境变量更不会把钥匙直接焊进每间办公室的门框里镜像写死而是访客到前台登记后前台才生成一个临时二维码。这个二维码只对特定楼层、特定时间段有效用完即失效。落到技术上Secret 有几个关键特性Secret 内容在创建后不会通过普通 API 回传docker secret inspect只能看到元数据看不到明文。Secret 以文件形式出现在容器中默认路径是/run/secrets/secret_name它是只读的 tmpfs 挂载不是持久化磁盘文件。Secret 与镜像、容器解耦同一个 Secret 可以被多个服务引用也可以随时切换引用关系。轮换时不用重建整个镜像只需创建新 Secret 再切换服务引用。我最初用 Secret 之前也怀疑过就一个“挂文件”能比环境变量安全多少后来真正在 Swarm 集群里跑了一遍才意识到安全感主要来自“最小暴露”密码不再散落在各种环境变量、镜像层、宿主机目录里了而是有了一个集中管理、按需下发的出口。2. 理解 Swarm 与本地两种模式的底层差异2.1 两种模式对比网上很多教程会告诉你“Docker Secret 是 Swarm 专属功能”然后手把手教你docker swarm init。这在几年前是对的但现在已经不完全准确了。Docker 在较新的版本里支持了不依赖 Swarm 的本地 Secret 用法docker run --secret和 Docker Compose 的secrets都在往这个方向靠。不过要强调的是这两种模式底层机制完全不同别以为本地模式就是 Swarm 模式的简化版。我在实际使用中发现很多人切换模式后遇到各种问题根源就是没搞清楚差异。维度Swarm 模式本地模式run / Compose存储方式创建时加密后存入 Swarm 集群存储宿主机文件系统上的明文文件分发机制管理节点解密后下发给工作节点挂载为 tmpfs容器启动时直接绑定挂载宿主机文件依赖条件必须先初始化 Swarm 或加入集群无需 Swarm普通 Docker 环境即可容器内形态/run/secrets/name只读 tmpfs/run/secrets/name只读绑定挂载适用场景多节点生产集群、需要集中管理密钥单机开发、CI、简单部署安全边界相对高数据不落工作节点持久化磁盘相对低宿主机明文文件即密钥本身2.2 Swarm 模式的分发流程Swarm 模式下docker secret create会把内容加密后写入集群的存储层。当你创建一个 service 并指定某个 secret 时调度器会在任务分配到某个工作节点后把解密后的内容以 tmpfs 的形式挂到容器的/run/secrets目录里。这意味着几个很重要的事实Secret 的内容不会因为容器重启而重新读取宿主机文件因为它的生命周期跟任务绑定工作节点的普通磁盘上没有这个 Secret 的持久化副本容器停下来tmpfs 一卸载内容就没了管理节点也不能随便把某个 Secret 的明文导出来这是设计上的单向通道。我在刚开始接触时犯过一个错误以为 Secret 像配置文件一样宿主机的某个路径下有个原始文件改了文件容器里就会变。实际完全不是这样。Secret 是在创建那一刻被“快照”下来的之后它就是一个独立对象跟来源文件、echo 命令都没关系了。想改内容只能创建一个新的 Secret。2.3 本地模式的安全边界本地模式的原理简单很多Docker 把宿主机上一个文件以只读方式挂到容器里路径通常还是/run/secrets/name。它不需要 Swarm没有加密存储也没有集群分发。本质上就是一个“按路径约定好的只读文件挂载”。正因为这样本地模式的安全边界很清晰宿主机上那个源文件必须是受保护的。如果源文件权限是 644任何能登录宿主机的用户都能直接读如果源文件被提交进 Git 仓库密码就进了代码历史这就跟环境变量泄漏没什么区别了。我的建议是本地模式可以用于开发环境、内网单机部署、CI 临时跑测试但不要因为用起来简单就把它当成生产环境的安全方案。多节点生产还是优先走 Swarm 模式或者考虑更专业的密钥管理系统。3. 从零跑通一个 Swarm 下的 Secret 实战3.1 环境准备与 Swarm 初始化先确认环境状态。Docker 自带的docker info就能看到 Swarm 状态docker info | grep -i swarm如果输出类似Swarm: inactive说明当前没开 Swarm。单节点学习时直接初始化即可docker swarm init --advertise-addr 127.0.0.1这里提一下--advertise-addr它决定了其他节点怎么访问当前节点的管理接口。单机测试用回环地址没问题但是如果你后面要加工作节点这里就得填一个其他节点能访问到的 IP。初始化完成后可以看一眼集群状态docker node ls看到Leader字样就说明 Swarm 控制面可用了。这一步不做的话后面的docker secret create会直接报错因为 Secret 的存储依赖 Swarm 的集群存储。3.2 创建 Secret 的三种方式进入正题。创建 Secret 最常用的方式是从标准输入读取echo prod-db-pass-2024 | docker secret create db_pass -注意末尾的横线-它表示从 stdin 读取内容。我推荐配合printf使用避免echo默认追加换行符把密码弄脏printf root | docker secret create mysql_root_pass -如果密钥内容在文件里也可以直接指定文件名docker secret create app_key ./app_key.txt这种方式适合从已有的密钥文件迁移。不过要提醒一句文件名里面的内容会被完整读取作为 Secret 的快照之后这个文件本身怎么处理都影响不到已经创建的 Secret 了。创建完可以查看列表docker secret ls想确认某个 Secret 的元数据用docker secret inspect db_pass注意了这里你会看到名称、ID、创建时间但看不到内容。这不是因为我操作有误而是设计如此Secret 的内容不通过 API 回传谁都不能事后从 Docker 里倒推明文。所以重要 Secret 一定要在外部有安全备份比如密码管理器否则丢了只能重新创建。3.3 服务挂载与容器内验证创建完 Secret接下来把它挂给一个服务。传统 Swarm 场景里这不是docker run --secret而是写在docker service create的参数里docker service create \ --name demo-web \ --secret db_pass \ --publish 8080:80 \ nginx:alpine容器启动后Secret 会出现在默认路径/run/secrets/db_pass。进入容器验证一下docker service ps demo-web CONTAINER ID$(docker ps --filter namedemo-web -q | head -1) docker exec -it $CONTAINER_ID sh cat /run/secrets/db_pass正常会输出你刚刚创建的内容。再留意一下文件权限ls -l /run/secrets/输出通常是-r--r--r--之类反正不是普通配置文件那种可写状态。这个只读属性就是设计的一部分——防止运行中的程序意外篡改密钥。如果你不想用默认路径和文件名service 创建时可以用更精细的语法控制docker service create \ --name demo-web2 \ --secret srcdb_pass,target/etc/demo/pass.txt,mode0400 \ nginx:alpine这里src指定 Secret 名称target指定容器内路径mode指定权限。这个写法在“容器以非 root 用户运行”的场景里特别重要后面我会专门讲。4. 容器内 Secret 文件的变化机制与验证4.1 /run/secrets 到底是什么在开始调戏 Secret 之前先搞清楚容器里看到的/run/secrets/db_pass到底是什么。它不是普通的文件也不是你宿主机上某个持久化目录的映射它是 tmpfs内存文件系统上的一份只读副本。你可以把它理解成快递柜里的临时格口快递密钥内容被放进去柜门锁上收件人看完取走格口就释放了。容器重启、服务滚动更新、任务被调度到别的节点旧的格口会销毁新的任务会在新节点上重新分配格口。这个设计和卷Volume有本质区别。卷是持久化存储改宿主机上的文件容器里马上能看到Secret 则相反它在创建时快照、调度时下发、生命周期绑定容器任务。所以别指望“我在宿主机某个目录改一下 Secret 内容容器里就自动更新”这种操作那不符合它的设计。4.2 更新 Secret 后容器内会怎样很多人第一次用 Secret 时都会遇到一个困惑我改了 Secret为什么容器里的文件没变这里要澄清一个关键点Docker Secret 本身支持修改内容的操作吗不支持。同一个名字的 Secret 创建后内容就不可变了。你要换内容必须创建一个新的 Secret 对象再让服务切换引用。切换引用时Swarm 会触发服务的滚动更新。命令大概长这样printf new-password-2025 | docker secret create db_pass_v2 - docker service update --detachfalse \ --secret-rm db_pass \ --secret-add db_pass_v2 \ demo-web执行后Swarm 会逐个替换服务任务。新的任务容器里挂载的是db_pass_v2的内容旧任务在收敛前可能还在运行很短时间。如果你的应用对数据库密码特别敏感切换时最好让新旧密码在上游系统比如 MySQL 里同时生效一小段时间这就是所谓的“双密码窗口”。我最早做轮换时没考虑这个结果服务更新瞬间出现过连接被拒后来就学乖了。4.3 如何验证更新生效验证流程也很简单。先看服务任务状态docker service ps demo-web找到新启动的任务对应的容器进入容器再cat /run/secrets/db_pass就能确认内容已经变成新密码。如果文件名变了比如从 db_pass 换成了 db_pass_v2但 target 没有指定记得路径也会变应用的配置文件或启动脚本要同步调整。这里也顺便提一个最容易忽略的点应用通常不会自动感知 Secret 文件变化。即使 Swarm 已经把新 Secret 挂上去了你的进程如果还在读旧配置或者连接池还握着旧密码一样会失败。所以轮换之后应用程序要能感知文件变化或者配合重启、信号刷新机制。这个问题在 Nginx、Java 应用里尤其明显。5. Secret 轮换、更新与删除的完整链路5.1 为什么轮换是个流程而不是一条命令很多教程讲 Secret 就停在“创建、挂载、查看”三步等真要换密码时读者就卡住了。Secret 是不可变对象这意味着轮换必然是一个多步骤流程不是一个docker secret update就能解决的问题。我总结的轮换顺序是这样的先在上游系统数据库、第三方服务里设置新密码并保留旧密码一小段时间。创建新内容的 Secret 对象。用docker service update --secret-rm 旧名 --secret-add 新名切换引用。等待服务收敛确认新任务容器里读取的是新内容。确认业务正常后再清理旧的 Secret 对象。顺序很重要。如果先删旧 Secret 再建新的中间服务一旦滚动更新可能同时引用一个不存在的对象直接导致调度失败。如果先切换再在上游改密应用连的还是旧密码就会被拒绝。5.2 脚本化轮换示例手工执行一两次可以但例行轮换比如三个月一次还是写成脚本更靠谱。下面是一个简化但可用的轮换脚本#!/usr/bin/env bash set -euo pipefail SERVICE_NAMEdemo-web OLD_SECRETdb_pass NEW_SECRETdb_pass_$(date %Y%m%d%H%M%S) printf your-new-password-here | docker secret create $NEW_SECRET - docker service update --detachfalse \ --secret-rm $OLD_SECRET \ --secret-add $NEW_SECRET \ $SERVICE_NAME docker secret rm $OLD_SECRET几个细节值得说明set -euo pipefail保证脚本在中间出错时立刻退出避免半轮换状态新 Secret 名称带时间戳方便回溯--detachfalse让命令等待服务更新完成再继续否则可能在任务收敛前就删了旧 Secret引发调度错误。密码内容如果不想写死在脚本里可以结合openssl rand -base64 24生成随机内容或者从密码管理器的 CLI 取。不过那些就超出本文范围了只说一句别把真实密码直接 echo 到 shell 历史里。5.3 删除 Secret 的约束清理过期 Secret 用的是docker secret rm db_pass有个约束很容易踩到正在被某个服务引用的 Secret 删除会报错。这其实是一种保护机制防止你把还在用的密钥删掉。所以删除前应该先确认所有服务的--secret引用都已经切换走docker service inspect demo-web --format {{json .Spec.TaskTemplate.ContainerSpec.Secrets}}看到输出里没有旧 Secret 的引用再删就比较安全了。另外服务被删除后它引用的 Secret 不会自动删除。也就是说docker stack rm或者docker service rm之后旧 Secret 还躺在集群里。这是我见过很多人忽略的点清理服务却没清理 Secret结果docker secret ls里一堆过期的“僵尸密钥”。建议服务下线时把对应的 Secret 也顺手清掉。6. 不初始化 Swarm 也能用 Secret本地模式正解6.1 本地模式的开启条件前几节全是 Swarm 场景。但如果你只是单机跑几个容器或者想在本机写个 Demo不想为这点事儿初始化 Swarm那也有一条路本地 Secret 模式。这个模式最早跟 Docker Compose 绑定后来 Docker Engine 在较新版本里也支持了docker run --secret的用法。我建议以 Docker 官方文档为准如果你的版本比较旧就用 Compose 方式最稳妥。6.2 Compose secrets 配置示例一个典型的compose.yaml长这样services: app: image: myinternal/app:v1 secrets: - db_password - app_token environment: - DB_PASSWORD_FILE/run/secrets/db_password secrets: db_password: file: ./secrets/db_password.txt app_token: file: ./secrets/app_token.txt启动后容器内默认路径就是/run/secrets/db_password和/run/secrets/app_token。如果你的应用只支持从环境变量读配置可以在 environment 里指一个文件路径让应用自行读取文件内容。这样既用上了“敏感信息不直接进环境变量”的好处又兼容了老程序。如果对路径有特殊要求可以在容器的 secrets 配置里写 targetservices: app: secrets: - source: db_password target: /etc/app/db_pass uid: 1000 gid: 1000 mode: 0400这个写法跟 Swarm service create 里--secret src...,target...,uid...,mode...是同一个思路。6.3 本地模式的实际注意点本地模式用起来确实方便但在安全上的定位要清楚Swarm 模式好歹有集群存储和加密机制本地模式就是“宿主机文件明文 只读挂载”防的不是宿主机入侵者而是防止这些密钥不小心进入镜像层、环境变量或代码仓库。所以使用时有几条纪律源文件权限尽量收紧一般chmod 600起步源文件路径别暴露给所有用户共享的目录更重要的是绝对不要把secrets:下面的file:指到代码仓库目录里的某个文件然后把整个仓库提交到远端。我见过不止一次Compose 写得挺规范结果./secrets/password.txt被提交到 Git 历史里等于白干。开发调试阶段本地模式完全可以替代环境变量暴力传参。生产环境我建议还是回到 Swarm 模式或者根据实际情况选择更完整的密钥管理方案。7. 生产环境用 Secret 最容易踩的五个坑7.1 坑点总览坑现象解法容器以非 root 用户运行读不到 Secretcat报 Permission denied创建 Secret 引用时指定 uid / gid / mode把 Secret 内容写进 Compose 内联配置密码出现在代码仓库对外用file:引用并加入 .gitignore修改宿主机源文件以为容器自动生效应用还是旧值理解 Secret 不可变轮换必须建新对象并切换引用大段配置也塞进 Secret超出大小限制维护混乱敏感短数据用 Secret普通配置用 Docker Config 或卷环境变量传参的旧代码没改容器内找不到 /run/secrets 路径改代码从文件读取或在应用层做兼容7.2 非 root 用户读取 Secret 这个坑再多说几句这是我在实际部署中踩过最深的一个坑。很多基础镜像出于安全考虑进程以非 root 用户运行比如 UID 1000。而 Swarm 模式挂载的 Secret 文件默认属主是 root权限通常是 0444即 root 能读其他用户虽然名字上写着可读但实际因为目录权限或 mount 方式的关系非 root 进程可能cat时直接报权限拒绝。我当时排查了很久最后在创建 service 时加了三件套才解决docker service create \ --name demo-web \ --secret srcdb_pass,target/run/secrets/db_pass,uid1000,gid1000,mode0400 \ nginx:alpine或者 Compose 里对应写secrets: - source: db_pass target: /run/secrets/db_pass uid: 1000 gid: 1000 mode: 0400验证方法也很简单进容器后用应用同款 UID 试一下docker exec -u 1000 container cat /run/secrets/db_pass这一步能过说明非 root 进程也能正常读到密钥了。如果你用的是自定义镜像建议把 UID / GID 的数值跟镜像里用户的 UID 保持一致避免看着都是 1000 实际却对不上的问题。7.3 配置与密钥要分开最后一个经验总结是Docker Secret 跟 Docker Config 要分清楚别把什么文件都往 Secret 里塞。Secret 适合的是短小的、敏感的、需要轮换的内容比如数据库密码、第三方 Token、私钥。普通配置比如超时时间、服务地址、业务开关应该走 Docker Config 或者卷挂载。把它们混在一起的话轮换就很痛苦——你只想换一个 Token结果整个 Secret 里塞了一堆业务配置不得不全都重新生成一遍风险面反而扩大了。分开管理以后每次轮换的改动范围、影响面都能控制得清清楚楚。如果你也是从环境变量迁到 Secret 这边的我的经验是先做轮换流程再迁数据先把新密码在数据库侧配好再创建新 Secret、切换服务、验证、清理全部走通一遍之后再拆掉旧路径。这样迁移过程中任何一步出问题都可以随时回滚到旧方案不至于整个服务当场不可用。