В предыдущей статье я встроил команды для работы с веб-камерой в код сервиса робота, а также заменил Wi-Fi антенну на более крупную, чтобы повысить стабильность сигнала. Кроме того, я добавил поддержку управления с клавиатуры, чтобы сделать управление роботом более отзывчивым. В результате мне удалось успешно пройти полосу препятствий, управляя роботом и ориентируясь на изображение с веб-камеры.

В этом материале я создам Docker-контейнер для веб-приложения web-robot-control, который упростит и ускорит его запуск. Также я настрою GitHub Actions для сборки артефакта и его последующей автоматической отправки в Docker Hub.

Статья будет полезна веб-разработчикам, девопсам, которые интересуются созданием Docker-контейнеров и работой с Docker Hub.

Оглавление

Создание docker контейнера

В данный момент приложение web-robot-control запускается только в режиме разработки с помощью команды poetry run start_app. Кроме того, для запуска требуется ручная настройка виртуального окружения. Очевидно, что простой пользователь не должен заниматься этими шагами самостоятельно. Именно поэтому для упрощения запуска и развёртывания веб-приложений используется контейнеризация с помощью Docker.

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

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

Переменные из .env вместо хардкода

Перед написанием конфигурации для Docker и упаковкой веб-приложения в контейнер, я исправлю хардкод параметров (значения, жёстко заданные в коде) host, port и reload в функции start_app(), расположенной в файле start.py. Эта функция использует веб-сервер uvicorn для запуска веб-приложения. Хардкод подобных параметров недопустим для продакшн-версии, поэтому я вынесу host, port и reload в файл .env, а обращаться к ним буду через settings проекта.

Старый код функции start_app():

def start_app():
    """Функция запуска приложения"""

    uvicorn.run(
        'web_robot_control.main:app', 
        host='127.0.0.1',
        port=8000,
        reload=True
    )

Хардкод в контексте host и port — это жёсткое задание адреса и порта прямо в коде приложения, без возможности изменить их через конфигурацию или переменные окружения. Это снижает гибкость приложения и может приводить к уязвимостям: раскрытие внутренней сетевой структуры, ошибки п��и развёртывании и риск запуска сервиса на небезопасных или конфликтующих портах.

В продакшене reload=True не нужен и вреден: он потребляет лишние ресурсы, запускает дополнительный процесс и может приводить к нестабильному поведению. При разработке этот параметр полезен тем, что автоматически перезапускает сервер при изменении кода.

Добавил в файл .evn.example описание новых переменных.

HOST_APP=ip или доменное имя для app
PORT_APP=порт для app
IS_RELOAD=True для разработки, False для обычного использования

Пример новых переменных в .env:

HOST_APP=127.0.0.1
PORT_APP=8070
IS_RELOAD=False

Далее я добавил новые переменные в класс Settings файла settings.py, к которым буду обращаться из кода веб-приложения.

class Settings(ModelConfig):
    """Класс для данных конфига"""

    # Другой код
    port_app: int
    host_app: str
    is_reload: bool

Ещё мне нужно добавить папку с тестами tests/ в файл .dockerignore. Это необходимо, чтобы эта папка не попала внутрь контейнера, так как ей там не место.

# app
tests/

Пока тестов в этой папке нет, но в будущем я планирую написать статью и код, связанный с тестами. Обычно тесты пишут до сборки контейнера и создания бэкапов артефактов. На данный момент я провёл только ручное тестирование, проверив работоспособность приложения вручную.

Чтобы полностью понимать, как работать с .dockerignore в Python-проекте, можно обратиться к документации и к примеру шаблона для Python-проекта.

Финальным штрихом добавляю settings.host_app, port=settings.port_app и settings.is_reload вместо хардкода в функцию start_app().

import uvicorn

from web_robot_control.settings import settings


def start_app():
    """Функция запуска приложения"""

    uvicorn.run(
        "web_robot_control.main:app",
        host=settings.host_app,
        port=settings.port_app,
        reload=settings.is_reload,
    )

Конфигурация для контейнера

Сначала я создал Dockerfile, который выполняет первоначальную настройку контейнера и устанавливает всё необходимое программное обеспечение.

FROM python:3.12.0-slim

WORKDIR /app

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

COPY . /app

RUN pip install poetry

RUN poetry config virtualenvs.create false && \
    poetry install --no-interaction --no-ansi

Разбор Dockerfile:

  • FROM python:3.12.0-slim — базовый облегчённый образ Python 3.12;

  • WORKDIR /app — рабочая директория внутри контейнера;

  • ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 — отключает буферизацию вывода и создание .pyc файлов, что упрощает работу с логами и ускоряет выполнение кода в контейнере;

  • COPY . /app — копирование всех файлов проекта в контейнер;

  • RUN pip install poetry — установка Poetry для управления зависимостями;

  • RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi — отключает виртуальное окружение и устанавливает зависимости проекта прямо в контейнер, упрощая сборку и запуск приложения.

Для более удобного управления контейнером или даже несколькими контейнерами я использую файл docker-compose.yml.

services:
  app:
    build: .
    image: arduinum628/web-robot-control-app:latest
    container_name: web_robot_control
    command: poetry run start_app
    env_file:
      - .env
    ports:
      - "${PORT_APP}:${PORT_APP}"
    volumes:
      - app:/app

volumes:
  app:

Разбор docker-compose.yml:

  • services: — секция, где описываются все контейнеры (сервисы) проекта;

  • app: — имя сервиса, в данном случае контейнер приложения;

  • build: — указывает, что образ для контейнера нужно собрать из текущей директории, используя Dockerfile;

  • image: arduinum628/web-robot-control-app:latest — задаёт имя образа;

  • container_name: web_robot_control — задаёт имя контейнера для удобства управления;

  • command: poetry run start_app — команда, которая запускается внутри контейнера при старте;

  • env_file: — список файлов с переменными окружения; здесь подключается .env;

  • ports: — проброс портов из контейнера наружу; ${PORT_APP}:${PORT_APP} использует переменные окружения;

  • volumes: — подключение томов; app:/app связывает том app с директорией /app внутри контейнера;

  • volumes: (внизу) — определение тома app, который может использоваться для хранения данных вне контейнера.

Теперь всё готово к сборке и последующему запуску контейнера. Для этого уже сейчас можно использовать команды для docker compose. Как его установить для вашей операционной системы, можно узнать в его документации.

Перед тем, как начать сборку контейнера, я решил облегчить себе ввод команд, создав Makefile в корне проекта. Makefile — это файл с правилами автоматизации, который описывает, как собирать, запускать и обслуживать проект (сборка, тесты, деплой), чтобы вместо длинных команд использовать простые make-цели.

Makefile:

help:
    @echo "Доступные команды:"
    @echo "  make docker-build    - Собрать Docker образ"
    @echo "  make docker-rebuild  - Пересобрать Docker образ (с очисткой кеша)"
    @echo "  make start-app-debug - Поднять контейнер с приложением (в режиме debug)"
    @echo "  make start-app       - Поднять контейнер с приложением"
    @echo "  make stop-app        - Остановить контейнер с приложением"
    @echo "  make docker-pull     - Скачать образ web-robot-control-app c Docker Hub"

docker-build:
    docker compose build

docker-rebuild:
    docker compose build --no-cache

start-app-debug:
    docker compose up

start-app:
    docker compose up -d

stop-app:
    docker stop web_robot_control

docker-pull:
    docker pull arduinum628/web-robot-control-app

Написать команду довольно просто:

название команды:
    оригинальная команда запуска

Достаточно выполнить команду make help, чтобы увидеть список доступных команд, а также их описание.

make help

Доступные команды:

Доступные команды:
  make docker-build    - Собрать Docker образ
  make docker-rebuild  - Пересобрать Docker образ (с очисткой кеша)
  make start-app-debug - Поднять контейнер с приложением (в режиме debug)
  make start-app       - Поднять контейнер с приложением
  make stop-app        - Остановить контейнер с приложением
  make docker-pull     - Скачать образ web-robot-control-app c Docker Hub

Описание команд говорит само за себя. Очевидно, для чего предназначена каждая из них. Далее я собираю образ, который будет использоваться для создания контейнера.

make docker-build

Результат сборки:

✔ Image arduinum628/web-robot-control-app:latest Built

Docker собрал образ, который можно увидеть, выполнив следующую команду:

docker image ls

Вывод команды:

IMAGE
arduinum628/web-robot-control-app:latest

ID
131af20f4t6a

DISK USAGE
310MB

CONTENT SIZE
0B

Разбор информации об образе:

  • IMAGE — имя и тег Docker-образа в локальном хранилище (репозиторий arduinum628/web-robot-control-app, тег latest);

  • ID — уникальный идентификатор образа (используется Docker для ссылок на конкретную версию);

  • DISK USAGE — фактический объём места на диске, который занимает образ (включая слои);

  • CONTENT SIZE — размер «уникального» содержимого образа без учёта общих слоёв; 0B означает, что все слои уже используются другими образами и ничего нового на диск не добавлено.

Теперь данный образ можно использовать для создания контейнера.

Запуск веб-приложения из контейнера

Прежде чем запустить приложение в контейнере, используя образ, собранный выше, я воспользуюсь avahi-daemon. Он позволяет задать имя в сети для моего ПК — так же, как я делал это на Orange Pi в одной из предыдущих статей. Это повышает удобство использования внутри локальной сети и избавляет от необходимости постоянно искать IP-адрес компьютера, который может измениться при следующем запуске ПК или роутера.

На данный момент моё приложение рассчитано на работу внутри домашней сети и не предусматривает использование VPS-серверов и доменов. Я не вижу смысла запускать робота, когда меня нет дома: при потере связи он может куда-то уехать, сломаться и т. д. Я предпочитаю запускать таких роботов только при личном присутствии.

Для начала я открываю файл hosts на ПК, в котором задаются различные имена для IP-адресов внутри системы.

sudo nano /etc/hosts

Задаю имя хоста ПК для его локального IP-адреса. Важно, чтобы это имя совпадало с именем, заданным в avahi-daemon, иначе возникнет конфликт имён, и avahi-daemon не сможет корректно его использовать. С этой проблемой я столкнулся лично, поэтому решил упомянуть её отдельно.

127.0.1.1       mydevice_name

Это позволит настроить сетевое имя ПК или ноутбука внутри самого устройства, однако другие устройства в локальной сети его не увидят. Далее я перезапускаю службу управления именами хостов, чтобы изменения вступили в силу.

sudo systemctl restart systemd-hostnamed

После этого мне необходимо установить avahi-daemon на ПК, так как я хочу, чтобы имя хоста было доступно другим устройствам в локальной сети. Это позволит легко подключаться к веб-приложению для управления роботом со смартфона или планшета по имени хоста.

В качестве альтернативы можно установить это веб-приложение на Raspberry Pi, настроить имя хоста и подключаться к нему с любого устройства в локальной сети. Преимущество такого подхода в том, что Raspberry Pi будет выполнять роль локального VPS, а пользователю не придётся каждый раз запускать приложение вручную — достаточно просто открыть URL в браузере.

Устанавливаю avahi-daemon на PC:

sudo apt install avahi-daemon

Задаю имя хоста для ПК в avahi-daemon:

sudo hostnamectl set-hostname mydevice_name

Разбор команды:

  • hostnamectl — утилита управления именем хоста (systemd);

  • set-hostname — подкоманда для установки нового имени;

  • mydevice — имя хоста, которое сохранится в системе.

Перезапуск службы avahi-daemon для применения настроек:

sudo systemctl restart avahi-daemon

Проверяю, что новое имя хоста задано в avahi-daemon.

sudo systemctl status avahi-daemon

Результат выполнения команды:

Server startup complete. Host name is mydevice_name.local

Имя хоста соответствует тому, что было задано в avahi-daemon. Теперь можно запускать контейнер с веб-приложением.

make start-app

Для проверки я подключаюсь по URL http://mydevice_name.local:8070/ со смартфона.

Запуск приложения на смартфоне
Запуск приложения на смартфоне

Интерфейс приложения корректно отобразился на мобильном устройстве. Это означает, что теперь роботом можно управлять со смартфона, лёжа на диване, что заметно повышает мобильность управления. Изображение с камеры отсутствует, так как я не подключался к роботу и не включал его.

Мне очень интересно, как будет ощущаться управление со смартфона, однако перед полноценным использованием я бы хотел добавить стики, как у джойстика, вместо кнопок со стрелками — это должно повысить отзывчивость и удобство управления. Об этом я обязательно напишу в одной из следующих статей.

GitHub как CI: билд Docker-образа в облаке

После проверки сборки Docker-образа на локальном ПК и проверки работы веб-приложения в контейнере можно переходить к настройке CI через GitHub Actions workflows.

Первым шагом я хочу проверить, что контейнер корректно собирается в облаке. Это полезно тем, что в случае ошибки сборки я, как разработчик, сразу узнаю об этом и смогу оперативно исправить код. Событием, при котором будет запускаться сборка контейнера, выбран pull request в ветку master. В дальнейшем, вероятно, я перейду на сборку по тегам версий, но на текущем этапе этого достаточно.

CI (Continuous Integration) — это автоматическая проверка кода при определённых событиях (например, при коммите или создании pull request).

Workflows — это набор автоматизированных шагов в GitHub Actions, которые запускаются при заданных событиях (например, push или pull_request) и используются для выполнения CI/CD-задач.

Для проверки сборки я создаю в корне проекта следующий путь: .github/workflows/build.yml.

В файле build.yml я написал следующую конфигурацию:

name: Build and backup docker conteiner

on:
  # Сработает при создании pull request в master ветку
  pull_request:
    branches:
      - master

jobs:
  # Сборка контейнера
  build:
    name: Run build conteiner
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build Docker image
        run: |
          make docker-build
        env:
          PORT_APP: ${{ secrets.PORT_APP }}

Разбор конфигурации:

  • name: Build and backup docker container — имя workflow, которое будет отображаться в интерфейсе GitHub Actions;

  • on: — определяет события, при которых запускается workflow;

  • pull_request: — workflow запускается при создании или обновлении pull request;

  • branches: - master — ограничивает запуск workflow только pull request’ами, направленными в ветку master;

  • jobs: — набор задач, которые будут выполняться в рамках workflow;

  • build: — идентификатор job (внутреннее имя, используется GitHub Actions);

  • name: Run build container — человекочитаемое имя job в интерфейсе GitHub Actions;

  • runs-on: ubuntu-latest — указывает, что job будет выполняться на виртуальной машине с последней доступной версией Ubuntu;

  • steps: — последовательность шагов, выполняемых внутри job;

  • actions/checkout@v4 — клонирует репозиторий в рабочую директорию runner’а, без этого дальнейшие шаги не увидят код проекта;

  • docker/setup-buildx-action@v3 — настраивает Docker Buildx — современный билд-бэкенд Docker, необходимый для сборки образов (особенно полезен для multi-platform сборок);

  • make docker-build — запускает сборку Docker-образа через Makefile;

  • env: — передаёт переменные окружения в шаг сборки;

  • PORT_APP: ${{ secrets.PORT_APP }} — значение берётся из GitHub Secrets, в котором безопасно хранятся секретные данные.

Далее необходимо закоммитить изменения и сделать push в репозиторий. Если вы не понимаете, о чём я сейчас написал, то рекомендую ознакомится с документацией git.

После того, как я отправил новые данные в репозиторий на GitHub, я перешёл в свой репозиторий и создал новый pull request: Pull requests → New pull request.

На странице создания pull request GitHub проверяет, можно ли корректно смержить ветки (есть ли конфликты), а также запускает проверки (checks) — то есть workflow’ы GitHub Actions, если они настроены на событие pull_request. В моём случае начался workflow со сборкой Docker-контейнера.

Оповещение о сборке в pull request
Оповещение о сборке в pull request

Если какая-либо из jobs в workflow завершается с ошибкой, рядом с pull request появляется красный кружок с крестиком, а также отображается количество проваленных проверок (checks).

Ошибка при сборке контейнера
Ошибка при сборке контейнера

Я специально создал ситуацию, при которой проверка не проходит, чтобы показать, что произойдёт в этом случае. В моём случае не была пройдена одна проверка, о чём говорит запись 1 failing check.

Перейдя в интерфейсе Actions → Build and backup docker container → feat: added test_build, можно увидеть стадию job, на которой упала сборка контейнера.

Ошибка в Build docker image
Ошибка в Build docker image

Из лога ошибки видно сообщение: "PORT_APP" variable is not set, что означает, что переменная окружения PORT_APP не задана.

Эта переменная берётся из файла .env, которого нет в репозитории на GitHub. Файл .env обычно не хранят в открытом доступе, так как он содержит конфиденциальные данные, которые должен знать только разработчик приложения.

Однако для GitHub Actions можно задать такие значения через секретные переменные. Для этого нужно перейти в: Settings (репозитория) → Secrets and variables → Actions, а затем нажать кнопку New repository secret, где указать имя переменной и её значение.

Таким образом необходимо добавить все переменные из .env файла, которые используются в процессе выполнения workflow (job’ов).

После этого я снова перешёл к workflow, в котором произошла ошибка, и нажал кнопку Re-run all jobs.

Успешно выполненная job
Успешно выполненная job

Теперь, судя по тому, что везде отображаются галочки, стадия job Build Docker image успешно пройдена, как и остальные этапы workflow. После этого можно добавлять другие jobs, которые будут выполняться после успешной сборки контейнера.

GitHub как CD: отправка образов на Docker Hub

После проверки сборки контейнера я могу смело отправлять образа (артефакта) на Docker Hub. Docker Hub — это публичный или приватный реестр Docker-образов, предназначенный для их хранения, распространения и версионирования. Хранение артефактов (Docker-образов) в Docker Hub позволяет централизованно использовать готовые сборки в CI/CD, быстро разворачивать приложения и гарантировать воспроизводимость окружения.

Также Docker Hub подходит для распространения вашего ПО по всему миру. Если вас интересуют российские аналоги, можно использовать Yandex Container Registry (Yandex Cloud) или другой отечественный container registry.

Далее мне предстояло написать новую job для отправки образа в Docker Hub в том же workflow-файле. Эта job должна запускаться после успешного выполнения job сборки (build).

# Отправка образа в dockerhub
push_dockerhub:
  name: Push image to docker hub
  runs-on: ubuntu-latest
  needs: build # ждёт успешной сборки

  steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Login to Docker Hub
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_TOKEN }}

    - name: Build and push
      uses: docker/build-push-action@v6
      with:
        context: .
        push: true
        tags: ${{ secrets.DOCKERHUB_USERNAME }}/web-robot-control-app:latest
        build-args: |
          PORT_APP=${{ secrets.PORT_APP }}

Разбор конфигурации:

  • push_dockerhub: — имя job в workflow GitHub Actions, отвечающей за публикацию Docker-образа в Docker Hub;

  • name: Push image to docker hub — человекочитаемое название job, отображаемое в интерфейсе GitHub Actions;

  • runs-on: ubuntu-latest — указывает, что job выполняется на виртуальной машине с последней доступной версией Ubuntu;

  • needs: build — задаёт зависимость от job build; данная job начнёт выполняться только после её успешного завершения.

Шаги (steps):

  • Checkout code — клонирует репозиторий в рабочее окружение runner’а, чтобы Docker мог получить доступ к исходному коду и Dockerfile;

  • Set up Docker Buildx — настраивает Docker Buildx, необходимый для расширенных возможностей сборки (кэширование, multi-platform и push в реестр);

  • Login to Docker Hub — выполняет аутентификацию в Docker Hub с использованием секретов GitHub:
    DOCKERHUB_USERNAME — имя пользователя Docker Hub;
    DOCKERHUB_TOKEN — токен доступа (рекомендуется вместо пароля).

  • Build and push — собирает Docker-образ и отправляет его в Docker Hub:
    context: . — контекст сборки (корень репозитория);
    push: true — указывает, что образ нужно отправить в реестр после сборки;
    tags — имя и тег образа в Docker Hub (latest);
    build-args — передаёт аргументы сборки в Dockerfile, в данном случае значение переменной PORT_APP.

После написания конфигурации я отправил изменения в репозиторий и перезапустил jobs, в результате чего обе задачи выполнились успешно.

Связанные jobs и артефакт
Связанные jobs и артефакт

Запуск выполнялся на тестовом закрытом репозитории, поэтому на скриншоте видно имя test_CI_CD. Также здесь отображается артефакт Arduinum~test_CI_CD~SRX5KN.dockerbuild, который представляет собой временный артефакт GitHub Actions, созданный в процессе выполнения workflow для хранения служебных данных сборки Docker-образа (метаданные Buildx, кэш слоёв и результаты сборки). Этот артефакт не является самим Docker-образом, опубликованным в Docker Hub.

На job Push image to docker hub стоит зелёная галочка, значит, она выполнилась успешно, и образ должен быть доступен в Docker Hub. Чтобы это проверить, я перехожу в свой аккаунт Docker Hub, затем во вкладку Repositories, где нахожу репозиторий arduinum628/web-robot-control-app.

Репозиторий на Docker Hub
Репозиторий на Docker Hub

Если открыть этот репозиторий, можно увидеть сам Docker-образ и сопутствующую информацию о нём.

Образ на Docker Hub
Образ на Docker Hub

Теперь этот образ можно спокойно скачивать и разворачивать у себя, что гораздо удобнее, чем каждый раз собирать его локально с помощью docker compose.

Команда для скачивания образа:

docker pull arduinum628/web-robot-control-app:latest

После скачивания образа его можно легко использовать для запуска контейнера, выполнив следующие действия (Linux):

  • Создать .env в любом удобном месте, используя .env.example в качестве шаблона;

  • Скачать образ: docker pull arduinum628/web-robot-control-app;

  • Создать volume: docker volume create app;

  • Создать .env в корне проекта, используя .env.example в качестве шаблона;

  • Экспорт .env переменных: export $(grep -v '^#' .env | xargs);

  • Запуск приложения: docker run -d --name web_robot_control -p ${PORT_APP}:${PORT_APP} -v app:/app --env-file .env arduinum628/web-robot-control-app:latest poetry run start_app.

Данный способ удобен тем, что для запуска веб-приложения не требуется клонировать репозиторий с GitHub — достаточно скачать готовый Docker-образ и передать необходимые переменные окружения.

Ссылка на итоговый open-source проект web-robot-control.

Заключение и планы на будущее

Сегодня я создал контейнер для приложения web-robot-control, а также написал две jobs для GitHub Actions. Результат — успешная сборка контейнера и загрузка образа на Docker Hub.

Следующая статья (связанная с данным проектом) будет посвящена продолжению темы CI/CD — облегчению запуска и сборки приложения, а также его доставки. На этот раз я соберу в GitHub Actions пакет для Linux и организую его хранение и распространение через внешнее хранилище. Также я напишу инструкцию для запуска пользователю в README.md, скачаю пакет по этой инструкции и проверю его работу на одноплатном компьютере.

Если у вас есть идеи по развитию проекта, поделитесь ими в комментариях — буду рад услышать ваши предложения!

Автор статьи @Arduinum


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.