📱 Подписаться
IT и цифровая трансформация

Секреты Docker Swarm: как сделать их одноразовыми с помощью именованных каналов (FIFO)

📰 Habr 👁️ 7 просмотров

baldr13 часов назад

Секреты Docker Swarm: как сделать их одноразовыми с помощью именованных каналов (FIFO)

Уровень сложностиСложныйВремя на прочтение9 минОхват и читатели6.6KIT-инфраструктура*DevOps*Информационная безопасность*Распределённые системы*Системное администрирование*Туториал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 systemDocker монтирует /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 execЗащита от exec -u rootСекреты на дискеДоп. инфраструктураСтандартные Docker SecretsНетНетДа (read-only)Нетchmod/truncate———Read-only FSuid/gid/mode в секретеЧастичноНетДаНетFIFO (named pipes)ДаДаНетНетVault / Secret ManagerДаДаНетДа

Заключение

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

• Полностью исключить наличие секретов в файловой системе контейнера приложения
• Гарантировать одноразовое чтение без внешних зависимостей
• Работать в рамках стандартного Docker Swarm без Vault и дополнительной инфраструктурыЭто не замена полноценному менеджеру секретов для высоконагруженных production-систем. Но для команд, которые уже используют Docker Swarm и хотят значительно повысить безопасность секретов без усложнения стека, — это практичный и работающий подход.Теги:• docker swarm
• docker
• docker secretsХабы:• IT-инфраструктура
• DevOps
• Информационная безопасность
• Распределённые системы
• Системное администрирование

Получайте больше инсайтов о систематизации бизнеса

Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал