Секреты и образы
Этот урок — про то, как не утечь токеном в production. Это тема, которую Junior часто упускает, а последствия — реальные. Утечки токенов в публичных Docker-образах — обычная история: за месяц на Docker Hub автоматические сканеры находят тысячи образов с AWS credentials, GitHub PAT, Slack webhook’ами.
Три уровня проблемы: build-time (секреты для сборки, например токен для приватного pip-репо), runtime (секреты для работы приложения, например DATABASE_URL с паролем), на хосте (где хранится .env, как доставляется до контейнера).
Secret rotation и external secret managers
Главное правило: НЕ COPY секреты в образ
Самая частая ошибка Junior — добавить .env в образ:
# DANGEROUS:
FROM python:3.13-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python", "main.py"]
И .env в /app/.env лежит с AWS_SECRET_KEY=AKIA..., DATABASE_PASSWORD=prod123. Когда образ пушится в registry — все, у кого есть pull-доступ, видят секреты. Если это публичный Docker Hub — весь интернет.
Почему просто RUN rm .env после COPY не помогает. Каждый слой образа сохраняется отдельно. COPY . создал слой, в котором .env есть. RUN rm .env создал новый слой, в котором .env нет. Но слой с .env всё ещё в образе — кто угодно может его извлечь через docker history или docker image save.
Никогда не COPY .env в образ. Никогда не пиши секреты в ENV инструкцию. Никогда не делай ARG SECRET=... (build args тоже сохраняются в metadata). Утечка через образ — это compromise всех окружений, где этот образ запускался.
Минимум — .dockerignore:
.env
.env.*
*.key
*.pem
.git
__pycache__
Это исключит файлы из контекста сборки — COPY . физически не сможет их захватить.
Демонстрация утечки
mkdir leak-test && cd leak-test
echo "AWS_SECRET_KEY=AKIA1234" > .env
echo "FROM alpine
COPY . /app
RUN rm /app/.env" > Dockerfile
docker build -t leaked .
docker history --no-trunc leaked
Вывод:
CREATED BY SIZE
RUN /bin/sh -c rm /app/.env # buildkit 0B
COPY . /app # buildkit 34B <-- здесь .env
И финальное доказательство:
docker save leaked | tar -x --to-stdout '*/layer.tar' | tar -tv
# найдёшь app/.env в одном из слоёв
.env в слое доступен. RM из следующего слоя не удаляет файл из истории.
Build-time secrets: BuildKit --mount=type=secret
Иногда секрет нужен только во время сборки: токен для приватного pip-репозитория, GitHub PAT для git clone приватного репо, AWS credentials для скачивания artifact’а.
Способ старый и плохой — ARG:
# BAD
ARG PIP_INDEX_URL
RUN pip install --index-url=$PIP_INDEX_URL -r requirements.txt
Build args остаются в metadata образа. docker inspect | grep -i index покажет URL.
Правильный способ — BuildKit --mount=type=secret:
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim
COPY requirements.txt .
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL="https://__token__:$(cat /run/secrets/pip_token)@private-pip.company.com/" \
pip install -r requirements.txt
Запуск:
DOCKER_BUILDKIT=1 docker build \
--secret id=pip_token,src=./pip_token.txt \
-t my-image .
Что происходит:
--secret id=pip_token,src=./pip_token.txt— Docker монтирует файлpip_token.txtс хоста как/run/secrets/pip_tokenтолько на время выполнения этого RUN.- RUN читает токен из
/run/secrets/pip_token, использует для pip install. - После окончания RUN — mount размонтируется, файл не попадает в слой образа.
Результат: токен использован, но не утёк в финальный образ.
docker history my-image
# Никакого упоминания токена
В env-варианте:
echo "$PIP_TOKEN" | docker build --secret id=pip_token,src=- -t my-image .
src=- — читать секрет из stdin (труба echo). Удобно в CI, где $PIP_TOKEN — env-переменная.
SSH-секреты для git clone
Аналогичная проблема — нужен git clone приватного репо во время сборки. SSH-ключ нельзя COPY.
BuildKit поддерживает --mount=type=ssh:
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends git openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
RUN --mount=type=ssh \
git clone [email protected]:org/private-repo.git /app/private
docker build --ssh default=$HOME/.ssh/id_rsa -t my-image .
--ssh default пробрасывает SSH-agent из хоста. Ключ не попадает в образ, но git clone работает.
Runtime secrets через env vars
Самый простой и распространённый способ — env-переменные. Контейнер при запуске получает DATABASE_URL, API_TOKEN через -e:
docker run -d \
-e DATABASE_URL="postgresql://de:secret@postgres:5432/warehouse" \
-e API_TOKEN="$(cat ~/.api_token)" \
my-image
Внутри приложение читает os.getenv("DATABASE_URL"). Секрет в образе НЕ хранится — только в окружении запущенного контейнера.
Минусы:
docker inspect <c>показывает все env-переменные. Любой, у кого есть доступ к Docker daemon на хосте — видит секрет.ps auxeтоже показывает env процесса (на старых ядрах). На современных —cat /proc/<pid>/environ(только от root или того же UID).
Плюсы:
- Просто. Работает везде.
- Секрет привязан к окружению — для prod-образа в dev-окружении секрета нет.
В compose:
services:
app:
image: my-app
environment:
DATABASE_URL: "postgresql://de:${DB_PASSWORD}@postgres:5432/warehouse"
env_file:
- .env.production
.env.production — НЕ в git (в .gitignore), хранится отдельно (1Password, sealed-secrets, vault).
Compose secrets (file mounts)
Compose поддерживает secrets: — файловые секреты, монтируются как файлы внутри контейнера, не как env-переменные:
services:
app:
image: my-app
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Внутри контейнера секрет доступен как /run/secrets/db_password. Приложение читает файл:
with open("/run/secrets/db_password") as f:
password = f.read().strip()
Это лучше env-переменных, потому что:
- Файл с правами
0400(только root внутри контейнера). - Не виден в
docker inspect. - Не виден в
/proc/<pid>/environ.
Для critical-секретов (DB password, encryption key) предпочитай file-mount.
Vault и sealed-secrets
Для production-стенда (не локальный compose) Docker secrets — это компромисс. Production-grade:
- HashiCorp Vault — централизованное хранилище секретов с auth, audit, rotation. Контейнер при запуске запрашивает свой секрет у Vault через сетевой API.
- Bitnami Sealed Secrets / SOPS — для Kubernetes: секреты в git как encrypted blob, расшифровываются только в кластере.
- AWS Secrets Manager / GCP Secret Manager / Azure Key Vault — облачные сервисы. Удобно, если уже в облаке.
- infisical — open-source аналог Vault с UI, удобный для команд.
Паттерн: при старте контейнера sidecar или init-контейнер дёргает Vault, получает секрет, кладёт в shared volume или env. Приложение читает оттуда.
services:
vault-agent:
image: hashicorp/vault:1.16
command: ["vault", "agent", "-config=/etc/vault/agent.hcl"]
volumes:
- secrets:/secrets
app:
image: my-app
depends_on:
vault-agent:
condition: service_started
volumes:
- secrets:/run/secrets:ro
environment:
DATABASE_URL_FILE: /run/secrets/db_url
volumes:
secrets:
Это сложнее env-переменных, но обеспечивает rotation (Vault регенерит секреты, vault-agent подхватывает), audit (кто и когда читал секрет), least-privilege (каждое приложение видит только свои секреты).
Сканирование образов на секреты
Чтобы автоматически ловить случайные утечки, есть инструменты:
- trivy (с флагом
--scanners secret):trivy image --scanners secret my-image. Ищет токены AWS, GitHub, Slack, Stripe и т.д. по паттернам. - trufflehog:
trufflehog docker --image my-image. - gitleaks: для git-репо, но работает и на файлах.
Trivy-пример:
trivy image --scanners secret my-image
# AWS Access Key ID found in /app/.env (CVE-mock-aws-key)
В CI:
- name: Scan for secrets in image
run: trivy image --scanners secret --exit-code 1 my-image:${{ github.sha }}
PR с утёкшим секретом не пройдёт CI.
Полный production-ready Dockerfile
Сводим уроки модуля 15 вместе. Шаблон Python ETL:
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc=4:12.* \
libpq-dev=15.* \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN --mount=type=secret,id=pip_token \
pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.13-slim AS runtime
RUN useradd -m -u 1000 app
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index /wheels/*.whl && rm -rf /wheels
COPY --chown=app:app . .
USER app
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import sys; sys.exit(0)"
CMD ["python", "main.py"]
Чек-лист:
- pin’ы версий — DL3008 OK.
rm /var/lib/apt/lists/*— DL3009 OK.- multi-stage — финальный образ без gcc и build deps.
--mount=type=secretдля приватного pip-репо — секрет не утёк.useradd 1000 app+--chown+USER app— non-root.HEALTHCHECK— задан.- Никакого
COPY .env, никакогоENV PASSWORD.
Попробуй сам
- Создай Dockerfile с
COPY .и.envфайлом с фейковымAWS_KEY=AKIAFAKE. Собери. Запустиtrivy image --scanners secret <image>. Должен найти утечку. - Добавь
.dockerignoreс.env, пересобери. Перепроверь — утечки нет. - Попробуй BuildKit secret. Создай
token.txtс любым текстом. В Dockerfile:
Собери:# syntax=docker/dockerfile:1.7 FROM alpine RUN --mount=type=secret,id=mytoken cat /run/secrets/mytoken
В логах сборки увидишь содержимое токена (это RUN cat). СделайDOCKER_BUILDKIT=1 docker build --secret id=mytoken,src=./token.txt -t test .docker history testи проверь, что токена в слоях нет. - Создай compose с secrets:
Запустиservices: app: image: alpine command: cat /run/secrets/db_password secrets: - db_password secrets: db_password: file: ./db_password.txtdocker compose up. Проверь, что приложение прочитало секрет, ноdocker inspect appего не показывает. - Прогони
trivy image --scanners secretна каком-нибудь своём старом образе — посмотри, нет ли утечек, которые ты пропустил.