Интро
Всем привет! В современном мире разработки docker является одним из краеугольных камней эргономики рабочего пространства разработчика, наряду с git, разного рода IDE и редакторами, а для кого‑то — и GPT. И, хоть в самом по себе docker нет ничего такого уж уникального (LXC, CRI‑O, чистый containerd, различные легкие и средние виртуалки, бессерверные среды, для особых ценителей — chroot. Тысячи их), он подкупает удобством использования и развесистой экосистемой — поддержка Docker есть в большинстве редакторов кода и IDE, про него написаны многочисленные книги, статьи и туториалы от индусов, а по его реестрам (от Docker Hub до локальных реп на гитлабе) удобно разложен практически весь существующий на планете софт.
Вот о реестрах (registry) Docker и хочется сегодня поговорить.
Сам по себе реестр — это просто REST‑сервис и файловое хранилище. Образы прилетают в реестр в виде бинарных слоев (количество и размер которых зависит от Dockerfile, по которому собирался образ) и простого JSON‑файла манифеста.
Пример манифеста для nginx:1.27.3 (длинные SHA256-хеши слоев сокращены для удобства восприятия)
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"digest": "sha256:c59e92..."
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:d2eb42..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:ee083d..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:5afd65..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:8c2914..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:1e8aef..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:a982d0..."
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"digest": "sha256:ab571a..."
}
]
}
Пытливый читатель (впрочем, как и любой другой тоже) сразу заметит, что в манифесте совершенно нет ничего интересного. Это просто инструкция для докера, слои с какими digest затянуть из реестра и в какой последовательности сшить на запуске. Чуть более интересен конфигурационный слой (блок config). Из него, например, можно вытряхнуть первоначальные настройки будущего контейнера (для чего бы вам это ни пригодилось)
Пример фрагмента конфигурационного слоя nginx:1.27.3
{
"architecture": "amd64",
"config": {
"ExposedPorts": {
"80/tcp": {}
},
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.27.3",
"NJS_VERSION=0.8.7",
"NJS_RELEASE=1~bookworm",
"PKG_RELEASE=1~bookworm",
"DYNPKG_RELEASE=1~bookworm"
],
"Entrypoint": [
"/docker-entrypoint.sh"
],
"Cmd": [
"nginx",
"-g",
"daemon off;"
],
"Labels": {
"maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
},
"StopSignal": "SIGQUIT"
},
"created": "2024-11-26T18:42:08Z",
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:7914c8...",
"sha256:2cdcae...",
"sha256:0b2daf...",
"sha256:a280e1...",
"sha256:166490...",
"sha256:1b78ff...",
"sha256:e2eb04..."
]
}
}
Собственно, из этих манифестов, а также из полученных из реестра бинарных слоев и строится итоговый контейнер. Все гениальное — просто.
Слои
Слои, кстати, это по своей сути tar-gzip-архивы, содержащие разницу между состоянием файловой системы до выполнения какой-то операции (COPY, RUN и т.д.) и после
Например, фрагмент Dockerfile все того же nginx:1.27.3
COPY 10-listen-on-ipv6-by-default.sh /docker-entrypoint.d
создает слой, содержащий всего один файл
# tar -xvzf 8c2914db26a3b019cf8b9dcd9ffec286f7aa4db2165ee79bb214b6e66dd461c2
drwxr-xr-x 2 root root 4096 Feb 4 04:25 .
drwxr-xr-x 3 root root 4096 May 26 14:48 ..
-rwxr-xr-x 1 root root 2125 Feb 4 04:25 10-listen-on-ipv6-by-default.sh
Ну и так далее. Ничего сложного нет, но будет о чем на собесах рассказать.
Тема структуры и бизнес‑логики Docker Registry — более интересная. Настолько, что я решил создать небольшой opensource‑проект, в рамках которого будет разрабатываться простая логика реестра. Ну и, поскольку просто так писать такой проект может быстро надоесть, я решил работу над ним протащить через 1–2–3 статьи на Хабре, вдруг кому‑то это будет интересно (или хотя бы полезно).
На всякий случай, дисклеймер: это НЕ САМЫЙ ОПТИМАЛЬНЫЙ способ развертывания реестра, если вам просто нужен реестр сам по себе. Лучше развернуть себе гитлаб, Nexus или, на худой конец, registry с докерхаба. Там уже все это есть, и даже больше, а бонусом — комьюнити большое, на SO куча материала и даже нейросети про них знают и могут в настройке пособить. Мы же тут — пока исключительно с исследовательской целью.
Все, предупредил. Погнали
Стек
Этот проект — не из тех, на которых я обычно изучаю новые языки разработки или новые фреймворки. Поэтому стек лично для меня — стандартный.
[tool.poetry.dependencies]
python = ">=3.11, <3.13"
fastapi = "==0.115.12"
pydantic = {extras = ["email"], version = "2.11.5"}
pydantic-settings = "==2.9.1"
Hypercorn = "==0.17.3"
uvloop = "==0.21.0"
toml = "==0.10.2"
python-ulid = "==3.0.0"
aiofiles = "==24.1.0"
orjson = "==3.10.18"
Из этого всего лишь FastAPI для понимания проекта является критичным, большая часть остального — мой привычный тулсет, не несущий на данной стадии проекта никакой особой ценности. Но, думаю, пригодится в дальнейшем
Ну и верный poetry
— за главного на разруливании дерева зависимостей и запуске виртуального окружения
Старт проекта
Структура проекта на версии 0.1.0 (то есть, на момент окончания этой статьи)
./ice-registry/
├── Dockerfile
├── LICENSE.md
├── README.md
├── config.py
├── deploy
│ └── config
│ └── nginx
│ └── registry.conf
├── docker-compose-local.yaml
├── poetry.lock
├── pyproject.toml
└── registry
├── __main__.py
├── api
│ ├── __init__.py
│ ├── docker_v2
│ │ ├── __init__.py
│ │ ├── check_blob.py
│ │ ├── create_manifest.py
│ │ ├── create_upload.py
│ │ ├── finalize_upload.py
│ │ ├── get_blob.py
│ │ ├── get_manifest.py
│ │ ├── get_tags_list.py
│ │ ├── ping.py
│ │ ├── router.py
│ │ └── update_upload.py
│ └── status
│ ├── __init__.py
│ ├── get_health.py
│ └── router.py
└── utils
├── __init__.py
├── auth.py
└── version.py
Итак, с чего начинается любой приличный сервис? Правильно, с хелсчека и сборки
Первая ручка проекта будет просто возвращать всегда 200 Ok
. Такой вот примитивный healthcheck, да
# registry/api/status/get_health.py
from .router import router
@router.get(
"/health",
summary="Статус сервиса",
)
async def get_health():
return True
Роутер для модуля status тоже пока что примитивный
# registry/api/status/router.py
from fastapi import APIRouter
router = APIRouter(tags=["status"])
Когда-нибудь в этом модуле будет полноценная ручка статуса, хелсчек, который реально проверяет здоровье сервиса. Но не сегодня
На данный момент уже можно вытащить роутер модуля status в общий роутер приложения...
# registry/api/__init__.py
from fastapi import APIRouter
from .status import router as status_router
router = APIRouter()
router.include_router(status_router, prefix="/status")
... и создать, наконец, само приложение
# registry/__main__.py
import asyncio
import uvloop
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from hypercorn.asyncio import serve
from hypercorn.config import Config
from starlette.exceptions import HTTPException as StarletteHTTPException
from config import settings
from .api import router
def run():
app = FastAPI(
title="Ice Docker Registry",
version="0.1.0",
docs_url="/openapi" if settings.debug else None,
redoc_url=None,
root_path=settings.root_path,
debug=settings.debug,
)
app.include_router(router)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
headers=exc.headers,
content={
"meta": {
"url": str(request.url),
"client": request.client.host,
},
"status": exc.status_code,
"method": request.method,
"error_text": exc.detail,
})
hypercorn_config = Config()
hypercorn_config.bind = [ settings.bind_host ]
uvloop.install()
asyncio.run((serve(app, hypercorn_config)))
if __name__ == "__main__":
run()
Здесь происходит сразу несколько важных вещей:
создается и настраивается FastAPI-приложение
в приложение подключается корневой роутер
к приложению подключается (и совершенно безалаберно настраивается на полный allow) промежуточный обработчик CORSMiddleware
с помощью декоратора
@app.exception_handler
настраивается общий обработчик всех http-исключений (экземпляров и потомков универсального HTTPException). Важный шаг, который я делаю всегда на автомате. Позволяет с самого старта сервиса/микросервиса не задумываться о структуре объектов ошибок, и просто райзить HTTPException в нужных местах кода.создается конфиг Hypercorn
ну и, собственно, с помощью все того же Hypercorn (а также — более производительной петли uvloop), приложение стартует.
Последнее, что надо добавить перед первой сборкой и запуском — файл конфигурации приложения
# config.py
import os
from pydantic import Field, IPvAnyAddress
from pydantic_settings import SettingsConfigDict, BaseSettings
class ServiceSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=os.getenv('ENV_FILE', '.env'),
env_file_encoding='utf-8',
env_nested_delimiter='__',
)
@property
def bind_host(self):
return f"{self.bind_ip}:{self.bind_port}"
debug: bool = False
bind_ip: IPvAnyAddress = Field(default="0.0.0.0")
bind_port: int = 8080
root_path: str = ""
settings = ServiceSettings()
Настройки — базовые для любого сервиса, который еще не успел обрасти мясом функционала
Итак, приложение еще не несет в себе никакой ценности, но его уже можно собрать и запустить
FROM python:3.12.3
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install poetry
COPY poetry.lock /app/poetry.lock
COPY pyproject.toml /app/pyproject.toml
RUN poetry config virtualenvs.create false && poetry install --no-interaction
COPY ./registry /app/registry
COPY ./config.py /app/config.py
HEALTHCHECK --interval=5s --timeout=10s --retries=3 CMD curl -sS localhost:8080/status/health || exit 1
CMD [ "python", "-m", "registry" ]
Все просто, и даже усложняться не будет.
Затягиваем (в моем случае) python:3.12.3
, устанавливаем туда poetry, копируем lock и toml файлы проекта, устанавливаем, копируем файлы приложения (директорию с сервисом и файл с конфигурацией), прописываем авторский docker-healthcheck и указываем, как сервис можно запустить.
Собираем, запускаем, открываем сваггер, радуемся целой одной ручке, делающей ничего.
Шаг в сторону. Reverse Proxy
Я всегда стараюсь закрыть прокси все сетевые сервисы, которые открывают наружу свои порты. Тому есть несколько причин — возможность легко настраивать примитивную балансировку, возможность ограничить доступ к приложению вайтлистом, возможность быстро и легко сделать реврайт и подцепить SSL. Но в большей степени это дело привычки. Если вас не смущает, когда ваши сервисы торчат в интернет голыми филейными частями — можете опустить этот блок.
Конфиг для nginx прост и реализует на данный момент всего одну локацию (появится у проекта фронт — будут еще локации).
# deploy/config/nginx/registry.conf
upstream ice-registry-upstream {
server ice-registry:8080 fail_timeout=0;
}
server {
listen 80;
client_max_body_size 0;
charset utf-8;
gzip on;
gzip_types text/plain application/javascript image/svg+xml;
error_log /var/log/nginx/service.error_log debug;
location / {
proxy_pass http://ice-registry-upstream/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-Ssl off;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Connection "";
}
}
Подключается конфиг в тестовом docker-compose-local.yaml
services:
nginx:
image: nginx:1.27.3
container_name: nginx
hostname: nginx
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
volumes:
- ./deploy/config/nginx:/etc/nginx/conf.d
ports:
- 31002:80
networks:
- ice-registry-network
restart: on-failure:10
ice-registry:
build:
context: .
dockerfile: ./Dockerfile
container_name: ice-registry
hostname: ice-registry
environment:
DEBUG: true
networks:
- ice-registry-network
restart: on-failure:10
networks:
ice-registry-network:
driver: bridge
Запускаем через docker compose -f docker-compose-local.yaml up -d --build --force-recreate
, проверяем - на 31002 порту повисает сервис приложения
Шаг в другую сторону. Отображение версии приложения в сваггере
Я люблю принцип Single Source во всем. Поэтому для меня крайне важно, чтобы в сваггере приложения отображалась реальная версия приложения.
В случае poetry версия приложения хранится в файле pyproject.toml
. Поэтому во всем моих проектах всегда есть хелпер, помогающий вытащить из pyproject.toml
текущую актуальную версию и раздать ее всем страждущим.
# registry/utils/version.py
import toml
from pathlib import Path
def get_app_version() -> str:
pyproject_toml_file = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_toml_file.exists() and pyproject_toml_file.is_file():
data = toml.load(pyproject_toml_file)
if "tool" in data and "poetry" in data["tool"] and "version" in data["tool"]["poetry"]:
return data["tool"]["poetry"]["version"]
return "unknown"
Подключается и вызывается хелпер в данном случае на этапе создания приложения
# registry/__main__.py
...
from registry.utils import get_app_version
...
def run():
app = FastAPI(
title="Ice Docker Registry",
version=get_app_version(),
docs_url="/openapi" if settings.debug else None,
redoc_url=None,
root_path=settings.root_path,
debug=settings.debug,
)
...
Теперь, если подвигать версию приложения через poetry version patch
, poetry version minor
или poetry version major
, или просто залезть в pyproject.toml
и поменять версию ручками, сваггер всегда будет отображать актуальную версию. Ну и потом можно сделать ручку, которая будет отдавать версию на фронт
Docker Registry REST API
Ну а теперь, собственно, главное - бэкенд для реестра.
Продуктовые требования для него простые:
Наличие базовой аутентификации (юзер пока будет один, и задаваться его креды будут на старте сервиса через переменные окружения).
Реестр должен хранить слои на диске, в директории, которую мы также будем указывать на старте сервиса в переменных окружения.
Должна быть возможность создавать на старте один или несколько репозиториев. Функционал создания репозитория через API мы реализуем в какой-нибудь из следующих серий.
Начнем с конфигов. В файл конфигурации сервиса необходимо добавить несколько новых переменных
# config.py
...
docker_repos_path: str = "/data/docker/repos"
docker_default_repos: list[str] = []
docker_username: str = "admin"
docker_password: str = "admin"
...
где:
docker_repos_path
— локальный путь к репозиториямdocker_default_repos
— список стартовых репозиториевdocker_username
иdocker_username
— логин и пароль пользователя соответственно Пробросим сразу эти переменные (а также - volume) вdocker-compose-local.yaml
, чтобы не забыть
# docker-compose-local.yaml
services:
...
ice-registry:
volumes:
- ./.test:/data/docker/repos
environment:
...
DOCKER_DEFAULT_REPOS: '[ "test_repo_0", "test_repo_1" ]'
DOCKER_USERNAME: registry_user
DOCKER_PASSWORD: registry_password_123
Теперь на старте сервиса будем создавать все нужные директории
# registry/__main__.py
...
from pathlib import Path
...
def run():
Path(settings.docker_repos_path).mkdir(parents=True, exist_ok=True)
for docker_repo in settings.docker_default_repos:
repo_path = Path(settings.docker_repos_path) / docker_repo
Path(repo_path).mkdir(parents=True, exist_ok=True)
app = FastAPI(
title="Ice Docker Registry",
version=get_app_version(),
docs_url="/openapi" if settings.debug else None,
redoc_url=None,
root_path=settings.root_path,
debug=settings.debug,
)
...
Для проверки аутентификации необходимо сделать новый хелпер — check_auth
# registry/utils/auth.py
import secrets
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from config import settings
security = HTTPBasic(
realm="Ice Registry",
)
def check_auth(
credentials: HTTPBasicCredentials = Depends(security)
):
exception = HTTPException(
status_code=401,
detail="Unauthorized",
headers={
"WWW-Authenticate": 'Basic realm="Ice Registry"'
},
)
if not credentials:
raise exception
user_is_valid = secrets.compare_digest(credentials.username, settings.docker_username)
password_is_valid = secrets.compare_digest(credentials.password, settings.docker_password)
if not (user_is_valid and password_is_valid):
raise exception
return True
Здесь мы пользуемся поставляемыми вместе с FastAPI зависимостями, позволяющими автоматически прочитать Authorization
‑заголовок, при его получении — сравнить предоставленные креды с заданными в переменных окружения, а при неполучении (либо если предоставленные креды с заданными не сошлись) — вернуть заголовок WWW-Authenticate
и 401 код. При получении такого ответа docker либо попытается еще раз отправить запрос, пользуясь сохраненными пользователем реквизитами доступа, либо покажет пользователю ошибку unauthorized: authentication required
Создадим новый API-модуль docker_v2
, роутер для него, и первый эндпоинт
# registry/api/docker_v2/router.py
from fastapi import APIRouter, Depends
from registry.utils import check_auth
router = APIRouter(
tags=["docker"],
dependencies=[Depends(check_auth)]
)
# registry/api/docker_v2/ping.py
from .router import router
@router.get(
"/",
summary="Эхо-пинг v2"
)
async def ping():
return {}
Docker при первом обращении к реестру отправляет GET-запрос на адрес /v2/, ожидая либо 200 ответ (реестр публичный, аутентификация не требуется), либо 401 с заголовком WWW-Authenticate
, содержащим требуемый тип аутентификации (base, JWT и т.д.)
Кстати, при выполнении docker login ...
он тоже стучится на этот же эндпоинт, пытаясь обменять предоставленные пользователем реквизиты доступа на ответ 200 Ok
Добавление образа в реестр
Для добавления образа docker выполняет следующую последовательность действий:
Выполняет HEAD-запрос на эндпоинт
/{repo}/blobs/{digest}
, проверяя, не загружен ли уже данный слой в реестр. Если реестр ответит200 Ok
, docker не будет пытаться загрузить слой. Для инициации загрузки эндпоинт/{repo}/blobs/{digest}
должен ответить статусом404 Not Found
Выполняет POST-запрос на эндпоинт
/v2/{repo}/blobs/uploads/
, ожидая получить ответ202 Accepted
и заголовокLocation
, содержащий путь с уникальным ID загрузки (/v2/{repo}/blobs/uploads/{upload_id}
)Получив нужный
Location
, docker начинает загрузку слоя, отправляя PATCH-запрос на/v2/{repo}/blobs/uploads/{upload_id}
, с чанками слоя в теле запроса. В ответ он ожидает получить все тот же ответ202 Accepted
По окончании загрузки слоя docker отправляет финализирующий PUT-запрос на
/v2/{repo}/blobs/uploads/{upload_id}
, передавая в query-параметреdigest
свою версию SHA256-хеша переданного слоя. В ответ ему нужно вернуть статус201 Created
и путь к слою вида/v2/{repo}/blobs/{digest}
Загрузка нескольких слоев может производиться параллельно.
После того, как docker отправил все слои в реестр, и по каждому получил 201 Created
, он начинает загрузку в реестр манифеста образа. Последовательность действий следующая:
Выполняется PUT-запрос на адрес
/{repo}/manifests/{tag}
(где tag - тег образа), с манифестом в теле запроса. В ответ он ожидает получить статус201 Created
и заголовокDocker-Content-Digest
, содержащий SHA256-хеш манифестаПосле этого docker выполняет еще один PUT-запрос на тот же адрес, но вместо тега использует SHA256-хеш манифеста. Docker предполагает, что реестр примет и корректно ответит на обе отправки манифеста
Реализуем код всех этих эндпоинтов:
Обработчик для HEAD-запроса. Тут все просто — получаем запрос, проверяем, есть ли указанный репозиторий, и есть ли в нем указанный слой. Если нет того или другого - возвращаем 404 Not Found
Код
from pathlib import Path
from fastapi import (
Path as FastAPIPath,
HTTPException,
Response,
)
from config import settings
from .router import router
@router.head(
"/{repo}/blobs/{digest}",
summary="Получение слоя по отпечатку",
)
async def check_blob(
digest: str,
repo: str = FastAPIPath(...),
):
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_blobs_dir = repo_dir / "blobs"
blob_hex = digest.replace("sha256:", "")
blob_path = repo_blobs_dir / blob_hex
if not blob_path.exists():
return Response(status_code=404)
return Response(status_code=200)
Обработчик POST-запроса на создание загрузки. С помощью библиотеки python-ulid создаем уникальный идентификатор загрузки, который пока нигде, кроме tmp-файла фигурировать не будет (но в будущих сериях будет уходить в БД), создаем пустой tmp-файл и возвращаем клиенту 202 Accepted
и заголовок с путем, по которому можно начинать грузить слой
Код
from pathlib import Path
from fastapi import Path as FastAPIPath, Response, HTTPException
from ulid import ULID
from config import settings
from .router import router
@router.post(
"/{repo}/blobs/uploads/",
summary="Создание загрузки слоя"
)
async def create_upload(
repo: str = FastAPIPath(...),
):
upload_id = ULID()
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_blobs_dir = repo_dir / "blobs"
Path(repo_blobs_dir).mkdir(parents=True, exist_ok=True)
tmp_file_path = Path(repo_blobs_dir) / f"{str(upload_id)}.tmp"
tmp_file_path.touch()
return Response(
status_code=202,
headers={
"Location": f"/v2/{repo}/blobs/uploads/{str(upload_id)}"
}
)
Обработчик PATCH-запроса на загрузку слоя. Проверяем наличие репозитория и tmp-файла загрузки. Получаем из тела запроса чанк данных, дописываем его в tmp-файл. Возвращаем 202 Accepted
и заголовок с размером принятых данных
Обработчик PATCH-запроса на загрузку слоя. Проверяем наличие репозитория и tmp-файла загрузки. Получаем из тела запроса чанк данных, дописываем его в tmp-файл. Возвращаем 202 Accepted
и заголовок с размером принятых данных
Код
import aiofiles
import os
from pathlib import Path
from fastapi import (
Path as FastAPIPath,
Response,
HTTPException,
Body,
)
from config import settings
from .router import router
@router.patch(
"/{repo}/blobs/uploads/{upload_id}",
summary="Дозагрузка слоя",
)
async def update_upload(
upload_id: str,
repo: str = FastAPIPath(...),
chunk: bytes = Body(...),
):
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_blobs_dir = repo_dir / "blobs"
tmp_file_path = Path(repo_blobs_dir) / f"{str(upload_id)}.tmp"
if not tmp_file_path.exists():
raise HTTPException(404, detail=f"Upload [{upload_id}] does not exist")
async with aiofiles.open(str(tmp_file_path), mode="ab") as tmp_file:
await tmp_file.write(chunk)
offset = os.path.getsize(tmp_file_path)
return Response(
status_code=202,
headers = {
"Location": f"/v2/{repo}/blobs/uploads/{upload_id}",
"Range": f"0-{offset-1}",
},
)
Обработчик PUT-запроса на финализацию слоя. Считаем SHA256-хеш tmp-файла, сверяем результат с переданным от клиента. Если все ок, переименовываем tmp-файл в файл слоя, и возвращаем 201 Created
и путь, по которому этот слой можно скачать
Код
import hashlib
import aiofiles
import os
from pathlib import Path
from fastapi import (
Path as FastAPIPath,
Response,
HTTPException,
Query,
)
from config import settings
from .router import router
@router.put(
"/{repo}/blobs/uploads/{upload_id}",
summary="Финализация слоя",
)
async def finalize_upload(
upload_id: str,
repo: str = FastAPIPath(...),
digest: str = Query(..., description="sha256:<hex>"),
):
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_blobs_dir = repo_dir / "blobs"
tmp_file_path = Path(repo_blobs_dir) / f"{str(upload_id)}.tmp"
if not tmp_file_path.exists():
raise HTTPException(404, detail=f"Upload [{upload_id}] does not exist")
h = hashlib.sha256()
async with aiofiles.open(str(tmp_file_path), mode="rb") as tmp_file:
while True:
block = await tmp_file.read(4096)
if not block:
break
h.update(block)
real_digest = f"sha256:{h.hexdigest()}"
if real_digest != digest:
os.remove(str(tmp_file_path))
raise HTTPException(400, f"Digest mismatch, real={real_digest}")
blob_path = repo_blobs_dir / h.hexdigest()
os.replace(str(tmp_file_path), str(blob_path))
return Response(
status_code=201,
headers={
"Location": f"/v2/{repo}/blobs/{real_digest}"
},
)
Обработчик PUT-запроса на загрузку манифеста. Проверяем Content-Type
манифеста (наш реестр принимает только манифесты v2, потому что надо жить настоящим, а не прошлым), рассчитываем SHA256-хеш полученного манифеста, сохраняем на диск и возвращаем клиенту ответ 201 Created
и SHA256-хеш манифеста
Код
import aiofiles
import hashlib
import orjson
from pathlib import Path
from fastapi import (
Path as FastAPIPath,
HTTPException,
Body,
Response,
Header,
)
from config import settings
from .router import router
@router.put(
"/{repo}/manifests/{tag}",
summary="Создание манифеста для тега",
)
async def create_manifest(
tag: str,
repo: str = FastAPIPath(...),
content_type: str = Header(..., alias="Content-Type"),
manifest: dict = Body(...)
):
valid_types = {
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
}
if content_type not in valid_types:
raise HTTPException(400, detail=f"Unsupported media type: {content_type}")
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_manifests_dir = repo_dir / "manifests"
Path(repo_manifests_dir).mkdir(parents=True, exist_ok=True)
manifest_bytes = orjson.dumps(manifest)
manifest_digest = f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}"
manifest_file_path = Path(repo_manifests_dir) / f"{tag}.json"
manifest_sha256_file_path = Path(repo_manifests_dir) / f"{manifest_digest}.json"
async with aiofiles.open(str(manifest_file_path), mode='wb') as manifest_file:
await manifest_file.write(manifest_bytes)
async with aiofiles.open(str(manifest_sha256_file_path), mode='wb') as manifest_file:
await manifest_file.write(manifest_bytes)
return Response(
status_code=201,
headers = {
"Docker-Content-Digest": manifest_digest,
"Location": f"/v2/{repo}/manifests/{tag}"
}
)
Повторный PUT-запрос на создание манифеста по подписи вместо человекочитаемого тега упадет в этот же обработчик, ничего специально для этого делать не надо
На этом все. Вызываемый командой docker push ...
флоу мы полностью поддержали на бэкенде.
Получение образа из реестра
Настала очередь реализации эндпоинтов, вызываемых docker при docker pull ...
. Тут все максимально просто:
Docker запрашивает манифест образа через GET-запрос эндпоинта
/{repo}/manifests/{tag}
. Мы должны ответить либо404 Not Found
, если такого манифеста у нас нет, либо200 Ok
и манифестом в теле ответаДалее docker начинает параллельно догружать из реестра слои, которых нет в локальном кеше. Для этого он вызывает GET-запросом эндпоинт
/{repo}/blobs/{digest}
. На это мы можем ответить либо404 Not Found
(и на клиенте загрузка слоев и сборка образа сломается), либо потоком на выгрузку с типомapplication/octet-stream
Реализуем код всех этих эндпоинтов:
Обработчик GET-запроса выгрузки манифеста. Просто забираем с диска манифест из файла, именованного запрошенным тегом, и отдаем, по пути посчитав его SHA256-хеш
Код
# registry/api/docker_v2/get_manifest.py
import hashlib
from pathlib import Path
from fastapi import (
Path as FastAPIPath,
HTTPException,
Response,
)
from config import settings
from .router import router
@router.get(
"/{repo}/manifests/{tag}",
summary="Получение манифеста для тега",
)
async def get_manifest(
tag: str,
repo: str = FastAPIPath(...),
):
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_manifests_dir = repo_dir / "manifests"
manifest_file_path = Path(repo_manifests_dir) / f"{tag}.json"
if not manifest_file_path.exists():
raise HTTPException(404, detail=f"Manifest for tag [{tag}] does not exist")
manifest_bytes = open(str(manifest_file_path), "r").read().encode()
digest = f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}"
return Response(
content=manifest_bytes,
media_type="application/vnd.docker.distribution.manifest.v2+json",
headers={
"Docker-Content-Digest": digest,
}
)
Обработчик GET-запроса слоя. Находим на диске нужный слой и отдаем его клиенту с помощью StreamingResponse
Код
# registry/api/docker_v2/get_blob.py
from pathlib import Path
from fastapi import (
Path as FastAPIPath,
HTTPException,
)
from fastapi.responses import StreamingResponse
from config import settings
from .router import router
@router.get(
"/{repo}/blobs/{digest}",
summary="Получение слоя по отпечатку",
)
async def get_blob(
digest: str,
repo: str = FastAPIPath(...),
):
repo_dir = Path(settings.docker_repos_path) / repo
if not repo_dir.exists():
raise HTTPException(404, detail=f"Repository [{repo}] does not exist")
repo_blobs_dir = repo_dir / "blobs"
blob_hex = digest.replace("sha256:", "")
blob_path = repo_blobs_dir / blob_hex
if not blob_path.exists():
raise HTTPException(404, detail=f"BLOB [{digest}] does not exist")
return StreamingResponse(
open(str(blob_path), "rb"),
media_type="application/octet-stream"
)
На этом все. У нас готов минимальный docker registry, который умеет принимать, хранить и отдавать манифесты и слои, и производить примитивную проверку доступа
Проект можно собрать и запустить в docker-compose или через docker run
, и попробовать кидать туда образы. Если запускать его на localhost, адрес придется добавить в конфиг демона docker (в блок insecure-registries
), иначе docker будет требовать https. Либо можно задеплоить на VPS, купить или попросить у Lets Encrypt SSL-сертификат, добавить в конфиг nginx блок SSL, и просто работать с реестром через docker push ...
и docker pull ...
Весь исходный код проекта опубликован на GitVerse (GitHub и Gitlab — ребята ненадежные, а личный гитлаб высовывать в интернет не хочется). Конструктивная критика приветствуется (неконструктивная не возбраняется, но игнорируется :) ), пулл-реквесты — еще более приветствуются
Телеграм-канал свой не публикую. Настоящие пацаны инженера залипают в листингах кода и чатятся в issue :)
Проект на gitverse - https://gitverse.ru/icecloud/ice-registry
В следующих сериях:
логика работы с репозиториями и пользователями
разделение прав на доступ к репозиториям
централизованное хранилище слоев (чтобы не грузить те слои, которые где-то в реестре уже есть)
отправка событий в Kafka
и т.д.