Docker Swarm предоставляет встроенный механизм управления секретами: пароли, ключи API и сертификаты передаются в контейнеры через зашифрованный канал и монтируются в /run/secrets/. Звучит безопасно — пока вы не осознаете, что любой пользователь с доступом к docker exec может прочитать эти секреты в любой момент жизни контейнера.
В этой статье я разберу, почему стандартные способы защиты не работают, и покажу решение на основе именованных каналов (FIFO), которое позволяет секрету быть прочитанным ровно один раз — при старте приложения.
Disclaimer: Статья написана с большой помощью Claude Code. Я начал писать её руками, но, попросив Claude, я одобрил результат. Я отредактировал его своими замечаниями, поскольку Claude пропустил некоторые важные моменты.
Тем не менее, я готов ответить на все комментарии лично.
Как работают секреты в Docker Swarm
При развёртывании сервиса с секретами Docker Swarm:
Хранит секреты в зашифрованном виде в Raft-логе менеджер-нод
Передаёт их на рабочую ноду по зашифрованному каналу mTLS
Монтирует каждый секрет как файл в 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
Как это работает шаг за шагом
Запускается
secret-injector:Создаёт FIFO-файлы в общем tmpfs-томе (
mkfifo /shared/db_password /shared/api_key)Запускает фоновые процессы записи (
cat /run/secrets/... > /shared/...)Каждый процесс записи блокируется, ожидая читателя на другом конце канала
Команда
waitдержит контейнер, пока все фоновые процессы не завершатся
Запускается
app:Читает из FIFO:
DB_PASSWORD=$(cat /shared/db_password)Это разблокирует писателя в
secret-injectorДанные передаются и потребляются — в канале ничего не остаётся
secret-injectorзавершается:Все фоновые процессы записи завершены,
waitвозвращает управлениеКонтейнер останавливается (
restart_policy: condition: noneпредотвращает перезапуск)Выполнить
docker execв остановленный контейнер невозможно
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 Secrets | Нет | Нет | Да (read-only) | Нет |
| — | — | — | Read-only FS |
uid/gid/mode в секрете | Частично | Нет | Да | Нет |
FIFO (named pipes) | Да | Да | Нет | Нет |
Vault / Secret Manager | Да | Да | Нет | Да |
Заключение
Именованные каналы — это элегантное решение, которое использует фундаментальное свойство FIFO в Linux: данные потребляются при чтении. В сочетании с паттерном двух контейнеров (инжектор + приложение) это позволяет:
Полностью исключить наличие секретов в файловой системе контейнера приложения
Гарантировать одноразовое чтение без внешних зависимостей
Работать в рамках стандартного Docker Swarm без Vault и дополнительной инфраструктуры
Это не замена полноценному менеджеру секретов для высоконагруженных production-систем. Но для команд, которые уже используют Docker Swarm и хотят значительно повысить безопасность секретов без усложнения стека, — это практичный и работающий подход.
