Продолжая серию постов про Eurobot, рассказываю про следующую деталь нашей инфраструктуры — доставку Docker образов до роботов в обход интернета и удаленных Docker Registry.
В предыдущем посте я рассказал о том, как мы собирали Docker образы и как контейнеры запускались на роботах. В этот раз покажу, как эти самые образы по Wi-Fi доставлять с машин разработчиков на роботов.
Для опытных бэкендеров скорее всего материал поста не будет новым. Для меня же большая часть описанного здесь в свое время стала откровением.
Именно так выглядит деплой
Мотивация
Во время подготовки к соревнованиям мы использовали как классический подход доставки образов, через Docker Hub, так и эзотерический — по локальной сети.
Первый способ мы использовали в основном, загружая в Docker Hub стабильные образы, собранные из ветки master на CI в нашем репозитории. Мы точно знали что в публичном репозитории находятся рабочие образы и иногда откатывались к ним.
По локальной сети же мы деплоили в 99% случаев, находясь физически в лабе и отлаживая роботов вечерами после пар.
Таким образом, заливать образы минуя интернет я хотел по двум причинам:
- Это тупо быстрее и удобнее.
- На соревнованиях, куда мы приедем, может не быть интернета или он может быть нестабилен.
Самое простое решение
Первое решение, которое мне пришло в голову, пришло в голову кому-то еще до меня. Заключалось решение в следующем:
- Собираем Docker образ.
- Экспортируем образ в tar архив.
- Отправляем tar архив при помощи
rsync
илиscp
на робота. - На роботе импортируем tar архив обратно в образ.
Решение железное и супер понятное, но во всем остальном оно ужасно:
- Вы тратите время не только на сборку образа, но и на его экспорт. Экспорт ROS образов у нас занимал 20-30 секунд.
- Нельзя отправить только изменившиеся слои образа, придется каждый раз отправлять ВЕСЬ образ.
- Образы с ROS весят больше гигабайта, и даже архивирование их не спасает. Передача таких тяжелых образов сама по себе будет съедать пару минут времени (я проверял).
Я реализовал этот способ, понял что он работает ужасно долго и для нас он не подходит. Поэтому тратить буквы на него не буду, сразу перейду к объяснению финального решения.
Самое сложное решение
Если вы повторяете описанные здесь шаги на MacOS, вам нужно знать кое-что важное.
Оптимальное решение работает следующим образом:
- Собираем образ локально на своем лэптопе/ПК/микроволновке.
- По SSH запускаем на роботе Docker Registry.
- Поднимаем SSH туннель между вашей машиной и роботом, чтобы Docker Registry робота стал доступен в локальной сети.
- Пушим собранный вами локально образ в Docker Registry, расположенный на роботе.
- Освобождаем неиспользуемые ресурсы.
Прежде чем проделывать все эти шаги, нужно кое-что настроить на своей машине. Но самое главное — на роботе должен быть установлен Docker, и запускаться он должен без sudo (Manage Docker as a non-root user).
Шаг 0: Предварительная настройка
Настраиваем SSH-ключи
Я настоятельно рекомендую один раз выполнить этот пункт, в противном случае работоспособность кода ниже уже будет под вопросом, а удобство использования гарантированно пострадает.
SSH-ключи, это более безопасная альтернатива паре логин/пароль, позволяющая один раз "зарегистрировать" ваш лэптоп в пямяти робота и далее входить с того же устройства уже без ввода пароля. Нормальное объяснение SSH-ключей: ТЫК.
Проверяем что ключ уже есть
Проверить, что у вас уже есть SSH-ключ на устройстве, можно при помощи команды ниже:
ls ~/.ssh/
Если в списке файлов будут id_rsa
и id_rsa.pub
, новый ключ генерировать не нужно.
Генерируем SSH-ключ
Если у вас еще нет SSH ключа на устройстве, нужно выпустить новый. Команда для выпуска ключа следующая:
ssh-keygen -t rsa
Пароль для SSH-ключа ставить не надо.
Регистрируем SSH-ключ на роботе
В команде ниже:
user
— имя юзера в ОС робота. У нас в RESET имя юзера всегда былоnuc
.host
— IP-адрес робота в локальной сети, например192.168.1.64
.
Код для Linux/MacOS:
ssh-copy-id user@host
Код для Windows Powershell немного отличается:
type $env:USERPROFILE\.ssh\id_rsa.pub | ssh user@host "cat >> .ssh/authorized_keys"
Docker BuildKit
BuildKit это новый бэкенд для Docker, который нам нужен конкретно из-за своей способности билдить образы под разные архитектуры процессоров.
Убедитесь что на вашем компе установлен один из трех вариантов софта с BuildKit внутри:
- Docker Desktop — рекомендую ставить именно его и не париться.
- Docker версии 23.0 и выше.
- Старая версия Docker с установленным BuildKit — хз зачем вам вообще может понадобится эта опция.
Создание и настройка BuildKit Builder
Builder — Docker контейнер с эмулятором, который умеет собирать Docker образы под разные архитектуры процессоров. Если вы уже установили Docker, то один билдер с именем default
у вас уже автоматически создан.
Нам нужно создать новый билдер и передать в него наш конфиг buildkitd.default.toml
:
docker buildx create \
--name eurobot-builder \
--config buildkitd.default.toml \
--bootstrap --use
Опция --bootstrap
сразу запустит билдер, опция --use
сделает его билдером по-умолчанию.
Заводить свой билдер нужно из-за того, что из коробки Docker не позволяет отправлять образы куда-либо, кроме защищенных цифровой подписью репозиториев в интернетах. В конфиге buildkitd.default.toml
разрешаем пушить образы по адресу localhost:5000
:
[registry."host.docker.internal:5000"]
http = true
Вы можете возмутиться: откуда тут host.docker.internal:5000
?
Ответ: билдеры это обычные Docker контейнеры, им недоступна ваша локальная сеть по адресу localhost
. Чтобы обойти эту проблему, в Docker есть специальный алиас host.docker.internal
, ведущий в "настоящую" локальную сеть вашего компьютера. Про алиас и конкретно наш юзкейс можно почитать подробнее в документации Docker.
Шаг 1: Собираем образ локально
Наконец-то собираем образ. Для этого в директории с Dockerfile
запускается примерно вот такой код:
docker buildx build \
--platform linux/amd64 \
--tag eurobot2023:latest \
--output type=image,push=false \
.
Разберем по порядку:
- Запускаем билд с помощью
docker buildx build
— явно сообщаем Docker чтобы он использовал BuildKit. - Опция
--platform
определяет, под какую архитектуру будет сбилжен образ. Билдить надо под архитектуру компьютера, который установлен на роботе. Например:- Intel NUC это
linux/amd64
, - Raspberry Pi 3B+ это
linux/arm/v7
, - Raspberry Pi 4 и Nvidia Jetson Nano это
linux/arm64
и так далее.
- Intel NUC это
- Опция
--output
позволяет указать, что делать с результатом билда. В нашем случае этоtype=image,push=false
— собираем OCI образ, никуда не пушим, в Docker не экспортируем:- Не тратится время на экспорт образов в Docker. Так общее время билда сокращается.
- Сбилженные образы хранятся в кэше, их не видно в приложении Docker Desktop или при вызове
docker images
. Таким образом частый билд не засирает вам список ваших образов.
Шаг 2: Поднимаем Docker Registry на роботе
Docker Registry дает одно из основных преимуществ подхода — инкрементальную отправку образов. Docker во время отправки общается с Registry и узнает какие слои образов надо отправлять, а какие нет. Нам нужно только подключиться к этому Registry, остальное будет сделано за нас.
Я использовал примерно вот такой код, для того чтобы через SSH запустить на роботе Registry:
ssh -o ConnectTimeout=5 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
$ROBOT_HOSTNAME "
docker run -d \
-v /etc/docker-push-ssh/registry:/var/lib/registry \
--name registry \
--restart always \
-p 127.0.0.1:5000:5000 \
registry:2 || true
"
На Windows нет
/dev/null
, зато есть\\.\NUL
.
Разбираем по порядку:
Подключаемся по SSH к
$ROBOT_HOSTNAME
. Это как разuser@host
из пункта про SSH-ключи — напримерnuc@192.168.1.64
.Опцию
-o ConnectTimeout=5
вы и сами сможете нагуглить.Опции
-o StrictHostKeyChecking=no
и-o UserKnownHostsFile=/dev/null
нужны для автоматического принятия RSA ключей клиентом SSH. Это сделано для удобства, чтобы вас никто не спрашивал "ДЕЙСТВИТЕЛЬНО ХОТИТЕ ПОДКЛЮЧИТЬСЯ?" и для того чтобы у вас не разрастался файлknown_hosts
при смене IP адресов роботов.После установки соединения мы просто запускаем на роботе контейнер с Docker Registry. По-умолчанию Registry работает на порте 5000, при помощи биндинга
-p 127.0.0.1:5000:5000
мы делаем его доступным только в локальной сети.Образ с Registry весит в районе 20 мегабайт и в режиме бездействия не потребляет фактически никаких ресурсов. Так что если он будет все время работать, ничего плохого не произойдет даже на слабых компах типа Raspberry 3B+. Кстати, вместо
|| true
можно придумать более элегантное решение.
Шаг 3: Поднимаем SSH туннель между роботом и компьютером
SSH-туннель нужен для того чтобы сущности, доступные только в локальной сети робота стали доступны в локальной сети вашего компьютера. Если вы играли с друзьями в компик по Hamachi, можете воспринимать SSH-туннель как что-то подобное, но это все-таки другое.
Используем утилиту SSH:
ssh -N \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-L *:5000:localhost:5000 \
$ROBOT_HOSTNAME \
& echo $! > SSHPID
Разбираем что происходит:
- Подключаемся по SSH к
$ROBOT_HOSTNAME
(кuser@host
). - Опция
-N
позволяет не запускать никакую команду. - Опция
-L *:5000:localhost:5000
пробрасывает порты из сети нашего компьютера в локальную сеть робота. Таким образом нам будет доступен Docker Registry, запущенный на роботе. - Сам запуск ssh блокирует терминал, поэтому используем
&
чтобы запустить процесс в фоне и сразу записываем ID процесса в файлSSHPID
для того чтобы закрыть тоннель после деплоя.
На Windows, в PowerShell до 7 версии, конструкция
& echo $! > SSHPID
работать не будет. В рамках туториала можно просто запустить ssh, после чего продолжить работу в новом терминале.
Бонус: проверяем что все работает
Чтобы проверить, что SSH туннель успешно поднят, можно "пингануть" с вашего компьютера Registry на роботе:
curl -Is http://localhost:5000/v2/
Что выведет curl в случае успешного поднятия туннеля:
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/json; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
X-Content-Type-Options: nosniff
Date: Mon, 04 Sep 2023 20:02:21 GMT
Если делать этот запрос в цикле, можно дождаться установки соединения и только потом продолжать деплой.
Шаг 4: Пушим образ в Docker Registry на роботе
Приступаем к передаче образа на робота. Для этого используем ту же самую команду, что и для билда, но с двумя модификациями:
--output type=registry
.--tag host.docker.internal:5000/eurobot2023:latest
.
docker buildx build \
--platform linux/amd64 \
--tag host.docker.internal:5000/eurobot2023:latest \
--output type=registry \
.
Так как с момента предыдущего билда в Шаге 1 ничего не изменилось и все слои из докерфайла уже есть в кэше, стадия билда будет пропущена. Начнется загрузка образа в Docker Registry робота.
Почему это вообще работает:
- Опция
--output type=registry
указывает пушить в Registry после сборки образа. - В тег образа мы добавляем префикс
host.docker.internal:5000/
для того чтобы BuildKit билдер пушил образ в Registry, который доступен из нашей локальной сети. Если указатьlocalhost:5000/
, то отправка не заработает, ибоlocalhost
внутри контейнера с билдером ведет в совсем другое место.
На самом роботе надо "стянуть" образ из Registry, чтобы он появился в списке при вызове docker images
и после этого вернуть ему нормальное имя. Это мы тоже делаем через SSH:
ssh -o ConnectTimeout=5 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
$ROBOT_HOSTNAME "
docker pull localhost:5000/eurobot2023:latest && \
docker tag localhost:5000/eurobot2023:latest eurobot2023:latest
"
И на этом деплой закончился, образ eurobot2023:latest
уже на роботе и готов к запуску.
Шаг 5: Чистим за собой
Чтобы остановить SSH-туннель, можно просто убить процесс ssh. Для этого ранее мы запоминали его PID в файл SSHPID
:
cat SSHPID | xargs kill
rm SSHPID # не забываем удалить временный файл тоже
Останавливать Docker Registry на роботе, как я уже писал, смысла большого нет. Дешевле по времени и вычислительным ресурсам просто оставить его работать. Есть еще одна причина не трогать его — если вы работаете в команде и одновременно льете образы на робота, вы можете нечаянно завершить Registry пока кто-то другой передает образ.
Подводные камни
Комментарий по поводу Mac OS
На Mac OS нельзя просто так взять и пробросить SSH-туннель для порта 5000. Все потому что служба AirPlay тоже использует этот порт.
Если хотите чтобы описанный здесь код заработал на вашем яблочном компьютере, отключите службу AirPlay, либо поменяйте во всех скриптах из этого поста порт 5000 на другой, например на 5001.
Расход места на диске
Через какое-то время активной работы у вас на устройстве и на роботе начнут скапливаться груды ненужных слоев от Docker образов. Не забывайте их чистить.
Извините за картинку из Midjourney, я не удержался
Этот процесс нуждается в автоматизации
Во время подготовки к соревнованиям мы не запускали все эти шаги руками. Мы пользовались мета билд системой bldr, которая упрощала процесс, скрывая кишки с докером внутри себя.
В целом, чтобы автоматизировать все шаги для простого проекта, достаточно и обыкновенных sh скриптов. Если работать в сложном монорепозитории, лучше для подобных затей использовать Bazel.
Мысли напоследок
В команде RESET до последнего момента SSH туннель запускался немного иначе — в виде Docker контейнера с утилитой ssh внутри. Это негативно сказывалось на скорости установки и сброса соединения, но зато было удобно останавливать туннель, просто останавливая Docker контейнер. А еще не надо было использовать хак с host.docker.internal
для доступа к SSH туннелю внутри Docker сети. Но настраивать такую конфигурацию сложнее, поэтому специально для этого поста я немного допилил нашу реализацию.
Чем больше я ковырялся c bldr, тем больше хотелось переписать все на Bazel и опубликовать на GitHub. Так любые робототехнические команды могли бы использовать все наработки.
Деплой по локальной сети сэкономил нам кучу человекочасов во время подготовки к соревнованиям. Но на то чтобы разобраться, как все правильно настроить и автоматизировать, у меня ушло несколько недель. Этот гайд поможет избежать подобных жертв)))