Куда класть образы, как не выложить вместе с ними пароли и как узнать, что в них уязвимо
docker.sock и что дают rootless-режим и PodmanОбраз, собранный на ноутбуке, нужно доставить на серверы. Реестр (registry) — сервер, который хранит образы и отдаёт их по имени и тегу. Сборка в конвейере CI кладёт образ в реестр, серверы забирают его оттуда: исходный код и инструменты сборки на боевой сервер не попадают.
| Реестр | Адрес в имени образа | Особенности |
|---|---|---|
| Docker Hub | docker.io/… (можно опускать) | Официальные образы; ограничения доступа из России (модуль 01) |
| Yandex Container Registry | cr.yandex/<id реестра>/… | Российское облако, оплата в рублях, доступ по ключам сервисных аккаунтов |
| Реестр GitLab | registry.<сервер gitlab>/… | Встроен в GitLab, в том числе в установленный у себя; удобен с GitLab CI |
| GitHub Container Registry | ghcr.io/… | Рядом с кодом на GitHub |
| Harbor, Nexus, Docker Registry | свой домен | Ставятся у себя; Harbor умеет роли, сканирование и зеркалирование |
Для компаний, работающих с персональными данными и требованиями к их размещению в России, реестр в российском облаке или свой реестр часто не выбор, а условие.
Официальный образ registry — реестр, который можно поднять где угодно. Чтобы отправить образ в реестр, имя образа должно начинаться с адреса реестра — добавим такой тег и выполним push:
$ docker run -d --name registry -p 127.0.0.1:5000:5000 -v registry-data:/var/lib/registry registry:3
726f43e8076e3be887f1555ab612123203a3def16ac5f6b4cabe912784cf3721
$ docker tag notes:2.0.0 localhost:5000/notes:2.0.0
$ docker push localhost:5000/notes:2.0.0 | grep -v -E "Preparing|Waiting"
The push refers to repository [localhost:5000/notes]
be64570a34e4: Pushed
2.0.0: digest: sha256:08a3b608be9a475edc358e3a8db5e76cd2fafab5da0086d266305c2bc5d407d9 size: 527
Отправляются слои; слой, который уже есть в реестре, повторно не отправляется — второй образ на той же базе python:3.13-slim загрузится за секунды. В конце — дайджест: под этим адресом образ будет неизменным. У реестра простой HTTP API:
$ curl -s http://localhost:5000/v2/_catalog
{"repositories":["notes"]}
$ curl -s http://localhost:5000/v2/notes/tags/list
{"name":"notes","tags":["2.0.0"]}
Удалим образ локально и заберём из реестра — так сервер получает новую версию:
$ docker image rm localhost:5000/notes:2.0.0
Untagged: localhost:5000/notes:2.0.0
Untagged: localhost:5000/notes@sha256:08a3b608be9a475edc358e3a8db5e76cd2fafab5da0086d266305c2bc5d407d9
$ docker pull localhost:5000/notes:2.0.0
2.0.0: Pulling from notes
Digest: sha256:08a3b608be9a475edc358e3a8db5e76cd2fafab5da0086d266305c2bc5d407d9
Status: Downloaded newer image for localhost:5000/notes:2.0.0
localhost:5000/notes:2.0.0
$ docker image inspect -f "{{json .RepoDigests}}" localhost:5000/notes:2.0.0
["localhost:5000/notes@sha256:08a3b608be9a475edc358e3a8db5e76cd2fafab5da0086d266305c2bc5d407d9"]
$ docker rm -f registry
registry
localhost. Для реестра на другом сервере нужен HTTPS с настоящим сертификатом — обычно за обратным прокси nginx. Параметр insecure-registries в daemon.json отключает проверку, и тогда образ можно подменить по дороге — в эксплуатации так не делают.Реестр без пароля разрешает всем и читать, и писать. Простейшая защита — файл htpasswd с хешами паролей; сделаем его утилитой из образа Apache и запустим реестр с ним. Теперь без входа образ не скачать; docker login — вход:
$ mkdir -p auth; docker run --rm --entrypoint htpasswd httpd:2.4-alpine -Bbn student Учеба-2026 > auth/htpasswd; cut -c1-30 auth/htpasswd
student:$2y$05$EDksM9Qm9fIR9On
$ docker run -d --name registry -p 127.0.0.1:5000:5000 -v registry-data:/var/lib/registry -v "$PWD/auth":/auth:ro -e REGISTRY_AUTH=htpasswd -e REGISTRY_AUTH_HTPASSWD_REALM=nevabit -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd registry:3
035cfeb1fcee454a152c2624b9b5ff367607b302fcef17ad2a1cd860f6847b83
$ docker pull localhost:5000/notes:2.0.0
Error response from daemon: Head "http://localhost:5000/v2/notes/manifests/2.0.0": no basic auth credentials
$ echo "Учеба-2026" | docker login localhost:5000 -u student --password-stdin
WARNING! Your password will be stored unencrypted in /tmp/dkpage.gOdJgs/ex/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store
Login Succeeded
$ cat .docker/config.json
{
"auths": {
"localhost:5000": {
"auth": "c3R1ZGVudDrQo9GH0LXQsdCwLTIwMjY="
}
}
}$ grep -o "\"auth\": \"[^\"]*\"" .docker/config.json | cut -d\" -f4 | base64 -d; echo
student:Учеба-2026
$ docker logout localhost:5000
Removing login credentials for localhost:5000
$ docker rm -f registry
registry
Docker сам предупреждает: пароль сохранён незашифрованным. Поле auth — это логин:пароль в Base64, который раскодирует кто угодно. Файл ~/.docker/config.json — частая добыча: его забывают в резервных копиях, домашних каталогах общих серверов, в образах (COPY . . из домашнего каталога).
docker-credential-pass, docker-credential-secretservice): пароль уходит в связку ключей системы.pull) или только для нужного репозитория, а не личный пароль. Токен легко отозвать.--password-stdin, а не -p пароль: аргумент командной строки виден в ps и истории оболочки.docker logout.Частая ошибка — передать при сборке токен (для закрытого репозитория пакетов, для скачивания модели) и считать, что его «потом удалили». Образ для опыта: токен приходит через ARG и через файл, файл удаляется в той же инструкции:
FROM alpine:3.22
ARG API_TOKEN
COPY token.txt /tmp/token.txt
RUN . /tmp/token.txt && echo "скачиваю с токеном" && rm /tmp/token.txt
$ docker build -q --build-arg API_TOKEN=secret-arg-42 -f Dockerfile.leak -t leak . >/dev/null
$ docker run --rm leak ls /tmp
$ docker history --no-trunc --format "{{.CreatedBy}}" leak | grep -m1 API_TOKEN
RUN |1 API_TOKEN=secret-arg-42 /bin/sh -c . /tmp/token.txt && echo "скачиваю с токеном" && rm /tmp/token.txt # buildkit
$ mkdir img; docker save leak | tar -x -C img; for l in img/blobs/sha256/*; do tar -tf $l 2>/dev/null | grep -qx tmp/token.txt && tar -xOf $l tmp/token.txt; done
TOKEN=demo-1234567890
В запущенном контейнере файла нет — ls /tmp пуст. Но:
ARG записано в историю образа открытым текстом;COPY. Удаление в следующей инструкции лишь прячет его в верхнем слое, а слой с файлом остаётся в образе. docker save выгружает образ архивом, и в слоях файл находится за одну команду.Всё, что когда-либо попало в любой слой, есть у каждого, кто может скачать образ. Это касается и ENV, и файлов .env, скопированных через COPY . ..
Правильный способ — секрет сборки BuildKit. Файл подключается только на время одной инструкции RUN в /run/secrets/ и не попадает ни в слой, ни в историю:
FROM alpine:3.22
RUN --mount=type=secret,id=token \
. /run/secrets/token && echo "скачиваю с токеном, длина ${#TOKEN}"
$ docker build --no-cache --progress=plain --secret id=token,src=token.txt -f Dockerfile.secret -t secret . 2>&1 | grep "длина"
#5 [stage-0 2/2] RUN --mount=type=secret,id=token . /run/secrets/token && echo "скачиваю с токеном, длина ${#TOKEN}"
#5 0.188 скачиваю с токеном, длина 15
$ docker history --no-trunc --format "{{.CreatedBy}}" secret | head -1
RUN /bin/sh -c . /run/secrets/token && echo "скачиваю с токеном, длина ${#TOKEN}" # buildkit
Токен прочитан (длина 15 символов), а в истории — только команда. Секреты для работы приложения (пароль базы, ключи API) в образ не попадают вовсе: они передаются при запуске — переменными из .env на сервере или, надёжнее, через secrets: в Compose (модуль 06).
В базовом образе — десятки и сотни пакетов, и в них регулярно находят уязвимости. Сканер сверяет версии пакетов образа с базами известных уязвимостей (CVE). Trivy — открытый сканер, удобно запускается сам как контейнер; ему нужен доступ к Docker, чтобы прочитать образ, и кэш для базы уязвимостей:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$HOME/.cache/trivy":/root/.cache aquasec/trivy:0.67.2 image -q --scanners vuln --format table python:3.13-slim 2>&1 | sed -n "/Report Summary/,/Legend/p"
Report Summary
┌──────────────────────────────────────────────────────────────────────┬────────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┤
│ python:3.13-slim (debian 13.7) │ debian │ 164 │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┤
│ Python │ python-pkg │ 6 │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┤
│ usr/local/lib/python3.13/site-packages/pip-26.2.2.dist-info/METADATA │ python-pkg │ 0 │
└──────────────────────────────────────────────────────────────────────┴────────────┴─────────────────┘
Legend:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$HOME/.cache/trivy":/root/.cache aquasec/trivy:0.67.2 image -q --scanners vuln --format table python:3.13 2>&1 | sed -n "/Report Summary/,/Legend/p" | head -8
Report Summary
┌──────────────────────────────────────────────────────────────────────┬────────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┤
│ python:3.13 (debian 13.7) │ debian │ 3492 │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┤
│ Python │ python-pkg │ 6 │
Слим-образ — 170 записей, полный — больше 3 400: каждый лишний пакет добавляет уязвимостей, хотя приложение им не пользуется. Это ещё один довод за -slim и многоэтапную сборку из модуля 07. Числа — на 23.10.2026; сканируйте сами — завтра они будут другими.
Как с этим работать:
CRITICAL и HIGH (--severity HIGH,CRITICAL) и те, для которых есть исправленная версия (--ignore-unfixed).docker build --pull, а не годами запускать собранный однажды.Базу уязвимостей Trivy скачивает с ghcr.io и mirror.gcr.io; 23.10.2026 загрузка с учебного сервера работала. Для закрытых контуров Trivy умеет работать с заранее скачанной базой.
Некоторым программам нужен доступ к Docker: сканеру, панели управления, агенту CI, обратному прокси Traefik. Их запускают, подключив в контейнер сокет хоста по тому же пути — /var/run/docker.sock. Но сокет — это полный доступ к службе Docker (модуль 01), а значит к хосту. Изнутри такого контейнера видно и можно сделать всё:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:26-cli docker ps --format "{{.Names}}"
brave_dhawan
db-prod
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:26-cli docker run --rm -v /:/host alpine:3.22 cat /host/etc/hostname
mypc
Контейнер с сокетом увидел соседей, в том числе db-prod, и запустил новый контейнер с корнем хоста — дальше можно читать базы, ключи, менять системные файлы. Проброс :ro не помогает: «только чтение» относится к файлу сокета, а не к командам API.
docker save, trivy image --input) без сокета.docker-socket-proxy).Корень многих проблем — служба dockerd работает от root. Есть два способа обойтись без этого:
apt install podman). Образы и Dockerfile те же; podman compose работает с файлами Compose.Даже с обычным Docker часть защиты включается одной строкой в daemon.json: "userns-remap": "default" — root контейнеров отображается на непривилегированный диапазон UID хоста. После включения Docker хранит данные в отдельном подкаталоге /var/lib/docker, и прежние образы, контейнеры и тома перестают быть видны, — поэтому его включают на новом сервере, а не на работающем.
| Где | Что проверить |
|---|---|
| Dockerfile | Базовый образ -slim или меньше, с конкретным тегом; многоэтапная сборка; USER не root; .dockerignore с .env и ключами; секреты только через --secret |
| Образ | Сканирование без CRITICAL; docker history без паролей; регулярная пересборка с --pull |
| Запуск | cap_drop: [ALL], no-new-privileges, read_only, лимиты памяти и процессора, без --privileged и без сокета Docker |
| Сеть | Публикуется только прокси; остальное — 127.0.0.1 или без портов; база в отдельной сети |
| Сервер | Группа docker — только администраторы; API Docker не открыт в сеть; ротация журналов; токены реестра только на чтение |
push отправляет только недостающие слоиconfig.json хранит пароль в Base64 — помощники хранения и токены только на чтениеARG или ENV, утекает вместе с образом; секреты сборки — RUN --mount=type=secret--pulldocker.sock в контейнере = root на хосте