Docker Swarm предоставляет встроенный механизм управления секретами: пароли, ключи API и сертификаты передаются в контейнеры через зашифрованный канал и монтируются в /run/secrets/. Звучит безопасно — пока вы не осознаете, что любой пользователь с доступом к docker exec может прочитать эти секреты в любой момент жизни контейнера.

В этой статье я разберу, почему стандартные способы защиты не работают, и покажу решение на основе именованных каналов (FIFO), которое позволяет секрету быть прочитанным ровно один раз — при старте приложения.

Disclaimer: Статья написана с большой помощью Claude Code. Я начал писать её руками, но, попросив Claude, я одобрил результат. Я отредактировал его своими замечаниями, поскольку Claude пропустил некоторые важные моменты.

Тем не менее, я готов ответить на все комментарии лично.

Как работают секреты в Docker Swarm

При развёртывании сервиса с секретами Docker Swarm:

  1. Хранит секреты в зашифрованном виде в Raft-логе менеджер-нод

  2. Передаёт их на рабочую ноду по зашифрованному каналу mTLS

  3. Монтирует каждый секрет как файл в tmpfs внутри контейнера по пути /run/secrets/<имя>

Определение секрета в 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()

Всё просто и удобно. Но есть проблема.

Проблема 1: секреты доступны через docker exec

Любой пользователь, имеющий доступ к Docker daemon (а значит, и к команде docker exec), может прочитать все секреты работающего контейнера:

$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123
$ docker exec -it <container_id> ls /run/secrets/
api_key
db_password

Секреты лежат в открытом виде на протяжении всего жизненного цикла контейнера. Приложение прочитало пароль при старте, он ему больше не нужен в файле — но файл останется доступен для чтения до момента остановки контейнера.

Это означает, что разработчик, DevOps-инженер, или любой компрометированный процесс внутри контейнера может в любой момент получить доступ к секретам.

Проблема 2: секреты доступны на самом хосте

Во время исполнения контейнера Docker создаёт для него структуру папок в файловой системе хоста и они могут быть прочитаны суперпользователем.

sudo ls /var/lib/docker/containers/<container_id>/mounts/secrets/
ioz4pc6q8fjsfhem4pwsr35u6
sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123

Что НЕ работает

Удаление файлов

Первое, что приходит в голову — удалить файл секрета после прочтения:

$ rm /run/secrets/db_password
rm: cannot remove '/run/secrets/db_password': Read-only file system

Docker монтирует /run/secrets как read-only tmpfs. Изнутри контейнера удалить файлы невозможно.

Очистка содержимого (truncate)

Может быть, обнулить содержимое?

$ truncate -s 0 /run/secrets/db_password
truncate: cannot open '/run/secrets/db_password' for writing: Read-only file system
$ echo -n "" > /run/secrets/db_password
bash: /run/secrets/db_password: Read-only file system

Тот же результат — файловая система смонтирована только для чтения.

Изменение прав доступа (chmod)

$ chmod 000 /run/secrets/db_password
chmod: changing permissions of '/run/secrets/db_password': Read-only file system

Не работает по той же причине.

Размонтирование

$ umount /run/secrets
umount: /run/secrets: must be superuser to unmount.
# Даже с root внутри контейнера:
$ umount /run/secrets
umount: /run/secrets: permission denied

Для umount необходим CAP_SYS_ADMIN, который по умолчанию отсутствует у контейнеров — и добавлять его крайне нежелательно.

Более того - с хоста отмонтировать их тоже не получится - файл исчезнет с хоста, но останется виден внутри контейнера.

Переменные окружения

Можно прочитать секрет в переменную окружения в entrypoint и передать приложению:

#!/bin/sh
export DB_PASSWORD=$(cat /run/secrets/db_password)
exec myapp

Но это не решает проблему — во-первых, файлы /run/secrets по-прежнему доступны. Во-вторых, переменные окружения процесса доступны через procfs:

$ docker exec -it <container_id> cat /proc/1/environ | tr '\0' '\n' | grep DB_PASSWORD
DB_PASSWORD=SuperSecretPassword123

На хосте переменные окружения точно так же доступны для суперпользователя:

sudo cat /proc/642637/environ
DB_PASSWORD=SuperSecretPassword123HOME=/rootPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binPWD=/

Ну, и вдобавок, переменные окружения наследуются по-умолчанию всеми дочерними процессами, которые ваше приложение запускает (shell=True). Пусть даже и в контейнере, но очень вероятно, что ваше большое приложение может запускать какие-то сторонние приложения внутри.

Внешний менеджер секретов (Vault)

HashiCorp Vault, AWS Secrets Manager и аналогичные решения полностью снимают проблему: секреты запрашиваются по API, никогда не попадают на файловую систему и существуют только в памяти процесса. Это «правильное» решение для production-среды с высокими требованиями к безопасности.

Но у него есть цена: дополнительная инфраструктура, настройка аутентификации, высокая доступность хранилища секретов и более сложный деплой. Не каждая команда готова к такому усложнению, особенно если весь стек уже построен вокруг Docker Swarm.

Есть ли промежуточное решение — без внешних зависимостей, но с одноразовым чтением секретов?

Решение: именованные каналы (FIFO)

Что такое FIFO

Именованный канал (named pipe, FIFO) — это специальный тип файла в Linux, который работает как очередь:

  • Запись блокируется, пока на другом конце нет читателя

  • Чтение блокируется, пока на другом конце нет писателя

  • Данные потребляются при чтении — после первого прочтения канал пуст

  • Повторное чтение без нового писателя зависнет навсегда

Именно это нам нужно: секрет можно прочитать ровно один раз.

$ mkfifo /tmp/my_pipe
# Терминал 1: запись (заблокируется до появления читателя)
$ echo "secret_value" > /tmp/my_pipe
# Терминал 2: чтение (данные потреблены)
$ cat /tmp/my_pipe
secret_value
# Терминал 3: повторное чтение — зависает навсегда
$ cat /tmp/my_pipe
# ... тишина, ожидание нового писателя ...

Архитектура решения

Идея: разделить доставку секретов и работу приложения на два контейнера, связанных общим tmpfs-томом с именованными каналами.

┌───────────────────────────┐                           ┌───────────────────────┐
│    secret-injector        │     tmpfs volume          │    app                │
│                           │    ┌──────────────┐       │                       │
│  /run/secrets/db_password ├──▶ │ FIFO-каналы  │ ──▶   │  Читает из FIFO      │
│  /run/secrets/api_key     ├──▶ │              │ ──▶   │  однократно           │
│                           │    └──────────────┘       │                       │
│  Завершается после        │     /shared/              │  /run/secrets — НЕТ   │
│  записи в каналы          │                           │  Секретов нет нигде   │
└───────────────────────────┘                           └───────────────────────┘

Ключевой принцип: контейнер с приложением не имеет смонтированных Docker-секретов. Он получает значения исключительно через FIFO, после чего данные исчезают.

Реализация

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

Как это работает шаг за шагом

  1. Запускается secret-injector:

    • Создаёт FIFO-файлы в общем tmpfs-томе (mkfifo /shared/db_password /shared/api_key)

    • Запускает фоновые процессы записи (cat /run/secrets/... > /shared/...)

    • Каждый процесс записи блокируется, ожидая читателя на другом конце канала

    • Команда wait держит контейнер, пока все фоновые процессы не завершатся

  2. Запускается app:

    • Читает из FIFO: DB_PASSWORD=$(cat /shared/db_password)

    • Это разблокирует писателя в secret-injector

    • Данные передаются и потребляются — в канале ничего не остаётся

  3. secret-injector завершается:

    • Все фоновые процессы записи завершены, wait возвращает управление

    • Контейнер останавливается (restart_policy: condition: none предотвращает перезапуск)

    • Выполнить docker exec в остановленный контейнер невозможно

  4. app работает:

    • Секреты находятся только в памяти процесса

    • FIFO-файлы на томе существуют как записи в файловой системе, но не содержат данных

Что увидит атакующий

При попытке docker exec в контейнер приложения:

$ docker exec -it <app_container> sh
# Docker-секреты не смонтированы
$ ls /run/secrets/
ls: /run/secrets/: No such file or directory
# FIFO-файлы существуют, но чтение зависнет навсегда —
# писателя больше нет
$ cat /shared/db_password
^C  # зависло, пришлось прервать
# Переменные окружения основного процесса
$ cat /proc/1/environ
# (зависит от того, как приложение передаёт секреты — см. раздел про hardening)

При попытке docker exec в secret-injector:

$ docker exec -it <injector_container> sh
Error: container is not running

Контейнер уже завершился — войти в него невозможно.

Усиление защиты: файловые дескрипторы вместо переменных окружения

В примере выше секреты попадают в переменные окружения процесса, которые можно прочитать через /proc/<pid>/environ. Чтобы избежать этого, передавайте секреты через файловые дескрипторы:

Entrypoint:

#!/bin/sh
# Открываем FIFO как файловые дескрипторы (данные потребляются)
exec 3< /shared/db_password
exec 4< /shared/api_key
# Запускаем приложение, передав номера дескрипторов
exec myapp --db-password-fd=3 --api-key-fd=4

Чтение в приложении (Python):

import os
import sys

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)

# Секреты существуют только как переменные в памяти процесса.
# В /proc/1/environ их нет.
# В файловой системе их нет.
# В FIFO-каналах их нет.

Чтение в приложении (Go):

package main

import (
	"fmt"
	"io"
	"os"
	"strings"
)

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 strings.TrimSpace(string(data)), nil
}

func main() {
	dbPassword, err := readSecretFromFD(3)
	if err != nil {
		panic(err)
	}
	// использовать dbPassword...
	_ = dbPassword
}

Ограничения и нюансы

Порядок запуска контейнеров

В Docker Swarm depends_on не гарантирует порядок запуска на уровне Swarm mode (он работает только в docker compose). Однако это не проблема: операция cat на FIFO блокируется до появления другой стороны. Если приложение стартует раньше инжектора — оно просто подождёт на чтении из FIFO. Если инжектор стартует раньше — он подождёт на записи.

FIFO обеспечивает естественную синхронизацию без необходимости во внешних механизмах координации.

Размещение на одной ноде

Общий tmpfs-том требует, чтобы оба контейнера работали на одной ноде. В Swarm это обеспечивается через placement constraints:

services:
  secret-injector:
    deploy:
      placement:
        constraints:
          - node.id == <node_id>
  app:
    deploy:
      placement:
        constraints:
          - node.id == <node_id>

Или через общий label ноды для большей гибкости:

services:
  secret-injector:
    deploy:
      placement:
        constraints:
          - node.labels.app-group == myapp
  app:
    deploy:
      placement:
        constraints:
          - node.labels.app-group == myapp

Перезапуск приложения

Если контейнер приложения перезапустится (crash, health check failure), он снова попытается прочитать из FIFO. Но secret-injector уже завершился — чтение зависнет навсегда, и контейнер не поднимется.

Варианты решения:

1. Разрешить перезапуск инжектора при падении приложения:

services:
  secret-injector:
    deploy:
      restart_policy:
        condition: any
        delay: 5s
    entrypoint: ["sh", "-c", "
      rm -f /shared/db_password /shared/api_key;
      mkfifo /shared/db_password /shared/api_key &&
      cat /run/secrets/db_password > /shared/db_password &
      cat /run/secrets/api_key > /shared/api_key &
      wait
    "]

Это ослабляет защиту — инжектор будет работать дольше и станет доступен для docker exec. Но окно уязвимости ограничено временем записи.

2. Перезапускать весь стек (предпочтительно):

При падении приложения удалять и пересоздавать весь сервис, а не полагаться на restart policy. Это гарантирует чистый цикл доставки секретов.

Множество реплик

При масштабировании сервиса (replicas: N) каждая реплика приложения нуждается в своём экземпляре инжектора. Это можно решить через Docker Swarm global mode или через шаблоны имён:

services:
  app:
    deploy:
      replicas: 3

В этом случае лучше рассмотреть вариант, где entrypoint самого приложения выполняет роль одноразового читателя, а инжектор работает как global-сервис на каждой ноде.

Сравнение подходов

Подход

Защита от docker exec

Защита от exec -u root

Секреты на диске

Доп. инфраструктура

Стандартные Docker Secrets

Нет

Нет

Да (read-only)

Нет

chmod/truncate

Read-only FS

uid/gid/mode в секрете

Частично

Нет

Да

Нет

FIFO (named pipes)

Да

Да

Нет

Нет

Vault / Secret Manager

Да

Да

Нет

Да

Заключение

Именованные каналы — это элегантное решение, которое использует фундаментальное свойство FIFO в Linux: данные потребляются при чтении. В сочетании с паттерном двух контейнеров (инжектор + приложение) это позволяет:

  • Полностью исключить наличие секретов в файловой системе контейнера приложения

  • Гарантировать одноразовое чтение без внешних зависимостей

  • Работать в рамках стандартного Docker Swarm без Vault и дополнительной инфраструктуры

Это не замена полноценному менеджеру секретов для высоконагруженных production-систем. Но для команд, которые уже используют Docker Swarm и хотят значительно повысить безопасность секретов без усложнения стека, — это практичный и работающий подход.