Сказ о том, как я настраивал сервисы в docker на Raspberry PI и почему это, возможно, не лучшая идея.


Введение (или как всё начиналось)


Началось всё очень давно пару лет назад. Так уж вышло, что я оказался в Китае и надо было как-то связываться с внешним миром. Сторонним VPN и прокси я не очень доверял, поэтому решил поднять DigitalOcean со своим прокси. Так уж вышло, что со временем сервер с прокси оброс разными разностями: от файлового хранилища (Syncthing) до CI (Jenkins).


По возвращении в Россию было принято решение уходить с DO на какой-то self-hosting. Покупать для этого отдельный сервер не хотелось — дорого, да и пока незачем, по этой причине взял Raspberry PI 4B. Естественно, пришлось переносить все основные сервисы с DO на эту машинку, о чем и будет данный пост.


Вводные


Необходимо было настроить следующие сервисы:


  • Syncthing — синхронизация файлов
  • Jenkins — CI
  • Telegraf — |
  • Influxdb — графики CPU, GPU и прочего такого
  • Grafana — |
  • Gogs — git
  • Radicale — синхронизация календарей/контактов

Кроме того:


  • Хранение файлов и данных предполагалось производить на паре флешек USB3 в зашифрованном виде (LUKS)
  • Все веб-интерфейсы были спрятаны за Nginx реверс-прокси

Проблемы и нюансы


Сразу хотелось бы рассказать про проблемы, которые возникали по ходу сборки всего этого дела и/или возникают сейчас:


  • Конкретно моя Raspberry PI не захотела корректно запускать хард с требуемым током в 1A, поэтому я решил использовать флешки. Возможно, проблема была в питании, а возможно — в конкретной модели малинки или харде
  • На RaspberryPI 4B (на данный момент) нельзя загружаться с USB флешек. Как итог — очень медленная работа относительно чтения/записи файлов внутри системы. Относительно решалось использованием USB3 флешек для хранения файлов
  • В силу не самой большой производительности, докер контейнеры не всегда корректно стартуют. Для исправления этого дела (я не нашёл даже информации об этом в гугле) пришлось написать полукостыльный скрипт (описано в разделе "Последние штрихи")
  • Поскольку один из сервисов — syncthing, то иногда всё сильно начинает подвисать просто оттого, что малинка пишет/читает файлы
  • В простое без охлаждения малинка может нагреваться до 60 градусов

Так что если решитесь на такой эксперимент — будьте готовы к 502 при доступе через интернет и подвисаниям в локальном ssh.


Готовка


Само собой, в первую очередь необходимо подготовить флешку Micro SD 16GB (или большего объема) с системой Raspbian. Подробнее об этом есть на официальном сайте.


Краткая инструкция
  • Качаем Noobs
  • Форматируем Micro SD в Fat32
  • Через любое приложение записи на носимые устройства (я использовал uNetbootin) записываем скачанный архив на флешку
  • Запускаем Raspberry PI с установленной флешкой

Дальше всё достаточно интуитивно. Во время установки мне предлагалось два основных варианта установки: с GUI и без. Я ставил с GUI, но после настройки ssh GUI мне больше не понадобился.


Подготовка Флешки(ек)


Флешки я готовил через Elementary OS с GUI (GParted и утилита disks) и небольшим количеством консоли. Вкратце, алгоритм следующий:


ВНИМАНИЕ, ПОДОБНЫЕ МАНИПУЛЯЦИИ УДАЛЯЮТ ВСЕ ФАЙЛЫ С УСТРОЙСТВ. ВЫБИРАЙТЕ ЦЕЛЕВЫЕ УСТРОЙСТВА АККУРАТНО


Вкратце — задача отформатировать флешки в формате ext4 и зашифровать с помощью LUKS.


Подробнее
  • Через GParted
    • Выбираем нужное устройство
    • Идем по пути Device -> Create partition table...
    • Выбираем gpt
    • Нажимаем Apply
    • Создаём раздел ext4
  • Через утилиту gnome-disks (устанавливается через sudo apt install gnome-disk-utility) (в меню она называется Disks):
    • Находим устройство
    • Нажимаем клавишу настройки (шестерёнки)
    • Нажимаем Format partition...
    • Выбираем пункт с припиской Ext 4
    • Ставим галочку Password protect volume
    • После нажатия Next вам будет предложено ввести пароль для устройства
    • После форматирования:
      • Записываем куда-нибудь UUID, который отображается в информации об устройстве
      • Записываем куда-нибудь Device, который отображается в информации об устройстве

Далее будет достаточно простая процедура добавления файлов для открытия флешек. Это нужно будет, чтобы RaspberryPI могла автоматически подключать устройства. Делаем следующее:


Примечание: Далее /dev/sdb1 — адрес раздела, который мы записывали из Device


dd if=/dev/urandom bs=4M count=1 of=/tmp/usb_decrypt_file
sudo cryptsetup luksAddKey /dev/sdb1 /tmp/usb_decrypt_file

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


ВАЖНО: не потеряйте файл во время настройки и НИКОМУ не передавайте его.


Повторяем вышеописанные действия для второй флешки, если необходимо. Обязательно сохраняем файл(ы)-ключи и запоминаем/записываем пароли


Подготовка плацдарма Raspberry PI


После корректной установки системы, необходимо её донастроить:


  • Обновляем систему (sudo apt update && sudo apt -y dist-upgrade)
  • Доустановка пакетов:
    • Ставим docker и docker-compose (sudo apt -y install docker docker-compose)
    • Ставим Nginx(sudo apt -y install nginx). Будем его использовать для прокидывания reverse-proxy
  • Настраиваем монтирование флешек


    • Переносим файлы-ключики для флешек (например, в папку /root/cryptfiles; в дальнейшем она будет использована как пример)


    • В файл /etc/crypttab добавляем запись в формате:


      usb1_crypt UUID=ДАННЫЕ_ИЗ_ПОЛЯ_UUID /root/cryptfiles/имя_файла-ключа luks

    • В файл /etc/fstab добавляем запись в формате


      /dev/mapper/usb1_crypt /media/pi/usb1 ext4 defaults,nofail 0 2

      Тут:


      • /dev/mapper/usb1_crypt — это /dev/mapper/ + имя (первое слово) из /etc/crypttab
      • /media/pi/usb1 — адрес монтирования. Необходимо создать вручную (mkdir /media/pi/usb1). В теории — может быть любым, но лучше разполагать в /mnt или /media/$USER

    • Повторить для второй флешки, если есть




Щепотка скриптов для Nginx


Далее будет представлен ворох скриптов на bash, которые я использую для формирования конфигурации reverse-proxy до контейнеров.


Примечания:


  • Считается, что вы УЖЕ купили и настроили домен, либо планируете обходиться без него в условиях локальной сети
  • Считается, что вы используете letsencrypt сертификаты и их бота

Скрипты для создания reverse-proxy настроек в nginx

Для примера, будем называть домен — mydomain.com. Если путь до nginx находится по пути /etc/nginx, то скрипты следует класть по пути /etc/nginx/autocompile.


compile_apps_configs.sh
#!/bin/bash

# Use "-pl" key in subname to make it like https://hostname/subname.
# E.g.: for https://my.domain/example will be used "-pl example"

APPS=("syncthing" "grafana" "radicale" "git" "jenkins")
APPS_PROXIES=(http://localhost:8880 http://localhost:3000 http://localhost:8882 http://localhost:8883 http://localhost:8884)
HOSTNAMES=(my.domain)
# HOSTNAMES=()

conf_file="/etc/nginx/sites-available/autocompiled.conf"
ln_file="/etc/nginx/sites-enabled/autocompiled.conf"

echo "" > "$conf_file"

for app_index in ${!APPS[*]}
do
    app="${APPS[app_index]}"
    app_proxy="${APPS_PROXIES[app_index]}"

    for host in ${HOSTNAMES[*]}
    do
        echo "`./compile_config.sh "$host" "$app_proxy" $app`" >> "$conf_file"
        echo "" >> "$conf_file"
    done
done

ln -s "$conf_file" "$ln_file"

compile_config.sh
#!/bin/bash

# FIRST ARG IS DOMAIN BASE

DOMAIN_BASE="$1"
shift

# THIRD ARG IS PROXY_PASS

PROXY_PATH=$(echo "$1" | sed -e "s/\//\\\\\//g")
PROXY_LOCATION=""
shift

HOSTNAME="$DOMAIN_BASE"

while [ -n "$1" ]
do
    case "$1" in
        "-pl") shift; PROXY_LOCATION="$(echo "$1" | sed -e "s/\//\\\\\//g")" ;;
        *) HOSTNAME="$1.$HOSTNAME" ;;
    esac

    shift
done

cat template.conf | sed "s/HOSTNAME_BASE/$DOMAIN_BASE/g" | sed "s/HOSTNAME/$HOSTNAME/g" | sed "s/PROXYPATH/$PROXY_PATH/g" | sed "s/PROXYLOCATION/$PROXY_LOCATION/"

location_template.conf
    location /PROXYLOCATION {
        proxy_pass PROXYPATH;
    }

template.conf
server {
    server_name "HOSTNAME";

    ssl_certificate /etc/letsencrypt/live/HOSTNAME_BASE/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/HOSTNAME_BASE/privkey.pem;

    listen 443 ssl;

    keepalive_timeout 60;
    ssl on;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers "HIGH:!RC4:!aNULL:!MD5:!kEDH";
    add_header Strict-Transport-Security 'max-age=604800';
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;

    # set max upload size
    client_max_body_size 4000M;

    sendfile on;
    send_timeout 600s;
    proxy_connect_timeout 600;

    location /PROXYLOCATION {
        proxy_pass_request_headers on;
        proxy_pass_request_body on;
        proxy_pass PROXYPATH;
    }
}

После завершения копипасты и исправления домена в первом файле, запускаем скрипт ./compile_apps_configs.sh и перезапускаем nginx: sudo systemd reload nginx.


Подготовка Docker и скриптов


Само собой, в первую очередь нужно поставить docker и docker-compose:


sudo apt install docker docker-compose


После успешной установки следует заполнение yml файлов и не только. На данный момент я остановился на следующей структуре файлов:


Структура файлов для удобной работы с контейнерами
+-- doForAll
+-- gogs
¦   +-- docker-compose.yml
¦   +-- Dockerfile
¦   L-- .env
+-- grafana
¦   +-- configs
¦   ¦   +-- influxdb.conf
¦   ¦   L-- telegraf.conf
¦   +-- docker-compose.yml
¦   L-- .env
+-- jenkins
¦   +-- docker-compose.yml
¦   +-- .env
+-- makeFullUpdate
+-- radicale
¦   +-- docker-compose.yml
¦   L-- .env
L-- syncthing
    +-- docker-compose.yml
    L-- .env

Смысл такого разделения в том, чтобы упростить управление каждым из контейнеров или группой контейнеров (как в случае с grafana — там поднимается четыре контейнера, один из которых — инициализация базы данных influxdb). Сами исходники можно найти в репозитории.


После клонирования, нужно будет произвести несколько манипуляций:


  • Во всех .env проверить значения DATA_PATH
  • Для Grafana и её окружения нужно будет также сгенерировать и куда-то записать следующие данные:
    • Пароль для Telegraf. В файле .env он лежит под ключом INFLUXDB_WRITE_USER_PASSWORD, а в файле configs/telegraf.confpassword
    • Пароль для Grafana. В файле .env он лежит под ключом INFLUXDB_READ_USER_PASSWORD, а кроме того будет использован при настройке Grafana

Последние штрихи


Последним штрихом будет создание задачи проверки и перезапуска контейнера.


Настройка скрипта перезапуска

Сам скрипт должен будет запускаться от пользователя root. Для его заполнения выполните следующие операции:


# меняем пользователя на root
sudo -i
mkdir -p /root/scripts/
touch "/root/scripts/monitor_startup_docker_container"
chmod 700 "/root/scripts/monitor_startup_docker_container"
nano "/root/scripts/monitor_startup_docker_container"

Далее файл /root/scripts/monitor_startup_docker_container заполняем следующим:


#!/bin/bash

function log() {
    echo `date`: "$@"
}

container_name="$1"

true=1
false=0

function restartContainer() {
    docker container restart "$1"
}

function checkContanerExitStatus() {
    container_name="$1"
    status_line="`docker container ps -a --filter "name=$container_name" --filter "exited=255" | grep "$container_name"`"
    [[ -z "$status_line" ]] && echo $false || echo $true
}

function checkContanerStatusIsEqual() {
    container_name="$1"
    container_dest_status="$2"
    status_line=" `docker container ps -a --filter "name=$container_name" --filter "status=$container_dest_status" | grep "$container_name"`"
    [[ -z "$status_line" ]] && echo $false || echo $true
}

function isRunning() {
    echo "`checkContanerStatusIsEqual "$container_name" "running"`"
}

while [[ "`isRunning`" != "$true" ]]; do
    log check cycle "$container_name"

    if [ "`checkContanerStatusIsEqual "$container_name" "exited"`" == "$true" -o "`checkContanerStatusIsEqual "$container_name" "dead"`" == "$true" ]; then
        log restart "$container_name"

        restartContainer "$container_name"
    fi

    if [[ "`isRunning`" -eq "$false" ]]; then
        sleep 5
    else
        sleep 120
    fi

done

log started "$container_name"


Остаётся заполнить crontab конфигурацию. Это производится также от имени root:


Заполнение файла после вызова `crontab -e`
@reboot rm /root/startup_docker_logs

0 */5 * * * /root/scripts/monitor_startup_docker_container telegraf >> /root/startup_docker_logs
10 */5 * * * /root/scripts/monitor_startup_docker_container influxdb >> /root/startup_docker_logs
20 */5 * * * /root/scripts/monitor_startup_docker_container grafana >> /root/startup_docker_logs
30 */5 * * * /root/scripts/monitor_startup_docker_container jenkins >> /root/startup_docker_logs
40 */5 * * * /root/scripts/monitor_startup_docker_container gogs >> /root/startup_docker_logs
50 */5 * * * /root/scripts/monitor_startup_docker_container radicale >> /root/startup_docker_logs
10 */5 * * * /root/scripts/monitor_startup_docker_container syncthing >> /root/startup_docker_logs

Заключение


В итоге, у нас получилась относительно легко управляемая система, которая если ничего не обновлять в среднем почти не требует обслуживания. Что не было затронуто в рамках этой статьи:


  • Настройка доступа по SSH к малинке: есть большое число туториалов на эту тему, вот пример с DigitalOcean
  • Настройка самих сервисов
  • Покупка и настройка DNS для доменов

Буду рад комментариям и полезным замечаниям.