Симптом: приложение уверенно работает в development, но после выпуска либо получает не тот URL, либо оставляет credential в Docker image, console output или диагностическом объекте. Цена двойная. Сервис может стать недоступен из-за пустой переменной, а затем команда, пытаясь быстро увидеть конфигурацию, сама расширяет поверхность утечки. В обоих случаях проблема начинается раньше runtime — в пути, которым значение дошло до процесса.
Причина — смешение носителей. Репозиторий, build context, image, job log, переменная процесса и система журналирования имеют разные сроки жизни и разные аудитории. Называть их одним словом «env» недостаточно. Для M3-практики 2020 года полезнее нарисовать маршрут значения, указать допустимую остановку на каждой границе и не делать вид, что любой токен уже обслуживает современная secrets-платформа.
У каждого носителя свой срок жизни
| Носитель | Кто обычно видит | Как долго живёт | Допустимое содержимое | Типовая ошибка |
|---|---|---|---|---|
| Репозиторий и шаблон | Разработчики и все клоны | История commit | Имена, документация, фиктивные defaults | Реальный token в .env или fixture |
| Build context и job | Сборщик, логи CI, cache | До очистки job и cache policy | Исходники и безопасные параметры сборки | Печать окружения или передача credential в командной строке |
| Docker image | Registry и тот, кто запускает image | Пока образ хранится | Код и несекретные runtime defaults | Секрет в ENV или ARG Dockerfile |
| Runtime process | Процесс и ограниченный контур запуска | До restart или смены значения | Нужные приложению настройки и credential | Общий dump process.env в error |
| Логи и issue | Поддержка, мониторинг, участники incident | По retention policy | Имена, request ID, redacted поля | Копирование значения «для расследования» |
Из таблицы следует важное ограничение: переменная окружения — способ передать значение процессу, а не доказательство, что оно скрыто. У процесса может быть много читателей: библиотека логирования, crash handler, shell wrapper, дочерняя команда. Секрет становится уязвимым не в момент чтения process.env, а в момент, когда его копируют в более широкий носитель без необходимости.
Сборка и запуск отвечают на разные вопросы
Сборка должна собрать воспроизводимый кодовый артефакт; запуск должен подать конфигурацию конкретного контура. Когда эти задачи склеены, production URL или token пытаются передать как build argument, записать в сгенерированный JavaScript либо положить в Dockerfile. В результате один и тот же credential начинает жить столько же, сколько image, хотя нужен только работающему процессу.
Docker различает ENV и ARG, но это не повод использовать любой из них как тайник для credentials. ENV сохраняет значение для контейнеров, созданных из image. Документация Docker также предупреждает не передавать credentials и API tokens через build arguments: история и метаданные могут стать лишней поверхностью. В феврале 2020 года практическое правило проще технологии: секрет не появляется в Dockerfile, а delivery подаёт его после выбора image.
FROM node:12-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
ENV APP_ENV=production
CMD ["node", "server.js"]
# Токен не объявлен через ARG или ENV в Dockerfile.
# Контур запуска передаёт его процессу отдельно от образа.
Этот Dockerfile нарочно бедный: в нём есть безопасный runtime default APP_ENV, но нет URL платежей, пароля базы или токена. Конкретный контур запуска может передать нужные значения через свой ограниченный канал. Пример не утверждает, что контейнер уже защищён, и не показывает production-команду. Он фиксирует границу: image не обязан знать credential, чтобы сервис смог начать работу.
Loader превращает набор строк в контракт
После delivery процесс получает строки. Без loader значения начинают жить в произвольных модулях: handler подставляет fallback, worker читает другое имя, а диагностическая ветка сериализует весь объект окружения. Центральная загрузка не решает права доступа, зато делает два свойства явными: какие ключи обязательны и какие из них нельзя показать в отчёте.
const required = ["APP_ENV", "PAYMENTS_API_URL", "PAYMENTS_TOKEN"];
function readRequired(name, env = process.env) {
const value = env[name];
if (!value) throw new Error("Missing required setting: " + name);
return value;
}
export function loadConfig(env = process.env) {
for (const name of required) readRequired(name, env);
return {
appEnv: env.APP_ENV,
paymentsApiUrl: env.PAYMENTS_API_URL,
paymentsToken: env.PAYMENTS_TOKEN,
logLevel: env.LOG_LEVEL || "info",
};
}
export function safeConfigReport(config) {
return { appEnv: config.appEnv, paymentsApiUrl: config.paymentsApiUrl,
paymentsToken: "[REDACTED]", logLevel: config.logLevel };
}
Проверка loader не требует реального deployment. Для unit fixture передаём объект с APP_ENV=staging, PAYMENTS_API_URL=https://gateway.invalid и фиктивным PAYMENTS_TOKEN. Затем убираем один обязательный ключ и ожидаем ошибку с его именем; отдельным test проверяем, что safe report возвращает маску. Так backend получает контракт до сетевого вызова, а delivery знает, какие названия нельзя потерять при переносе job.
Приоритет переменных — отдельный источник расхождений
Compose и shell умеют брать значения из нескольких мест: файла, окружения вызывающего процесса, атрибутов configuration и параметров запуска. Поэтому сообщение «у нас есть .env» не отвечает на вопрос, какое значение получит контейнер. Сначала нужно зафиксировать источник для каждого класса переменных, затем посмотреть итоговую конфигурацию без раскрытия values и лишь после этого разбираться с кодом.
Для непубличного URL или token я не предлагаю выводить итоговую строку в CI. Достаточно проверить наличие обязательного имени, identifier набора и факт, что runtime прошёл валидацию. Если команда действительно должна сравнить значение между средами, ей нужен отдельный безопасный способ сопоставления, а не printenv в общем логе. Такой запрет неудобен ровно до первого incident; затем он экономит время всем участникам.
Короткая диагностика по границам
- Назвать наблюдаемый сбой: пустой ключ, неправильный URL, credential в image или value в логе. Не начинать с одновременного изменения кода и job.
- Проверить repository boundary: шаблон содержит только имена и фиктивные defaults, а локальные файлы не tracked. Если credential уже в истории, переключиться на маршрут ротации, а не на обычный cleanup.
- Проверить build boundary: Dockerfile, scripts и логи job не получают токен как аргумент, не печатают полный env и не записывают value в generated bundle.
- Проверить delivery boundary: у каждого обязательного имени указан источник и владелец; лог выпуска содержит только безопасный revision или идентификатор.
- Проверить runtime boundary: loader отвергает отсутствующее значение до первого запроса, а safe report маскирует секретные поля.
- Проверить support boundary: error serializer, HTTP logger и issue template не копируют headers, env или конфигурационный объект целиком.
- Только после этих проверок менять fallback или retry. Иначе технический симптом скроет неверную поставку конфигурации.
Что проверяет образ, а что проверяет выпуск
Образ проверяют на отсутствие секретов и на то, что он несёт код, зависимости и несекретные defaults. Выпуск проверяют на другой контракт: конкретный контур передал обязательные значения, приложение не раскрывает их при старте и зависимый сценарий прошёл с нужной конфигурацией. Эти проверки связаны, но не взаимозаменяемы. Чистый image не доказывает, что процесс получил верный URL; успешный запрос не доказывает, что token не остался в history.
Иногда после такой проверки остаётся вопрос: где именно хранить credential, кто выдаёт доступ и как вести audit. Это правильный следующий вопрос, но он шире одного loader или Dockerfile. Автономный пакет не выбирает ответ вместо команды. Он оставляет минимальную техническую поверхность, на которой любой выбранный механизм можно проверить: секрет не в Git, не в build output, не в image по умолчанию и не в diagnostics.
Ограничение: маска не отменяет доступ
Редакция логов предотвращает случайное распространение, но не отменяет права того, кто уже может читать runtime environment или deployment host. Ignore-файл защищает новый локальный путь, но не отзывают старое значение. Loader делает недостающую настройку видимой, но не создаёт credential. Поэтому результат статьи — не обещание «секреты решены», а маршрут для узкой проверки: обнаружить носитель, назвать владельца, сократить копии и подготовить ротацию для случая утечки.
Проверяемые источники
- Docker Docs: Dockerfile reference — ENV сохраняется в образе и доступен контейнеру; ARG не следует считать местом для credentials или токенов
- Docker Docs: environment variables in Compose — документация разделяет переменные контейнера, интерполяцию Compose и их приоритет
- Node.js v12: process.env — Node читает окружение процесса через process.env; это вход runtime, а не схема валидации сама по себе
- Git: gitignore documentation — игнорируются только намеренно неотслеживаемые пути; уже tracked файл правило не убирает из индекса