Как стать автором
Обновить

Docker Registry на Python с нуля

Уровень сложностиПростой
Время на прочтение19 мин
Количество просмотров1.4K

Интро

Всем привет! В современном мире разработки 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

  • и т.д.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Интересен ли такой подход — работа над opensource проектом в прямом эфире?
76.92% Да, интересно. Можно скорректировать ход проекта10
23.08% Нет, просто покажи итоговый вариант3
Проголосовали 13 пользователей. Воздержался 1 пользователь.
Теги:
Хабы:
+7
Комментарии2

Публикации

Работа

Data Scientist
50 вакансий

Ближайшие события