docker deploy ssh registry devops

Быстрое развертывание Docker-образов на сервере без стороннего реестра

Статья описывает технику доставки Docker-образа на боевой сервер при помощи локального Docker реестра и SSH тунеля

Я часто работаю с серверами, на которых приложения работают в Docker. А разработка происходит у меня на локальной машине. После завершения очередного обновления, мне нужно собрать новые Docker-образы, загрузить их на сервер и перезапустить приложение из новых контейнеров. В больших компаниях используют внешние CI/CD инструменты типа GitLab для выполнения Docker Build. Плюс отдельный реестр контейнеров для хранения Docker-образов, например Yandex Container Registry. Но всё это дорого, долго, сложно и подходит в большей степени для команд, чем индивидуальных разработчиков. В конце концов мне нужно только перекинуть новый образ на сервер и перезапустить приложение. Как же быть?

Самодельное решение

Можно собирать Docker-образы прямо на сервере, но для этого нужно достаточно мощностей, трафика и пространства на диске. Docker Build, довольно затратная операция и может повесить ваш боевой сервер на время выполнения сборки, например отдав этой операции всё процессорное время вместо ответов на запросы клиентов. Поэтому собирать я решил на своей локальной машине, у меня i7 и зачем ему простаивать без дела.

Я решил написать простой Bash скрипт, который запускает тесты, собирает новые Docker-образы, экспортирует образ из моего локального демона Docker в tar-архив, через scp загружает на сервер, там распаковывает и импортирует в демон Docker на удаленном сервере. После этого приложение перезапускается загружая новые версии.

 1#!/bin/bash
 2
 3# Останавливать выполнение скрипта на ошибке
 4set -e
 5
 6DEPLOY_SERVER="myserver"
 7DEPLOY_PATH="/home/admin/myapp"
 8
 9DOCKER_IMAGE="myapp:latest"
10ARCHIVE_NAME="myapp.tar"
11
12# Функция запуска тестов
13function run_tests() {
14  echo "Запускаем Unit тесты..."
15  # Запустить Unit тесты (set -e автоматически остановит при ошибке)
16  npm run test
17  
18  echo "Запускаем E2E тесты..."
19  # Выполнить e2e тесты
20  npm run test:e2e
21  
22  echo "Все тесты прошли успешно!"
23}
24
25# Загрузка Docker-образа на сервер
26function deploy_docker_image() {
27  # Экспортировать Docker-образ в tar-архив
28  docker save -o ./${ARCHIVE_NAME} ${DOCKER_IMAGE}
29  
30  # Передать образ на сервер
31  scp ./${ARCHIVE_NAME} ${DEPLOY_SERVER}:${DEPLOY_PATH}
32  
33  # Загрузить образ на сервере
34  ssh -t ${DEPLOY_SERVER} << EOF
35docker load -i ${DEPLOY_PATH}/${ARCHIVE_NAME}
36rm ${DEPLOY_PATH}/${ARCHIVE_NAME}
37EOF
38  
39  # Очистка локального архива
40  rm ./${ARCHIVE_NAME}
41  echo "Образ ${DOCKER_IMAGE} успешно передан на сервер и загружен в демон Docker."
42}
43
44# Перезапустить сервисы через Docker Compose
45function restart_services() {
46  ssh -t ${DEPLOY_SERVER} << EOF
47cd ${DEPLOY_PATH}
48docker compose down
49docker compose up -d
50EOF
51  echo "Docker Compose сервисы успешно перезапущены."
52}
53
54function main() {
55  # Запустить тесты
56  run_tests
57  
58  # Собрать Docker-образы
59  docker compose build
60  
61  # Деплой образов
62  deploy_docker_image
63  
64  # Перезапустить сервисы
65  restart_services
66}
67
68# Запуск основной логики
69main

Скрипт получился приемлемый, свою работу он выполняет. Для статьи я его немного упростил, в моём случае он работает с несколькими сервисами. Но постепенно, при частых деплоях мне приходилось ждать довольно долго отчасти из-за времени сборки и тестов, но в основном от передачи данных на сервер. Несмотря на использование alpine и multi stage build при сборке Dockerfile. Объем образа в итоге, к сожалению, не получался менее 300МБ вследствие большого фреймворка NestJS. Но процесс требовалось ускорить, ждать каждый раз загрузку очень утомительно.

$ time deploy.sh
real    0m53.094s
user    0m2.412s
sys     0m2.128s

Docker-образ состоит из слоев (Docker Layers), и по сути каждая команда в Dockerfile создает новый слой. Их кстати можно просмотреть с помощью утилиты dive и понять, какие слои самые тяжелые. Это нужно, чтобы при скачивании образа с реестра, скачивались только новые или измененные слои, в итоге экономия на трафике и ускорение процесса. Например, если вы уже скачивали какой-то образ, созданный на базе ubuntu:22.04, то при скачивании другого образа, который тоже основан на этом образе, вы скачаете только дополнительные слои. А основной образ возьмете из локального кеша.

Интерфейс dive для анализа слоев Docker-образа

Интерфейс утилиты dive для анализа слоев и размеров Docker-образа

Осталось понять, где поднять реестр. Арендовать третий сервер мне совсем нехотелось — это обслуживание и цена. Размещать на боевом сервере тоже не вариант, там и так места мало, да и не для этого он. Платить облачным реестрам тем более. Я стал размышлять и придумал, что реестр можно поднять локально, благо Docker легко позволяет это сделать при помощи официального образа registry. Ничего сложного: поднимаете локальный реестр в том же самом Docker (Docker в Docker), обычно на порту 5000, делаете тег на ваш образ типа “localhost:5000/myapp:latest” и он загружается в ваш локальный реестр вместо стандартного Docker Hub. Выгружать так же, через указание адреса реестра в имени Docker-образа.

# Запускаем локальный реестр
$ docker run -d -p 5000:5000 --name registry registry:2

# Собираем образ с тегом для локального реестра
$ docker build -t localhost:5000/myapp:latest .

# Или присваиваем тег на существующий образ
$ docker tag myapp:latest localhost:5000/myapp:latest

# Загружаем в локальный реестр
$ docker push localhost:5000/myapp:latest

# Скачиваем из локального реестра
$ docker pull localhost:5000/myapp:latest

Но как заставить удаленный сервер скачивать с нашего локального реестра? Можно просто пробросить порт через SSH. SSH поддерживает два типа port forwarding, один на локальную машину с сервера (-L), а другой наоборот, с сервера на локальную машину (-R). Подробнее про магию SSH можно прочитать на хабре в статье Магия SSH Т.е. когда на сервере демон Docker будет скачивать образ с тегом localhost:5000/my_image:latest, он на самом деле будет скачивать его с нашего локального 5000 порта на котором работает реестр Docker. И вследствие этого скачивать только необходимые слои, ускоряя процесс и оптимизируя трафик.

Внимание: такой подход открывает доступ к вашему локальному реестру с удаленного сервера.

# Проброс порта 5000 с сервера на локальную машину
$ ssh -R 5000:localhost:5000 user@server

Я переписал свой скрипт с использованием новой техники и получил ускорение в 5 раз! При этом не использовав никаких сложных инструментов и платных решений.

$ time deploy.sh
real    0m10.449s
user    0m3.550s
sys     0m2.210s

Обновленный скрипт с использованием локального реестра.

 1#!/bin/bash
 2
 3# Останавливать выполнение скрипта на ошибке
 4set -e
 5
 6DEPLOY_SERVER="myserver"
 7DEPLOY_PATH="/home/admin/myapp"
 8
 9DOCKER_IMAGE="myapp:latest"
10REGISTRY_IMAGE="localhost:5000/myapp:latest"
11REGISTRY_PORT="5000"
12
13# Функция запуска тестов
14function run_tests() {
15  echo "Запускаем Unit тесты..."
16  # Запустить Unit тесты (set -e автоматически остановит при ошибке)
17  npm run test
18  
19  echo "Запускаем E2E тесты..."
20  # Выполнить e2e тесты
21  npm run test:e2e
22  
23  echo "Все тесты прошли успешно!"
24}
25
26# Запуск локального реестра Docker
27function start_local_registry() {
28  echo "Запускаем локальный реестр Docker..."
29
30  docker run -d -p ${REGISTRY_PORT}:5000 --name registry registry:2
31}
32
33# Загрузка Docker-образа через локальный реестр
34function deploy_docker_image() {
35  echo "Назначаем тег для локального реестра..."
36  docker tag ${DOCKER_IMAGE} ${REGISTRY_IMAGE}
37  
38  echo "Загружаем образ в локальный реестр..."
39  docker push ${REGISTRY_IMAGE}
40  
41  echo "Проброс порта и загрузка образа на сервере..."
42  ssh -R ${REGISTRY_PORT}:localhost:${REGISTRY_PORT} ${DEPLOY_SERVER} << EOF
43docker pull ${REGISTRY_IMAGE}
44docker tag ${REGISTRY_IMAGE} ${DOCKER_IMAGE}
45EOF
46  
47  echo "Образ ${DOCKER_IMAGE} успешно передан на сервер через локальный реестр."
48}
49
50# Перезапустить сервисы через Docker Compose
51function restart_services() {
52  ssh -t ${DEPLOY_SERVER} << EOF
53cd ${DEPLOY_PATH}
54docker compose down
55docker compose up -d
56EOF
57  echo "Docker Compose сервисы успешно перезапущены."
58}
59
60# Очистка локального реестра
61function cleanup_registry() {
62  echo "Очищаем и останавливаем локальный реестр..."
63
64  docker stop registry && docker rm registry
65}
66
67function main() {
68  # Запустить локальный реестр
69  start_local_registry
70  
71  # Запустить тесты
72  run_tests
73  
74  # Собрать Docker-образы
75  docker compose build
76  
77  # Деплой образов через локальный реестр
78  deploy_docker_image
79  
80  # Перезапустить сервисы
81  restart_services
82  
83  # Очистка (опционально)
84  # cleanup_registry
85}
86
87# Запуск основной логики
88main

Готовое решение

Мне стало интересно, не использовал ли кто-то еще такой подход. Когда я пытался найти что-то подобное через ИИ, он мне выдавал все что угодно, кроме этой простой идеи. После этого я еще раз описал ему подход, и он смог найти мне проекты, одним из самых серьезных был unregistry. Который тоже реализует эту идею, но делает это более красиво. Написал его @psviderski, а назвал unregistry, что является игрой слов на реестр Docker, как бы показывая, что реестра на самом деле там нет.

Идея та же: поднимается собственная реализация реестра в контейнере, но на удаленном сервере. Это исключает риски reverse port forwarding — когда сервер может случайно получить доступ к локальным сервисам через проброшенный порт, т.е. использует $ ssh -L ... Собственная реализация позволяет ему быть временным: образы не сохраняются, как в обычном реестре на диск, а сразу записываются в демон Docker и удаляются.

Еще он реализовал это решение в виде плагина docker, и вызывается он как docker pussh (вероятно, push + ssh). Теперь и я пользуюсь его решением, это еще более упростило мой скрипт развертывания. Стоит правда заметить, что используя его решение, вы не можете до конца быть уверены в его безопасности. Поскольку для реестра Docker используется собственная реализация, и по идее там может быть всё что угодно. Хоть весь код и размещен на GitHub. Поэтому если вам важна безопасность и конфиденциальность, то реализуйте своё решение.

$ docker pussh myapp:latest myserver

В целом я остался доволен и своей идеей, и реализацией этой идеи этого разработчика. Загрузка на сервер теперь стала удобной, быстрой. Осталось придумать, как реализовать удобный механизм версий с возможностью отката (rollback), чтобы вышло что-то вроде полноценной локальной системы деплоя.

Для команд лучше использовать GitLab CI/CD, Jenkins или российские облачные платформы типа Yandex Cloud.

Нужна помощь с инфраструктурой?

Проведу аудит вашей системы и подберу оптимальное решение

Связаться со мной