Привет, Хабр! Я Дима Соколов, работаю в Positive Labs, где занимаюсь безопасностью аппаратных решений. В этой статье хочу рассказать про свое небольшое исследование в области загрузки/выгрузки образов, а также авторизации в Docker Private Registry.
Пользоваться общей инфраструктурой Docker очень удобно. Тысячи готовых образов, доступных вам через одну команду в консоли, в любом месте, где есть интернет. Ничего удивительного в том, что это вошло в обиход большого числа разработчиков. Но образы контейнеров занимают много места, и качать некоторые из них довольно долго. Зачастую хочется иметь свой или хотя бы локальный реестр. Да и безопаснее это... Наверное?
Зная, что в Docker private registry отсутствует авторизация, я решил разобраться, как легким способом можно блокировать различные действия для клиентов и разрешать все админам. Мне нужно было ограничить получение какой-либо информации по репозиториям, находящимся в реестре, выгрузку образов из реестра, а также запретить загрузку репозиториев, которые уже существуют в реестре. Начнем с основы основ — загрузки и выгрузки образов с помощью API v2.
Выгрузка образа из реестра
Подробную информацию по выгрузке образа из реестра можно посмотреть тут. Если тезисно, так выглядит выгрузка из реестра.
-
HEAD /v2/<name>/manifests/<tag>
Проверка на наличие манифеста образа (два раза происходит запрос, потому что первый запрос без авторизации, а второй с авторизацией).
-
GET /v2/<name>/manifests/<digest>
Загрузка json-файла манифеста.
-
GET /v2/<name>/blobs/<digest>
Загрузка блобов, указанных в манифесте.
На запросы по манифестам может возвращаться ошибка, в которой будет указано, что не совпадает Accept, поэтому в своих запросах следует добавлять сразу несколько типов:
application/vnd.oci.image.manifest.v1+json,
application/vnd.oci.image.index.v1+json,
application/vnd.docker.distribution.manifest.v2+json,
application/vnd.docker.distribution.manifest.list.v2+json,
application/vnd.docker.distribution.manifest.v1+json
Подробно посмотреть описание типов манифестов можно здесь для Schema 1 и тут для Schema 2.
Также стоит сказать, что если сборка выполнялась как мультиплатформенная, то в ответе на HEAD запрос манифеста мы получим один из этих Content-Type:
application/vnd.docker.distribution.manifest.list.v2+json,
application/vnd.oci.image.index.v1+json
Внутри загруженного манифеста будет храниться информация об отдельных манифестах для каждой платформы. Пример такого манифеста:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 1234,
"digest": "sha256:abcdef1234567890",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 1234,
"digest": "sha256:123456abcdef7890",
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}
Реестр использует content-based addressing. Чтобы получить блоб, нужно запросить его по хешу. Используются различные схемы хеширования: sha256
и tarhash
. Чаще всего используется первый вариант, именно это и лежит в digest
.
После этого выполняется загрузка манифеста платформы и блобов, указанных в нем, аналогично загрузке сингл-платформы.
Загрузка образа в реестр
На том же ресурсе можно посмотреть загрузку образов, но, как и про выгрузку, указано только про сингл-платформу. Так давайте посмотрим, как выглядит загрузка мультиплатформенного образа.
-
HEAD /v2/<name>/blobs/<digest>
Проверка на наличие блобов в реестре.
-
POST /v2/<name>/blobs/uploads/
Инициализация загрузки блобов.
-
POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<name source repo>
Если в реестре уже есть блоб, то происходит маунт уже существующего блоба в реестре в новый репозиторий.
-
PUT /v2/<name>/blobs/uploads/<uuid>?digest=<digest>
Загрузка блобов в реестр.
Когда у нас загружается сингл-платформа, то один раз проверяется наличие манифеста и дальнейшая его загрузка:
HEAD /v2/<name>/manifests/<tag>
PUT /v2/<name>/manifests/<tag>
Но если загружается образ, собранный как мультиплатформенный, то, как видно из снапшота Wireshark, сначала загружаются манифесты на отдельные платформы, после чего загружается манифест, хранящий в себе информацию об этих манифестах.
Что уже есть
Протокол обмена образами имеет публичную спецификацию. Хочешь — садись, пиши свой реестр. Если лень, на помощь приходит Distribute (для тех, у кого нужды попроще) или Harbor и т. п. (для тех, кому надо энтерпрайзно). Также есть вариант с поднятием сервера внутри кластера. Но чего-то между ними по шкале сложность/возможности нет. А Distribute плюшек не имеет совсем никаких. Можно поставить логин и пароль, но разграничения ресурсов это не даст. А нам надо было именно оно. Я выбрал простой вариант и реализовал прокси-сервер, написанный на Python, который можно развернуть у себя в кластере. Код посмотреть можно здесь.
Немного по коду
Обсуждать открытие сокетов я не буду, просто отмечу интересные для меня моменты:
-
Для обработки http-пакетов я решил написать небольшой класс:
Функция
modify_auth
нужна для подмены кредов внутри пакета на админские для дальнейшей отправки их в реестр.
class ParserHttp:
def __init__(self, data):
self.raw_data = data
self.method = None
self.url = None
self.protocol = None
self.headers = None
self.parse()
def parse(self):
start_line, headers = self.raw_data.split(b'\r\n', 1)
self.method, self.url, self.protocol = start_line.split(b' ', 2)
self.headers = BytesParser().parsebytes(headers)
def modify_auth(self, new_auth, orig_auth):
return self.raw_data.replace(orig_auth, new_auth)
Как проверить, что репозиторий в реестре? Популярный вопрос, ответ на который с годами обновляется, так как добавляются новые версии сборок, схемы, а также мультиплатформенные сборки. Все ответы объединяет проверка манифестов. Как выглядит обычный запрос на наличие манифеста:
-
HEAD https://<name registry>/v2/<name repo>/manifests/latest
Нужно не забывать добавлять в хедеры нужные
Accept
-ы в зависимости от того, какой манифест залит. Я особо не заморачиваюсь и просто перечисляю все возможныеAccept
-ы.На каждый запрос я проверяю наличие репы в реестре — это слишком долго и в разы увеличивает время исполнения программы, поэтому я кэширую полученный ответ и удаляю его каждые 10 секунд (время не обязательно должно быть таким, выбрано по субъективному мнению), таким образом, если я уже проверил наличие репозитория в реестре, то ближайшие 10 секунд я этого делать не буду.
def is_repo_in_registry(self, s, repo_name):
if repo_name in self.registry_cache:
time_delta = datetime.now() - self.registry_cache[repo_name]['last_updated']
if time_delta < timedelta(seconds=10):
return self.registry_cache[repo_name]['val']
del self.registry_cache[repo_name]
req_msg = (b'HEAD /v2/' + repo_name + b'/manifests/latest HTTP/1.1\r\n'
b'Host: ' + HOST_REGISTRY + b':' + PORT_REGISTRY + b'\r\n'
b'Authorization: ' + ORIG_CRED + b'\r\n'
b'User-Agent: curl/8.7.1\r\n'
b'Accept:'
b' application/vnd.oci.image.manifest.v1+json,'
b' application/vnd.oci.image.index.v1+json,'
b' application/vnd.docker.distribution.manifest.v2+json,'
b' application/vnd.docker.distribution.manifest.list.v2+json,'
b' application/vnd.docker.distribution.manifest.v1+json\r\n'
b'\r\n')
self.channel[s].send(req_msg)
resp = b''
while True:
r_ready, _, _ = select.select([self.channel[s]], [], [], 0.1)
if len(r_ready) == 0:
break
data = self.channel[s].recv(BUFFER_SIZE)
if data:
resp += data
if resp.split(b' ')[1] == b'404':
self.registry_cache[repo_name] = {'last_updated': datetime.now(), 'val': False}
return self.registry_cache[repo_name]['val']
self.registry_cache[repo_name] = {'last_updated': datetime.now(), 'val': True}
return self.registry_cache[repo_name]['val']
В ответ на запросы, которые надо блокировать, я просто отправляю ответ с ошибкой
401 Unauthorized
.
BAD_RESPONSE = (b'HTTP/1.1 401 Unauthorized\r\n'
b'Content-Type: application/json; charset=utf-8\r\n'
b'Docker-Distribution-Api-Version: registry/2.0\r\n'
b'Www-Authenticate: Basic realm="Registry Realm"\r\n'
b'X-Content-Type-Options:nosniff\r\n'
b'Content-Length: 48\r\n'
b'\r\n'
b'{"errors": "permission denied for PULL and GET"}\r\n'
b'\r\n')
Так что блокировать?
Выше я уже расписал последовательность запросов как для загрузки, так и для выгрузки образов. Исходя из этой информации, я блокирую любой запрос GET
, кроме запроса GET /v2/
, так как этот запрос используется для проверки доступности Docker Registry API версии 2 и определения, поддерживается ли данный API сервером. Этот запрос не требует никакого дополнительного контекста или заголовков и выполняется для того, чтобы убедиться, что реестр работает и использует Docker Registry HTTP API v2. Также при любом запросе HEAD
, PUT
, POST
я проверяю наличие репозитория и, в случае если репозиторий уже есть, то я блокирую эти сообщения.
Но есть одно но...
Давайте рассмотрим ситуацию, когда несколько пользователей будут загружать в реестр образ с одним названием.
Если слои образа уже существуют: Docker Registry работает по принципу дедупликации слоев. Если какой-либо из слоев образа (или все слои) уже существуют в реестре, Docker Registry не будет перезаписывать их, а просто повторно использует существующие слои. Это позволяет экономить пространство и избегать повторной загрузки тех же данных.
В таких случаях:
Загрузка слоев: Если слой с таким же
digest
уже существует в реестре, сервер вернет код ответа201 Created
, но реестр фактически не будет повторно загружать уже существующий слой. Это происходит потому, что слои идентифицируются с помощью их контрольной суммы (SHA-256), и если слой уже присутствует в реестре, он считается загруженным.Загрузка манифеста: Если вы загружаете манифест образа с новым тегом, даже если все слои уже существуют, реестр примет новый тег и создаст его, связывая с существующими слоями. Ответ будет
201 Created
.
Если тег образа уже существует: Тег (например, latest
или любой другой) указывает на конкретный манифест и его слои. Если вы загружаете образ с тем же тегом, что уже существует в реестре, то:
Загрузка нового манифеста: Если у вас новый манифест (например, у вас изменились слои или метаданные образа), новый манифест будет перезаписан по тому же тегу. В этом случае старый манифест будет замещен новым. Сервер ответит кодом
201 Created
.Если манифест не изменился: Если манифест точно такой же, как уже существующий по этому тегу (то есть все слои и метаданные те же), реестр воспримет это как успешную операцию, но фактически ничего не изменит. Он также может вернуть код
201 Created
, даже если никаких новых данных не было загружено.
Исходя из всего этого я делаю вывод, что в случае одновременной загрузки побеждает тот, кто загрузит манифест последним. Это стандартное поведение докера, и я в своем коде не устраняю эту особенность. Но есть решение...
Если вы уверены, что будут загружаться только образы, которые собраны как сингл-платформа, то, не дожидаясь 10 секунд для очистки кэша, можно сразу удалять запись о репозитории в кэше, и в итоге следующий запрос на обновление манифеста будет отклонен, так как проверку на репозиторий в реестре он уже не пройдет.
-
Более реалистичный вариант, когда мы не знаем, какие образы нам будут заливать. Тут уже сильно сложнее... Когда образ собран как мультиплатформенный, он представляет собой набор манифестов (по одному для каждой платформы, например для
linux/amd64
,linux/arm64
и т. д.), а также «манифест-список» (manifest list или manifest index), который связывает эти платформозависимые манифесты. Количество загружаемых манифестов при загрузке мультиплатформенного образа зависит от того, сколько платформ поддерживает ваш образ и как настроена сборка. Рассмотрим пример:Манифест-список (index): Один манифест для всего мультиплатформенного образа.
Платформозависимые манифесты: По одному манифесту для каждой платформы (например,
linux/amd64
,linux/arm64
,windows/amd64
).
Пример расчета:
Если ваш мультиплатформенный образ поддерживает платформы
linux/amd64
,linux/arm64
,windows/amd64
, то количество манифестов, которые будут загружены в реестр, будет таким:Один манифест-список (manifest list), который объединяет все платформы.
Три платформозависимых манифеста (по одному на каждую из поддерживаемых платформ:
linux/amd64
,linux/arm64
,windows/amd64
).
Итого: 4 манифеста (1 манифест-список (index) + 3 манифеста для платформ).
Вот, как это выглядит наглядно (картинка взята из документации).
В случае когда загрузка происходит с помощью клиентов Docker, Podman... в основном они перед загрузкой в целях оптимизации делают на каждый манифест
HEAD
-запрос перед их загрузкой. Таким образом, можно считать количество отправленныхHEAD
-запросов, пропускать столько же загрузок манифестов и на последнем манифесте (list или index) уже очищать кэш.
В нашем случае используется вариант, когда загрузка может быть как сингл-, так и мультиплатформенных образов. В своем коде я это не реализовал, так как остается проблема, если пользователь решит загружать образ без использования докер-клиентов, что влечет за собой неизвестное использование HEAD
-методов.
Как оно работает внутри кластера
Для начала я поднимаю Docker Registry. Подробно пошагово, как развернуть реестр внутри кластера, написано тут.
Свою проксю я запускаю в отдельном поде, контейнер основан на python:3.11-slim-bookworm
:
apiVersion: apps/v1
kind: Deployment
metadata:
name: proxy-to-registry
spec:
replicas: 1
selector:
matchLabels:
app: proxy-to-registry
template:
metadata:
labels:
app: proxy-to-registry
spec:
containers:
- name: proxy-to-registry
image: docker.io/python:3.11-slim-bookworm
env:
- name: LISTEN_PORT
value: "{{ .Values.portProxyServer }}"
- name: HOST_REGISTRY
value: "{{ .Values.hostProxyServer }}"
- name: PORT_REGISTRY
value: "{{ .Values.portDockerRegistry }}"
- name: PORT_REGISTRY_SERVICE
value: "{{ .Values.portDockerService }}"
- name: REGISTRY_ADRESS
value: proxy-registry-service
- name: CRED_USERS
value: "{{ .Values.usersAuthorization }}"
- name: CRED_ROOT
value: "{{ .Values.rootAuthorization }}"
imagePullPolicy: {{ .Values.imageProxy.pullPolicy }}
Чтобы связаться с подом, на котором работает прокся, я создал сервис NodePort
:
apiVersion: v1
kind: Service
metadata:
name: client-proxy-service
spec:
type: NodePort
selector:
app: proxy-to-registry
ports:
- protocol: TCP
port: 80
targetPort: 30113
nodePort: 30113
Docker Registry также работает в отдельном поде внутри кластера, и чтобы соединить эти два пода, нужно создать сервис ClusterIP
:
apiVersion: v1
kind: Service
metadata:
name: proxy-registry-service
spec:
type: ClusterIP
selector:
app: docker-registry
ports:
- protocol: TCP
port: 5003
targetPort: 5000
Таким образом, через порт 30113
происходит соединение с проксей, которая, в свою очередь, по адресу proxy-registry-service:5003
попадает в под с реестром.
Не совсем по теме, но я уверен, что кому-то будет полезно узнать, что для того, чтобы создать контейнер с образом из Docker Registry Private внутри кластера, в spec нужно добавить imagePullSecrets
с секретом, который создается для Docker Registry:
spec:
imagePullSecrets:
- name: test-secret
containers:
- name: test
image: "{{ image_name }}"
Как все выглядит наглядно
Пример запроса курлом на список репозиториев в реестре: сначала выполняется запрос с кредами пользователя, затем с админскими кредами, в конце — рандомный пользователь.
Загрузка образа в реестр.
Выгрузка образа из реестра.
Заключение
В этой статье я решил собрать информацию из разных источников в одном месте и добавить своих наблюдений. Код с проксей можно использовать как отдельный скрипт или разворачивать внутри своего кластера. Если в дальнейшем понадобится работать с реестром по HTTPS или добавить больше пользователей, то код легко модернизировать и можно использовать в качестве базы.
project_delta
Хмм, умные люди давно уже придумали Harbor для хранения образов. И в нем уже есть и права доступа и gui и все остальное. И самый главный момент - он ставится через helm чарт в тот же самый кубер
soko1ov_dmitry Автор
В главе "Что уже есть" я про это как раз описал, а также указал, почему его не использовал.
kozlyuk
На самом деле, "почему" как раз не раскрыто. Ладно, Harbor и правда тяжелый и не особо гибкий при этом. Но почему не auth server, пусть даже свой, чтобы быть попроще cesanta/docker_auth? Прокси на Python неэффективен в работе с сетью; большая часть кода занимается HTTP и кэшированием, а не auth.
soko1ov_dmitry Автор
Я предложил свой вариант для того, чтобы показать, что ничего сложного в разграничении ресурсов реестра нет, предоставив собранную информацию в одном месте со своими дополнениями и замечаниями.