1. Секреты в Docker: не паранойя, а риск
Когда приложение работает локально (через bootRun) или даже на вашей машине «просто в jar», у вас есть иллюзия приватности. Конфиг лежит где-то рядом, пароль написан в заметках, и кажется: «ну это же мой ноутбук, что случится?». С Docker эта иллюзия ломается очень быстро, потому что Docker делает одну простую вещь: превращает вашу сборку в артефакт, который удобно переносить, копировать, пушить в registry и отдавать другим людям. И именно поэтому любые «случайно попавшие» внутрь этого артефакта секреты становятся не просто ошибкой, а ошибкой с эффектом размножения.
В контейнерном мире образ — это почти как чемодан, который вы отправляете в путешествие по разным средам. И если вы однажды положили туда паспорт (секрет), то он может «путешествовать» дальше без вашего ведома: в кешах сборки, в layers, в истории образа, в чужой машине после docker pull, а иногда и в скриншоте из Jira, потому что кто-то выложил docker inspect в тикет.
Здесь полезно держать один короткий термин: baked secret. Это чувствительное значение, которое «запеклось» (то есть стало частью) Docker image или committed артефактов репозитория. Оно перестало быть «входным параметром запуска» и превратилось в «часть поставки». И это почти всегда неверно для паролей, токенов, ключей и любых credential’ов.
Чтобы закрепить причинно‑следственную связь, полезно увидеть её как короткую схему:
flowchart TD A["Секрет попал в Dockerfile / файл в репозитории"] --> B["Секрет оказался в слое образа или в истории"] B --> C["Образ/репозиторий попал в чужие руки (registry, коллега, CI, кеш, тикет)"] C --> D["Секрет прочитали без взлома — просто потому что он там лежал"]
Обратите внимание: тут нет хакеров. Тут есть только плохая привычка «ну пусть будет в Dockerfile, так удобнее».
2. Что считать секретом в Catalog Service
Когда мы говорим «секреты», новичку легко представить что-то пафосное: «API-ключ к банку» или «токен от продакшена». А потом он спокойно коммитит пароль от PostgreSQL в compose.yaml, потому что «ну это же локально». На практике полезнее начать с более приземлённого вопроса: какие параметры в нашем проекте чувствительные, а какие — нет, и почему это важно даже в учебной среде.
В Container-Ready Catalog Service конфигурация делится на две большие категории. Первая — обычные runtime-параметры, которые не страшно менять и не страшно показывать. Это порт, активные профили, путь для экспорта, уровни логирования, включение actuator endpoints. Вторая — значения, которые дают доступ или повышают риск: пароли, токены, ключи, иногда connection strings, если в них есть пароль.
Удобно держать это в виде таблицы. Она не заменяет «настоящий security», но отлично дисциплинирует мышление:
| Пример параметра | Тип | Почему |
|---|---|---|
| SERVER_PORT=8080 | обычная конфигурация | не даёт доступ ни к чему, просто меняет порт |
| SPRING_PROFILES_ACTIVE=postgres,cache | обычная конфигурация | описывает режим работы, не раскрывает credential’ы |
| APP_EXPORT_DIR=/app/data/exports | обычная конфигурация | путь сам по себе не секрет, но важен для прав и mounts |
| SPRING_DATASOURCE_URL=jdbc:postgresql: //postgres:5432/catalog | условно обычная | сам URL не секрет, но иногда в него «запихивают» пароль — и вот тогда это становится секретом |
| SPRING_DATASOURCE_USERNAME=catalog | условно обычная | логин иногда считают не секретом, но лучше не разбрасываться (и уж точно не хранить рядом с паролем в одном файле, который коммитится) |
| SPRING_DATASOURCE_PASSWORD=... | секрет | даёт доступ к базе |
| SPRING_RABBITMQ_PASSWORD=... | секрет | даёт доступ к брокеру |
| API_KEY | секрет | почти всегда даёт прямой доступ или возможность выдавать себя за сервис |
| TOKEN | секрет | почти всегда даёт прямой доступ или возможность выдавать себя за сервис |
| PRIVATE_KEY | секрет | почти всегда даёт прямой доступ или возможность выдавать себя за сервис |
Тут важная мысль: даже если вы используете «смешной пароль» в локальной среде (postgres/postgres), дисциплина всё равно должна быть правильной. Учебный проект — это место, где вы формируете привычки. А привычки очень плохо умеют «переключаться» по кнопке “prod mode: ON”.
И ещё одна практическая граница: секрет — это не только пароль. Иногда секретом становится «слишком умный конфиг», например строка подключения вида jdbc:postgresql://.../db?user=...&password=.... Она выглядит как «просто URL», но внутри уже всё самое интересное.
3. Baked secret: где секрет «запекается»
Самая неприятная особенность baked secrets в Docker в том, что они часто попадают туда «случайно», а затем их сложно вычистить так, чтобы точно не осталось следов. Docker image собирается слоями, а слой — это как снимок файловой системы на момент шага Dockerfile. Если в одном из ранних слоёв секрет появился, а вы потом его удалили, это не значит, что секрет исчез из истории. Он всё ещё может жить в старом слое.
Начнём с самого очевидного анти‑паттерна: ENV с паролем прямо в Dockerfile.
# ПЛОХО: пароль становится частью образа (и его истории)
# Важно: это значение уедет в метаданные образа и будет видно через docker inspect
ENV SPRING_DATASOURCE_PASSWORD=super-secret
Это «плохо» не из-за морали, а потому что значение попадает в метаданные образа. И любой, у кого есть образ, может его прочитать через docker image inspect. То есть вы буквально положили пароль в конверт с надписью “пароль внутри”, и этот конверт начали передавать по офису.
Чуть более хитрый, но не менее опасный вариант — «ну я же не в ENV, я в ARG, это типа build-time»:
# ТОЖЕ ПЛОХО: многие думают, что ARG безопаснее, но секрет всё равно светится
# Проблема: build args могут утечь в логи сборки и кеши
ARG DB_PASSWORD
# Важно: как только вы превращаете ARG в ENV — секрет уже в финальном образе
ENV SPRING_DATASOURCE_PASSWORD=$DB_PASSWORD
ARG действительно живёт на build-time, но как только вы сделали из него ENV, секрет уже в финальном образе. Более того, даже если вы не превратили ARG в ENV, значение может утечь через build-логи, кеши и сам процесс сборки. В рамках курса для Junior‑уровня проще запомнить короткое правило: секреты не должны приходить на build, они приходят на runtime.
Ещё более «коварный» сценарий — копирование файла с секретом и последующее удаление:
# ПЛОХО: даже если потом удалить файл, он останется в предыдущем слое
COPY secrets/application-secrets.yml /tmp/application-secrets.yml
RUN rm /tmp/application-secrets.yml
На глаз кажется: «всё удалили, в контейнере файла нет». Но образ состоит из слоёв, и ранний слой с секретом никуда не делся. Поэтому правильная стратегия не «копировать и удалить», а вообще не копировать секреты в image.
Теперь репозиторий и Compose. Очень типичный baked secret — это пароль, закоммиченный в compose.yaml:
services:
app:
environment:
SPRING_DATASOURCE_PASSWORD: postgres # ПЛОХО: уезжает в git
Даже если репозиторий приватный, это всё равно плохая привычка. Репозитории мигрируют, люди уходят, доступы расширяются, а секрет остаётся жить «вечно». Плюс, если кто-то делает code review или копирует фрагмент в чат, секрет утечёт банально через копипаст.
И последняя «неочевидная» точка: логи. Иногда разработчик радостно печатает конфигурацию на старте, чтобы «убедиться, что всё подхватилось». Если он печатает SPRING_DATASOURCE_URL — это ещё туда-сюда. Если он печатает пароль — поздравляю, секрет теперь живёт в docker logs, а значит и в любых системах, куда эти логи могут попасть (даже если это “просто локально”).
4. Базовый подход: секреты на runtime
После страшилок нужна простая, практическая модель, чтобы студент мог действовать, а не бояться. Для Junior уровня самый понятный baseline такой: секреты передаются контейнеру только во время запуска, а в репозитории остаются только “плейсхолдеры” и примеры.
Если вы запускаете одиночный контейнер через docker run, это выглядит так:
# -e ...: секрет передаётся на runtime (он не попадает в образ)
# -p ...: пробрасываем порт наружу
docker run --rm --name catalog-app \
-e SPRING_DATASOURCE_PASSWORD=postgres \
-p 8080:8080 \
catalog-service:local
Важный нюанс: да, env var видно через docker inspect и иногда можно увидеть внутри контейнера. Это не “идеальная безопасность”, но это уже огромный шаг вперёд по сравнению с baked secret. Секрет не лежит в образе, не уезжает в registry и не живёт в слоях.
Для Compose базовая «чистая» модель выглядит так: в YAML вы не пишете значение, вы пишете ссылку на значение, которое придёт извне.
services:
app:
environment:
# Значение подставится из окружения или из локального .env (который не коммитится)
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
SPRING_RABBITMQ_PASSWORD: ${SPRING_RABBITMQ_PASSWORD}
То есть compose.yaml остаётся переносимым и “reusable”, а секреты приходят из окружения (или из локального .env, который не коммитится). И вот здесь появляется практическая дисциплина двух файлов:
.
├── .env.example # коммитим: шаблон, без реальных секретов
└── .env # НЕ коммитим: реальные значения у каждого разработчика свои
Пример .env.example (обратите внимание: это именно пример, без настоящих значений):
# Шаблон: реальные пароли сюда не кладём
SPRING_DATASOURCE_PASSWORD=change-me
SPRING_RABBITMQ_PASSWORD=change-me
Смысл .env.example не в том, чтобы “дать пароль”, а в том, чтобы дать названия переменных, которые нужны проекту. Это существенно облегчает onboarding: новый разработчик не гадает, что вообще нужно передать контейнеру.
Иногда хочется ещё жёстче: если переменная не задана, пусть Compose не стартует. Тогда можно использовать форму с обязательностью:
services:
app:
environment:
# Fail fast: без переменной запуск сразу упадёт с понятным сообщением
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?set it in .env}
Это хороший “fail fast”: лучше упасть сразу с понятной подсказкой, чем получить странный org.postgresql.util.PSQLException и потом двадцать минут искать, где вы забыли пароль.
И да, маленькая самоирония: .env — это не сейф. Это скорее «листочек с паролем, который лежит в вашем ящике стола, а не приклеен к двери подъезда». В учебном проекте это допустимый baseline, если .env не попадает в git, и вы не делаете скриншот терминала с этим файлом для публичного поста.
5. Секреты файлами: mount и :ro
Env vars — хороший старт, но у них есть две неудобные особенности. Первая: их легко случайно напечатать в логи или вывести при диагностике (особенно если разработчик любит “dump everything”). Вторая: иногда секрет уже существует в виде файла (например, вы получили его как файл в защищённой директории, или так устроено окружение). Поэтому во взрослом Docker-мире часто живёт идея: секреты передаются как файлы, которые монтируются в контейнер только на чтение.
5.1. Mounted external config file для Spring Boot без пересборки image
Самый естественный для Spring Boot путь — использовать внешний конфиг, который не лежит внутри jar и не копируется в image. Его можно смонтировать как read-only, а Boot попросить читать дополнительную локацию конфигурации.
Фрагмент compose.yaml:
services:
app:
environment:
# Spring Boot будет искать дополнительные конфиги в /config (и это опционально)
SPRING_CONFIG_ADDITIONAL_LOCATION: optional:file:/config/
volumes:
# Важно: секреты монтируем read-only, чтобы контейнер не мог их переписать
- ./config/application-secrets.yml:/config/application-secrets.yml:ro
А файл config/application-secrets.yml (этот файл не коммитим, а рядом держим application-secrets.yml.example):
spring:
datasource:
# Секрет хранится вне образа: файл подмонтируется на runtime
password: postgres
rabbitmq:
# То же самое: значение не «запекается» в image
password: rabbit
Плюс этого подхода в том, что секреты остаются за пределами образа, и при этом они не обязаны быть env vars. Минус в том, что нужно аккуратно следить за путями и правами чтения. И тут красиво связываются сегодняшние лекции: если контейнер запускается от non-root пользователя, он должен иметь право читать этот файл. Обычно read permission (644 на host) достаточно, но иногда на разных OS/настройках Docker Desktop можно увидеть сюрпризы. Важно, что сюрпризы будут выглядеть как permission error, а не как “сломался Spring”.
Здесь очень полезна дисциплина :ro. Она не делает конфиг «секьюрным», но делает намерение очевидным: контейнер не должен переписывать ваш файл с секретами. Он должен только читать его.
5.2. Read-only mount — это про роль файла
Ещё раз проговорю, потому что это частый «мозговой сдвиг»: :ro нужен не потому, что он скрывает секрет (он не скрывает), а потому что он фиксирует роль. Если файл — источник конфигурации, он должен быть read-only. Если каталог — место, куда приложение пишет (например экспорт), он должен быть writable. Если перепутать роли, начинаются очень странные проблемы: приложение пытается писать туда, где нельзя, или наоборот пытается менять конфиг “на лету”.
Теперь про _FILE‑style паттерн. Его идея проста: вместо PASSWORD=... вы задаёте PASSWORD_FILE=/path/to/file, а процесс читает секрет из файла при старте.
Это часто встречается в официальных Docker image, и это удобно показать на знакомой инфраструктуре — например, на PostgreSQL. Многие образы умеют принимать пароль так:
services:
postgres:
image: postgres:16
environment:
# Пароль берётся из файла внутри контейнера (а не строкой в YAML)
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
volumes:
# Файл с секретом подмонтирован read-only
- ./secrets/postgres_password.txt:/run/secrets/postgres_password:ro
Здесь пароль не светится как строка в YAML и не вынужден жить в env var. Он живёт в файле, который вы монтируете. Это всё ещё не «корпоративная система секретов», но это уже дисциплина, которая масштабируется на разные окружения.
А что со Spring Boot? У Spring Boot “из коробки” нет универсального автоматического правила вида “если у свойства есть *_FILE, прочитай”. Поэтому на Junior уровне мы обычно выбираем один из двух простых вариантов: либо env vars (и просто следим, чтобы не коммитить и не логировать), либо внешний application-secrets.yml, который монтируется read-only. Оба варианта укладываются в философию курса: same image, different runtime config. Мы меняем входные данные запуска, а не пересобираем образ ради нового пароля.
6. Утечки через диагностику и логи
Очень легко сделать всё правильно в Dockerfile и Compose, а потом испортить всё одной строкой в логах. В контейнерном мире диагностика обычно начинается с docker logs, docker inspect и docker compose config. Эти инструменты полезны, но у них есть неприятное свойство: они без стеснения показывают конфигурацию.
Если вы запускаете контейнер с SPRING_DATASOURCE_PASSWORD, то docker inspect может показать его как часть env. Если вы запускаете Compose и подставляете секреты из .env, то docker compose config может вывести уже “развёрнутую” конфигурацию. Это не значит, что эти инструменты плохие. Это значит, что нужно аккуратно относиться к тому, что вы копируете в чат/в тикет/в скриншот.
Ещё одна классика — “давайте включим побольше actuator endpoints”. Напомню, что курс не про security, но базовая осторожность нужна: не стоит включать то, что вы не понимаете и не контролируете, особенно в публично доступной среде. В нашем проекте actuator нужен как operational contract (health/info/metrics), а не как “давайте откроем всё”. И точно не надо делать так, чтобы какой-нибудь endpoint случайно показывал вам конфигурацию целиком.
Наконец, банальный, но важный момент: если вы пишете лог “Active profiles: postgres,cache”, это нормально и полезно. Если вы пишете лог “Datasource password: ...” — это уже не лог, а публичное объявление. И Docker тут ни при чём: вы сами распечатали секрет.
7. Типичные ошибки при работе с секретами
Ошибки с секретами почти всегда происходят не из-за злого умысла, а из-за желания “сделать быстрее”. Поэтому лучше относиться к ним как к типовым граблям: наступил, понял, больше не наступаешь.
Ошибка №1: пароль в Dockerfile через ENV, а потом «уберу».
Это выглядит как быстрый хак, но он формирует вредную привычку и часто заканчивается тем, что “потом” никогда не наступает. Плюс, даже если вы удалили строку и пересобрали образ, старый образ мог остаться в кеше, в registry или на машине коллеги. В итоге секрет успел пожить “в артефактах” дольше, чем вы планировали.
Ошибка №2: использовать ARG как “секретный канал”.
Интуитивно кажется, что ARG лучше, потому что он “не runtime”. Но реальность такая: build-процесс часто логируется, кешируется, реплицируется, а значения build args могут утекать в вывод и в инфраструктуру сборки. Для курса проще принять правило: секреты — это runtime input, а не build input.
Ошибка №3: коммит .env с настоящими значениями.
Это случается даже у опытных людей, потому что .env кажется “техническим файлом”. Потом репозиторий уезжает в другое место, доступы расширяются, и внезапно оказывается, что пароль «просто лежал рядом». Правильная модель: коммитим .env.example, а .env держим локально и добавляем в .gitignore.
Ошибка №4: “спрятать” секрет в application.yml внутри src/main/resources.
Поначалу кажется удобно: “Boot всё подхватит”. Но это как положить ключи от квартиры в копию ключей, которую вы раздаёте всем. Внутренний application.yml попадает в jar, jar попадает в image, image разлетается. Секрет становится baked secret просто потому, что вы смешали конфиг приложения и секреты в одном месте.
Ошибка №5: монтировать файл с секретом как writable и потом удивляться, что его кто-то изменил.
Если файл — источник конфигурации, он должен быть read-only. Это не “security magic”, это дисциплина роли. Иначе появляются странные сценарии, когда приложение или случайный скрипт внутри контейнера может перезаписать конфиг, и вы потом долго ищете “кто поменял пароль”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ