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