返回首页

用于一次性 Docker Swarm 机密的 FIFO

本文描述了一种通过命名 FIFO 通道保护 Docker Swarm 机密的方法。机密一次性传输到应用程序,排除通过 docker exec 的访问。基于两个容器共享 tmpfs 卷的实现。

一次性 Docker Swarm 机密:对抗 docker exec 的 FIFO
Advertisement 728x90

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

应用中读取方式:

Google AdInline article slot
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 管道的工作原理

命名管道具有以下关键特性:

Google AdInline article slot
  • 无读者时写入会阻塞
  • 无写入者时读取会阻塞
  • 数据在首次读取时被消耗
  • 后续读取会挂起

演示:

$ 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/

Google AdInline article slot

实现

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

Advertisement 728x90

继续阅读