Learning Platform
Глоссарий Troubleshooting
Урок 16.05 · 24 мин
Средний
dockersecuritysecretsbuildkit

Секреты и образы

Этот урок — про то, как не утечь токеном в 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.

DANGER

Никогда не 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 .

Что происходит:

  1. --secret id=pip_token,src=./pip_token.txt — Docker монтирует файл pip_token.txt с хоста как /run/secrets/pip_token только на время выполнения этого RUN.
  2. RUN читает токен из /run/secrets/pip_token, использует для pip install.
  3. После окончания 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-переменная.

BuildKit secret vs ARG
ARG PIP_TOKENСтарый способ. Build args записываются в metadata образа. docker inspect показывает их
токен в metadataЛюбой с pull-доступом видит через docker inspect или скачав образ
--mount=type=secretBuildKit монтирует файл-секрет только на время RUN, не пишется в слой
никаких следов в образеПосле RUN секрет исчез, в слоях его нет, в metadata нет

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 (каждое приложение видит только свои секреты).

Уровни управления секретами
ENV в DockerfileENV PASSWORD=secret — в metadata, читается любым docker inspect. НЕ ДЕЛАЙ
COPY .env в образСлой с .env остаётся в образе навсегда, видится через docker history. НЕ ДЕЛАЙ
docker run -e PASSWORDRuntime env-переменная. Не в образе, но в docker inspect. OK для dev
compose secrets / file mountСекрет как файл с правами 0400. Не в env, не в inspect. Уже лучше
Vault / sealed-secretsЦентрализованное хранилище с rotation, audit, RBAC. Стандарт для prod
BuildKit --mount=type=secretBuild-time секреты не попадают в образ. Для приватных pip-репо и SSH-clone

Сканирование образов на секреты

Чтобы автоматически ловить случайные утечки, есть инструменты:

  • 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.

Попробуй сам

  1. Создай Dockerfile с COPY . и .env файлом с фейковым AWS_KEY=AKIAFAKE. Собери. Запусти trivy image --scanners secret <image>. Должен найти утечку.
  2. Добавь .dockerignore с .env, пересобери. Перепроверь — утечки нет.
  3. Попробуй BuildKit secret. Создай token.txt с любым текстом. В Dockerfile:
    # syntax=docker/dockerfile:1.7
    FROM alpine
    RUN --mount=type=secret,id=mytoken cat /run/secrets/mytoken
    Собери:
    DOCKER_BUILDKIT=1 docker build --secret id=mytoken,src=./token.txt -t test .
    В логах сборки увидишь содержимое токена (это RUN cat). Сделай docker history test и проверь, что токена в слоях нет.
  4. Создай compose с secrets:
    services:
      app:
        image: alpine
        command: cat /run/secrets/db_password
        secrets:
          - db_password
    secrets:
      db_password:
        file: ./db_password.txt
    Запусти docker compose up. Проверь, что приложение прочитало секрет, но docker inspect app его не показывает.
  5. Прогони trivy image --scanners secret на каком-нибудь своём старом образе — посмотри, нет ли утечек, которые ты пропустил.

Проверка знанийKnowledge check
Почему COPY .env в Dockerfile + RUN rm /app/.env в следующей строке НЕ защищает от утечки секретов, и какой правильный способ передать секрет в момент сборки?
ОтветAnswer
Каждая инструкция Dockerfile создаёт отдельный слой образа. Слои сохраняются в registry независимо друг от друга, и docker pull тянет их все. COPY . /app создал слой с файлом /app/.env внутри. RUN rm /app/.env создал НОВЫЙ слой с whiteout-маркером (специальная пометка «удалить файл»). В финальном объединённом view файла /app/.env нет — но он по-прежнему лежит в нижнем слое. Любой человек с pull-доступом может: - docker history --no-trunc <image> увидеть COPY с этим файлом - docker image save <image> | tar -x — достать сырые слои и прочитать .env Правильный способ — BuildKit --mount=type=secret. В Dockerfile: первая строка "# syntax=docker/dockerfile:1.7", базовый FROM python:3.13-slim, затем RUN --mount=type=secret,id=mytoken pip install --index-url=https://__token__:\$(cat /run/secrets/mytoken)@private/ ... (бэкслеш для продолжения строки опционален). Запуск: docker build --secret id=mytoken,src=./token.txt -t my-image . Что происходит: BuildKit монтирует файл token.txt как /run/secrets/mytoken ТОЛЬКО на время выполнения этого RUN. После RUN — mount размонтируется, никаких следов токена в слоях не остаётся. docker history не показывает токен, docker image save не содержит его. Дополнительная защита: .dockerignore с .env/*.key/*.pem — исключает файлы из контекста сборки физически, COPY . не сможет их захватить даже если запутаешься. И главное: установить .trivyignore + trivy image --scanners secret в CI — автоматически ловит утечки на каждой сборке.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Junior пишет в Dockerfile: COPY . /app, потом RUN rm /app/.env (думая, что rm защитит от утечки). .env содержит AWS_SECRET_KEY. Будет ли секрет утёкать из готового образа?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5