Docker Swarm 中使用 FIFO 管道实现一次性密钥
Docker Swarm 将密钥挂载到 /run/secrets/ 作为只读 tmpfs 卷。任何有 docker exec 权限的人都能随时读取它们。解决方案:使用命名管道(FIFO)在容器启动时实现一次性密钥传递。
标准方法的缺陷
密钥存储在管理节点的 Raft 日志中,通过 mTLS 发送到工作节点,并挂载到容器中。docker-compose.yml 中的定义如下:
version: "3.8"
services:
app:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: true
应用中读取方式:
with open("/run/secrets/db_password") as f:
db_password = f.read().strip()
通过 docker exec 访问:
$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123
在宿主机上访问:
sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123
为什么常见变通方案无效
- rm /truncate/chmod:只读 tmpfs
- umount:需要 CAP_SYS_ADMIN 权限
- 环境变量:在
/proc/1/environ中可见,并被子进程继承 - 外部管理工具(Vault):增加额外基础设施
FIFO 管道的工作原理
命名管道具有以下关键特性:
- 无读者时写入会阻塞
- 无写入者时读取会阻塞
- 数据在首次读取时被消耗
- 后续读取会挂起
演示:
$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe # 阻塞
cat /tmp/my_pipe # secret_value, 数据已消失
cat /tmp/my_pipe # 挂起
解决方案架构
两个容器共享一个带有 FIFO 的 tmpfs 卷:
- secret-injector:创建 FIFO,写入密钥,然后退出
- app:仅读取一次 FIFO,将密钥保存在内存中
应用容器没有 /run/secrets/。
实现
docker-compose.yml
version: "3.8"
services:
secret-injector:
image: alpine:3.19
secrets:
- db_password
- api_key
volumes:
- secrets_pipe:/shared
entrypoint: ["sh", "-c", "
mkfifo /shared/db_password /shared/api_key &&
cat /run/secrets/db_password > /shared/db_password &
cat /run/secrets/api_key > /shared/api_key &
wait
"]
deploy:
restart_policy:
condition: none
resources:
limits:
memory: 16M
app:
image: myapp:latest
volumes:
- secrets_pipe:/shared:ro
entrypoint: ["sh", "-c", "
DB_PASSWORD=$(cat /shared/db_password) &&
API_KEY=$(cat /shared/api_key) &&
exec myapp
"]
depends_on:
- secret-injector
volumes:
secrets_pipe:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=1m,noexec,nosuid
secrets:
db_password:
external: true
api_key:
external: true
执行流程
- secret-injector:
- mkfifo /shared/db_password /shared/api_key
- cat /run/secrets/... > /shared/...(阻塞)
- wait 保持容器存活
- app:
- DB_PASSWORD=$(cat /shared/db_password) → 解除写入者阻塞
- FIFO 中的数据被消耗
- secret-injector 退出(
restart_policy: none)
- app 运行,密钥仅存于内存
攻击者能看到什么
在 app 容器中:
$ docker exec -it <app_container> sh
$ ls /run/secrets/
ls: /run/secrets/: No such file or directory
$ cat /shared/db_password # 挂起
^C
在 secret-injector 容器中:
$ docker exec -it <injector_container> sh
Error: container is not running
强化安全:使用文件描述符
跳过环境变量,通过文件描述符(FD)传递:
Entrypoint:
#!/bin/sh
exec 3< /shared/db_password
exec 4< /shared/api_key
exec myapp --db-password-fd=3 --api-key-fd=4
Python:
import os
def read_secret_from_fd(fd_num: int) -> str:
with os.fdopen(fd_num, 'r') as f:
return f.read().strip()
db_password = read_secret_from_fd(3)
api_key = read_secret_from_fd(4)
Go:
package main
import (
"fmt"
"io"
"os"
)
func readSecretFromFD(fd int) (string, error) {
f := os.NewFile(uintptr(fd), fmt.Sprintf("fd-%d", fd))
if f == nil {
return "", fmt.Errorf("invalid file descriptor: %d", fd)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}
密钥仅存在于进程内存中。
关键要点
- 密钥通过 FIFO 仅读取一次,数据被消耗
secret-injector退出,无法docker exec- 应用容器无
/run/secrets/ - 优先使用文件描述符而非环境变量
- 无需外部服务
— Editorial Team
暂无评论。