HAProxy + acme.sh в Docker

Попользовавшись реверс-прокси Traefik для своего домашнего сервера, я решил вернуться на HAProxy как более функциональное решение, в частности, в области ограничений на количество запросов от клиентов, а то некоторые адреса и подсети буквально бомбардируют мой сайт запросами, и хочется иметь автоматический механизм, охлаждающий пыл таких товарищей. Вопрос стоял в способе выпуска и обновления SSL-сертификата: Traefik умеет сам этим заниматься, а вот для HAProxy нужен какой-то сторонний механизм.

Сначала я думал сделать второй контейнер с Certbot, который будет заниматься обновлением сертификата и класть результат в общий с HAProxy том, где он и будет подхватываться. Но возникают вопросы:

  1. Как сделать запуск обновления периодическим?
  2. Как дать знать реверс-прокси, что пора перезапуститься или перечитать конфигурацию, чтобы новый сертификат начал использоваться?

Вариант завязываться с кроном на хосте с командами docker run мне не нравится, хотелось бы всё держать внутри Докера. Другой вариант, когда в контейнере запущен бесконечный цикл в терминале, также не представляется мне удовлетворительным:

while :; do
  <команда обновления сертификата>
  sleep 24h
done

Некоторое время назад я прочёл статью HAProxy and Let’s Encrypt: Improved Support in acme.sh о настройке совместной работы Хапрокси и скрипта по работе с выпуском и обновлением сертификатов acme.sh. По сравнению с моим прошлым решением здесь новшеством является то, что после выпуска сертификата вообще не нужно ни перечитывать конфигурацию, ни тем более перезапускать сервер — сертификат начинает работать сразу же «на горячую». Для запуска Хапрокси в Докере я использую образ haproxy:lts-alpine, а в образах Alpine есть свой crond. По умолчанию он отключён и умеет работать только от пользователя root, но это не проблема — crond я уже использую в других контейнерах, например, для выполнения фоновых заданий в Nextcloud и для напоминаний о днях рождения родственников в Webtrees. В этом направлении я и решил действовать — контейнер с реверс-прокси будет заниматься также и обновлением сертификата. Да, это не очень правильно, потому что в одном контейнере, по-хорошему, должен быть запущен только один процесс; но остальные пути решения выглядят либо хуже, либо слишком громоздко.

В вышеупомянутой статье сначала создаётся отдельный пользователь acme, но здесь я не вижу в этом особого смысла. В исходном образе уже есть учётка haproxy с UID 99, её и будем использовать. Что касается самого скрипта acme.sh, то в этом случае проще всего поставить его из пакета, а не возиться с ручной установкой из git-репозитория. Ещё дополнительно пригодится утилита su-exec, которая нужна для выполнения команд от имени haproxy из-под root. Почему su-exec — она занимает всего 10 КБ и, в отличие от sudo, который я по привычке попробовал использовать, хорошо работает в контейнере, не требуя какой-то дополнительной настройки. Sudo давал мне ошибку

haproxy  | haproxy is not in the sudoers file.
haproxy  | This incident has been reported to the administrator.

После установки утилит надо ещё прописать в cron задание по обновлению сертификата. В файл скрипта прописывается шебанг и команда на обновление сертификата (о ней чуть позже), дальше скрипт делается исполняемым. Размещается скрипт в каталоге /etc/periodic/daily, что означает, что он будет выполняться раз в сутки.

Вот весь Dockerfile, собирающий образ для запуска. Строка CMD активирует крон и запускает реверс-прокси при старте контейнера.

FROM haproxy:lts-alpine
WORKDIR /var/lib/haproxy
ARG renew_script=/etc/periodic/daily/cert_renew
USER root

RUN apk add acme.sh su-exec && \
echo '#!/bin/sh' > $renew_script && \
echo "su-exec haproxy acme.sh --renew -d example.com --renew-hook \"acme.sh --deploy -d example.com --deploy-hook haproxy\"" >> $renew_script && \
chmod ug+x $renew_script

CMD crond && su-exec haproxy haproxy -f /usr/local/etc/haproxy/haproxy.cfg

Acme.sh можно настраивать с помощью переменных окружения, что для контейнеров является наиболее предпочтительным способом (см. The twelve-factor app, глава «Конфигурация»). Пропишем эти переменные в docker-compose.yml:

services:
  haproxy:
    # Здесь лежит Dockerfile
    build: ./haproxy
    container_name: haproxy
    hostname: haproxy
    restart: always
    sysctls:
      net.ipv4.ip_unprivileged_port_start: 0
    environment:
      # Настройка развёртывания сертификатов для HAProxy
      DEPLOY_HAPROXY_HOT_UPDATE: yes
      DEPLOY_HAPROXY_STATS_SOCKET: "/tmp/api.sock"
      DEPLOY_HAPROXY_PEM_PATH: "/certs"
      # Рабочий каталог acme.sh, где хранятся настройки, сертификаты и т. д.
      LE_WORKING_DIR: "/acme.sh"
    ports:
      - 80:80
      - 443:443
      - 8404:8404
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - ./haproxy/cfg:/usr/local/etc/haproxy
      - ./haproxy/certs:/certs
      - ./haproxy/acme.sh:/acme.sh

Необходимо дать права на каталоги хоста группе или пользователю haproxy (UID/GID 99), чтобы acme.sh мог записывать туда свои данные изнутри контейнера.

sudo chown -R $USER:99 ./haproxy/{certs,acme.sh}

Дальше нужно запустить контейнер с реверс-прокси и зарегистрировать учётку для работы acme.sh с Let’s Encrypt. Все команды docker exec обязательно нужно выполнять, указывая пользователя haproxy, потому что в нашем обновлённом докер-образе, если не указано иначе, все команды будут выполняться под суперпользователем root.

docker compose up -d --build
docker exec -u haproxy haproxy acme.sh --register-account --server letsencrypt -m youremail@example.com

Полученное значение ACCOUNT_THUMBPRINT необходимо скопировать и прописать его в свой файл haproxy.cfg как переменную, а также строку в веб-фронтенд. Всё это позволит Хапрокси корректно отвечать на запрос проверки принадлежности сервера перед выдачей сертификата (ACME challenge).

global
  stats socket /tmp/api.sock user haproxy group haproxy mode 660 level admin expose-fd listeners
  setenv ACCOUNT_THUMBPRINT 'lCufto4sDRTHdmWL0EugFywGV54hBCuTTXvwifi65R4'

frontend web
    bind :80
    bind :443 ssl crt /certs strict-sni
    http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/)].${ACCOUNT_THUMBPRINT}\n" if { path_beg '/.well-known/acme-challenge/' }

Перечитываем конфигурацию Хапрокси и переходим к выпуску и установке сертификата.

docker kill -s HUP haproxy

# Выпуск сертификата. Лог полезен для выяснения причин потенциальных проблем.
docker exec -u haproxy haproxy acme.sh --issue --stateless --server letsencrypt -d example.com --log /acme.sh/debug.log

# После успешного выпуска развёртываем сертификат в каталог для Хапрокси.
# Так как переменные DEPLOY уже заданы в docker-compose.yml, больше никаких параметров задавать не нужно.
docker exec -u haproxy haproxy acme.sh --deploy -d example.com --deploy-hook haproxy

[Mon Jul 14 14:34:02 MSK 2025] The domain 'example.com' seems to already have an ECC cert, let's use it.
[Mon Jul 14 14:34:02 MSK 2025] Deploying PEM file
[Mon Jul 14 14:34:02 MSK 2025] Moving new certificate into place
[Mon Jul 14 14:34:02 MSK 2025] Update existing certificate '/certs/example.com.pem' over HAProxy stats socket.
[Mon Jul 14 14:34:02 MSK 2025] Success

Всё, новый сертификат установлен и работает! Теперь про его перевыпуск, который был прописан в крон в докерфайле. Строка выглядит так:

su-exec haproxy acme.sh --renew -d example.com --renew-hook "acme.sh --deploy -d example.com --deploy-hook haproxy"

Т. е., от root запускается скрипт, а дальше команда уже выполняется от пользователя haproxy. Если сертификату больше 60 дней, то он перевыпускается; если меньше, то пишется следующее:

[Mon Jul 14 10:00:40 MSK 2025] The domain 'example.com' seems to already have an ECC cert, let's use it.
[Mon Jul 14 10:00:40 MSK 2025] Renewing: 'example.com'
[Mon Jul 14 10:00:40 MSK 2025] Switching back to https://acme-v02.api.letsencrypt.org/directory
[Mon Jul 14 10:00:40 MSK 2025] Renewing using Le_API=https://acme-v02.api.letsencrypt.org/directory
[Mon Jul 14 10:00:40 MSK 2025] Skipping. Next renewal time is: 2025-09-10T14:21:52Z
[Mon Jul 14 10:00:40 MSK 2025] Add '--force' to force renewal.

Параметр --force, принудительно перевыпускающий сертификат вне зависимости от его срока действия, я рекомендую никогда не использовать. Вас могут заблокировать за чрезмерно частые обращения к сервису, да и смысла часто перевыпускать сертификаты никакого. Если нужно проводить какие-то тесты и эксперименты, пользуйтесь тестовыми серверами Let’s Encrypt (staging servers), где ограничения гораздо мягче. В acme.sh тестовые сервера настраиваются указанием --server letsencrypt_test при регистрации учётки и выпуске сертификата.

Параметр --renew-hook в строке перевыпуска сертификата — это команда, срабатывающая только тогда, когда сертификат перевыпущен. Здесь это команда развёртывания сертификата в каталог, где его увидит реверс-прокси.

Желаю успехов.

Systemd для непривилегированного пользователя

Уже год я работаю девопсом в банке. Это первая работа за много лет, где у меня нет полных прав в системе, то есть, делать что-либо под учёткой root и пользоваться sudo я не могу. Одновременно с этим на серверах могут быть дополнительные ограничения, например, может не быть доступа к планировщику cron, даже к личному через crontab -e. Также, в число моих обязанностей входит развёртывание собранных приложений (в основном это запуск файлов .jar), соответственно, также под непривилегированными учётками. Часто люди пытаются запускать такие приложения в фоне прямо в консольной сессии:

nohup "java -jar be-app-project-${VERSION}.jar --server.port=9090 &"

А потом, чтобы остановить приложение, приходится искать его идентификатор процесса (PID), используя порой вот такие громоздкие конструкции (видел в одном пайплайне развёртывания):

ps -ef | grep be-app-project-*.jar | grep -v grep | awk '$1=="project" {print $2}' | xargs kill

Можно, конечно, для отлавливания PID пользоваться pgrep, что несколько улучшит вид вышеприведённой строки, но общей кривизны решения это не меняет, к тому же, если пытаться такие практики применять в системах CI/CD типа Gitlab или Teamcity, то раннер может не запустить фоновый процесс и будет бесконечно висеть или выдаст ошибку.

Решением всех этих проблем является systemd, сервисы, таймеры и прочие сущности (юниты) которого будут работать в пространстве пользователя, т. е., для управления ими не требуется никакого повышения привилегий. Более того, отпадёт необходимость ухищряться в фоновом запуске приложения из консольной сессии и последующего отлавливания PID. Да и в целом грех не использовать systemd — это современная, гибкая и удобная система, основными препятствиями к освоению которой являются косность мышления, предубеждение против новых решений и нежелание учиться новому. С этим надо бороться по мере сил. С запуском непривилегированных юнитов есть один небольшой нюанс, о нём ниже.

Начнём с замены крона, опишу самый минимум. Предположим, каждые 3 минуты нужно запускать скрипт, который будет добавлять текущее время в текстовый файл. Создаём сам скрипт, например, по пути /home/user/scripts/add-time.sh, в котором обязательно нужно указать шебанг, чтобы скрипт гарантированно ассоциировался с bash.

#!/bin/bash
date +'%F %T' >> ~/add-time.txt

Затем сделать его исполняемым.

user@k3:~$ chmod u+x /home/user/scripts/add-time.sh

Теперь приступим к созданию службы, или сервиса, или демона. Все команды systemctl нужно будет запускать с параметром --user, что указывает на работу не c системными, а с пользовательскими юнитами. Следующая команда создаёт сервис add-time.service в каталоге ~/.config/systemd/user, где будут храниться все сервисы, таймеры и прочие юниты пользователя. Если этого каталога нет, он будет создан автоматически. Эту же команду можно использовать и для редактирования уже существующего юнита.

user@k3:~$ systemctl edit add-time --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
# В ExecStart= указывается абсолютный путь к скрипту. Скрипт должен быть исполняемым, иначе не заработает.
[Service]
ExecStart=/home/user/scripts/add-time.sh

Удобство команды systemctl edit в том, что после неё не нужно перечитывать список служб командой systemctl --user daemon-reload. Теперь создаём таймер, запускающий одноимённую службу с заданной периодичностью.

user@k3:~$ systemctl edit add-time.timer --user --full --force

# Вставить в открывшийся редактор следующий текст и выйти с сохранением.
[Timer]
OnCalendar=*:0/3

[Install]
WantedBy=timers.target

Таймер в systemd исключительно гибкий, он более функционален, чем cron. Например, можно запускать пропущенные задания, если сервер в нужный момент не работал, запускать задания через определённое время после запуска системы или в зависимости от активности/неактивности сервиса, настраивать точность срабатывания таймера для распределения нагрузки, когда в одно и то же время задание начинается на многих серверах и т. д. Секция [Install] здесь нужна для того, чтобы таймер можно было прописать в автозагрузку, т. е. он продолжит срабатывать после перезапуска сервера.

Теперь запустим таймер, пропишем его в автозагрузку и проверим его состояние. Видно, что таймер запускает сервис add-time.service и до его запуска осталась 1 мин 48 сек.

# Запуск
user@k3:~$ systemctl --user start add-time.timer
# Автозагрузка
user@k3:~$ systemctl --user enable add-time.timer
Created symlink /home/user/.config/systemd/user/timers.target.wants/add-time.timer → /home/user/.config/systemd/user/add-time.timer.
# Статус
user@k3:~$ systemctl --user status add-time.timer
● add-time.timer
     Loaded: loaded (/home/user/.config/systemd/user/add-time.timer; enabled; vendor preset: enabled)
     Active: active (waiting) since Wed 2025-04-16 15:46:08 MSK; 3s ago
    Trigger: Wed 2025-04-16 15:48:00 MSK; 1min 48s left
   Triggers: ● add-time.service

Apr 16 15:46:08 k3 systemd[952]: Started add-time.timer.

Через некоторое время видно результат в текстовом файле:

user@k3:~$ cat add-time.txt
2025-04-16 15:48:43
2025-04-16 15:51:43
2025-04-16 15:54:43
2025-04-16 15:57:43
2025-04-16 16:00:43
2025-04-16 16:03:43

Теперь про тот небольшой нюанс запуска пользовательских непривилегированных сервисов systemd, о котором я упоминал. Дело в том, что такие сервисы работают только тогда, когда пользователь работает в системе. Если он не зашёл, его сервисы не работают. К счастью, можно включить возможность выполнения сервисов пользователя вне зависимости от его логина. Сделать это нужно один раз, но команда включения при повторном применении ошибок не даёт, поэтому её можно спокойно использовать без предварительной проверки, например, в плейбуках Ansible.

# "Linger: no" - службы работать без входа в систему не будут
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: no
    [...]

# Включить для текущего пользователя
user@k3:~$ loginctl enable-linger $USER

# Теперь порядок
user@k3:~$ loginctl user-status
user (1000)
           Since: Sat 2025-04-12 12:24:26 MSK; 4 days ago
           State: active
        Sessions: *2
          Linger: yes
    [...]

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

[Unit]
Description=Project App Backend {{ backend_maven_version }}

[Service]
Restart=always
Environment=REPORT_PATH={{ backend_report_directory }}
ExecStart=/usr/bin/java -Xrs -jar {{ backend_lib_directory }}/be-app-project-{{ backend_maven_version }}.jar

[Install]
WantedBy=multi-user.target

Шаги копирования шаблона и запуска службы в плейбуке.

- name: "Copy unit file"
  template:
    src: "{{ service_name }}.service.j2"
    dest: "~/.config/systemd/user/{{ service_name }}.service"

- name: "Start service"
  systemd:
    scope: user
    daemon_reload: true
    name: "{{ service_name }}"
    state: started
    enabled: true

Теперь можно просто и удобно работать со службами systemd (которые, к слову, могут и перезапускаться сами в случае сбоя, быть зависимыми друг от друга и много чего ещё) и не вспоминать про ловлю процессов и висящие в фоне терминалы, и всё это безо всяких привилегий root.

Перекодировка DVD-Video и метаданных в H.265

Попался мне один DVD-Video с концертом, который надо было перекодировать в более компактный и удобный формат. Сегодня это H.265 (HEVC) для видео и OPUS для аудиодорожки. Также, хотелось бы иметь удобную навигацию, то есть, возможность перематывать сразу на начало следующего номера. Для этого необходимо вытащить с DVD метаданные и переделать их в формат, понятный кодировщику ffmpeg. Сразу скажу, что с метаданными-то и возникли проблемы, которые нужно было решить. Всё действие будет происходить в командной строке Powershell. Поехали.

DVD имел стандартный вид — это папка VIDEO_TS с файлами IFO, где хранится меню, и VOB, где содержится видео в формате MPEG-2, порезанное на части по гигабайту. Полезные файлы VOB имеют индекс VTS_<номер видео>_<номер файла>.VOB, где номера начинаются с единицы. Например, такими файлами будут VTS_01_1.VOB, VTS_01_2.VOB и так далее, или VTS_02_1.VOB, VTS_02_2.VOB и так далее. Их можно легко вычислить по большому размеру (столбец Length):

cd "C:\temp\PETERSON_QUARTET\VIDEO_TS"
dir -Recurse -Include "*.ifo","*.vob"

    Directory: C:\temp\PETERSON_QUARTET\VIDEO_TS

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-ar--          01.04.2025    10:01          16384 VIDEO_TS.IFO
-ar--          01.04.2025    10:01        1198080 VIDEO_TS.VOB
-ar--          01.04.2025    10:01          40960 VTS_01_0.IFO
-ar--          01.04.2025    10:01         149504 VTS_01_0.VOB
-ar--          01.04.2025    11:10     1073664000 VTS_01_1.VOB
-ar--          01.04.2025    11:10     1054126080 VTS_01_2.VOB
-ar--          01.04.2025    10:01          43008 VTS_02_0.IFO
-ar--          01.04.2025    10:01         149504 VTS_02_0.VOB
-ar--          01.04.2025    11:10     1073295360 VTS_02_1.VOB
-ar--          01.04.2025    11:10     1073457152 VTS_02_2.VOB
-ar--          01.04.2025    11:05      228806656 VTS_02_3.VOB

Сразу займёмся метаданными. Нужно сказать, что меню DVD-Video могут иметь самую причудливую и неудобную для обработки структуру. Раньше для вытаскивания пунктов меню из файлов IFO я пользовался программой ChapterXtractor, для которой я сделал шаблон, сразу при сохранении создающий файл метаданных ffmpeg с пересчётом временных меток. Но когда я открыл файлы VTS_01_0.IFO и VTS_02_0.IFO, стало ясно, что здесь так сделать не выйдет.

dvd_chapterXtractor1.png
dvd_chapterXtractor2.png

Как видно из картинок, каждое меню содержит полный набор номеров, но часть из них очень короткие, то есть, это явно заглушки. Само меню на DVD двухстраничное, что, наверное, и обусловило наличие двух меню вместо одного. Концерт на DVD один, поэтому эти два меню нужно как-то совмещать.

dvd_menu1.jpg
dvd_menu2.jpg

Для этого я взял консольную программу vgtmpeg, которая представляет собой видоизменённый ffmpeg, дополненный инструментами по работе с DVD/BluRay и т. п. В частности, эта программа умеет показывать метаданные DVD и даже конвертировать их сразу в формат ffmpeg, но сейчас это нам не подходит, так как нужна предварительная обработка. Вот информация, которую vgtmpeg выдаёт об этом диске:

$vgtmpeg = & C:\scripts\vgtmpeg\vgtmpeg.exe -hide_banner -i 'dvd://.' 2>&1

# Вывести результат
$vgtmpeg

Guessed Channel Layout for Input Stream #0.1 : stereo
Input #0, mpeg, from 'dvd://.?title=1':
  Metadata:
    source_type     : dvd
  Duration: 00:42:15.18, start: 0.000000, bitrate: 6714 kb/s
    Chapter #0:0: start 0.000000, end 513.689289
    Chapter #0:1: start 513.689289, end 989.563244
    Chapter #0:2: start 989.563244, end 1420.233467
    Chapter #0:3: start 1420.233467, end 1768.811800
    Chapter #0:4: start 1768.811800, end 2132.419356
    Chapter #0:5: start 2132.419356, end 2533.942333
    Chapter #0:6: start 2533.942333, end 2534.118556
    Chapter #0:7: start 2534.118556, end 2534.294778
    Chapter #0:8: start 2534.294778, end 2534.471000
    Chapter #0:9: start 2534.471000, end 2534.647222
    Chapter #0:10: start 2534.647222, end 2534.823444
    Chapter #0:11: start 2534.823444, end 2534.999667
    Chapter #0:12: start 2534.999667, end 2535.175889
  Program 1 
    Stream #0:0[0x100e0]: Video: mpeg2video (Main), yuv420p(tv, top first), 720x480 [SAR 8:9 DAR 4:3], 29.97 fps, 29.97 tbr, 90k tbn, 59.94 tbc
    Stream #0:1[0x100a0](und): Audio: pcm_dvd, 48000 Hz, stereo, s16, 1536 kb/s
    Metadata:
      language-iso639_2: und
      language-simple : Unknown
      language-description: Unknown
  No Program
    Stream #0:2[0x100bf]: Data: dvd_nav_packet
Guessed Channel Layout for Input Stream #1.1 : stereo
Input #1, mpeg, from 'dvd://.?title=2':
  Metadata:
    source_type     : dvd
  Duration: 00:45:33.19, start: 0.000000, bitrate: 6953 kb/s
    Chapter #1:0: start 0.000000, end 0.176222
    Chapter #1:1: start 0.176222, end 0.352444
    Chapter #1:2: start 0.352444, end 0.528667
    Chapter #1:3: start 0.528667, end 0.704889
    Chapter #1:4: start 0.704889, end 0.881111
    Chapter #1:5: start 0.881111, end 1.057333
    Chapter #1:6: start 1.057333, end 388.582222
    Chapter #1:7: start 388.582222, end 712.003578
    Chapter #1:8: start 712.003578, end 1017.434489
    Chapter #1:9: start 1017.434489, end 1419.978933
    Chapter #1:10: start 1419.978933, end 1858.760578
    Chapter #1:11: start 1858.760578, end 2302.571778
    Chapter #1:12: start 2302.571778, end 2733.185778
  Program 2 
    Stream #1:0[0x200e0]: Video: mpeg2video (Simple), yuv420p(tv, top first), 720x480 [SAR 8:9 DAR 4:3], 29.97 fps, 29.97 tbr, 90k tbn, 59.94 tbc
    Stream #1:1[0x200a0](und): Audio: pcm_dvd, 48000 Hz, stereo, s16, 1536 kb/s
    Metadata:
      language-iso639_2: und
      language-simple : Unknown
      language-description: Unknown
  No Program
    Stream #1:2[0x200bf]: Data: dvd_nav_packet
At least one output file must be specified

Отлично, всё видно. Нужные строки — со словом chapter. Сделаем из них таблицу и вычислим длительность частей в дополнительной колонке.

$csv = $vgtmpeg -match 'chapter' -replace '.*#(\d+:\d+).*start ([\d\.]+).*end ([\d\.]+)','$1;$2;$3' |
ConvertFrom-Csv -Header Chapter,Start,End -Delimiter ';' |
select *,@{n='Range';e={$_.End - $_.Start}}

# Вывести результат
$csv

Chapter Start       End          Range
------- -----       ---          -----
0:0     0.000000    513.689289  513,69
0:1     513.689289  989.563244  475,87
0:2     989.563244  1420.233467 430,67
0:3     1420.233467 1768.811800 348,58
0:4     1768.811800 2132.419356 363,61
0:5     2132.419356 2533.942333 401,52
0:6     2533.942333 2534.118556   0,18
0:7     2534.118556 2534.294778   0,18
0:8     2534.294778 2534.471000   0,18
0:9     2534.471000 2534.647222   0,18
0:10    2534.647222 2534.823444   0,18
0:11    2534.823444 2534.999667   0,18
0:12    2534.999667 2535.175889   0,18
1:0     0.000000    0.176222      0,18
1:1     0.176222    0.352444      0,18
1:2     0.352444    0.528667      0,18
1:3     0.528667    0.704889      0,18
1:4     0.704889    0.881111      0,18
1:5     0.881111    1.057333      0,18
1:6     1.057333    388.582222  387,52
1:7     388.582222  712.003578  323,42
1:8     712.003578  1017.434489 305,43
1:9     1017.434489 1419.978933 402,54
1:10    1419.978933 1858.760578 438,78
1:11    1858.760578 2302.571778 443,81
1:12    2302.571778 2733.185778 430,61

Вот, стало гораздо лучше. В колонке Range числа хранятся точные, просто при выводе всей таблицы отображаются в сокращённом виде. Например, если вывести второе значение отдельно, то оно будет 475,873955, так что по поводу корректности расчётов можно не беспокоиться. Теперь наглядно видно, что первые 6 частей берутся из первого меню, а последующие — из второго. Из-за того, что начала и окончания частей не идут последовательно по времени, надо их реконструировать. Для этого я взял первую метку начала и дальше прибавлял к ней длину из Range, а потом отсеивал те части, которые меньше 5 секунд.

$timeline = @()
$t = $csv[0].Start
$csv |% {
    $obj = [PSCustomObject]@{
        Start = $t
        End = $t + $_.Range
        Range = $_.Range
    }
    $t = $t + $_.Range
    $timeline += $obj |? Range -gt 5
}

# Вывести результат
$timeline

  Start     End  Range
  -----     ---  -----
   0,00  513,69 513,69
 513,69  989,56 475,87
 989,56 1420,23 430,67
1420,23 1768,81 348,58
1768,81 2132,42 363,61
2132,42 2533,94 401,52
2536,23 2923,76 387,52
2923,76 3247,18 323,42
3247,18 3552,61 305,43
3552,61 3955,15 402,54
3955,15 4393,94 438,78
4393,94 4837,75 443,81
4837,75 5268,36 430,61

# Показать количество строк
$timeline.count
13

Прекрасно! Ровно 13 частей. Можно теперь лепить файл метаданных ffmpeg.

# Названия частей
$titles = ("Cakewalk
Love Ballade
Soft Winds
You Look Good To Me
My One And Only Love
Nigerian Market Place
Cool Walk
I Can't Get Started
Come Sunday
Reunion Blues
If You Only Knew
Sushi Blues
Blues Etude") -split "`n"

# Создание нового файла метаданных file.ffmeta для ffmpeg, заголовок
";FFMETADATA1
title=Recorded 'LIVE' at Kan-i Hoken Hall, Tokyo on February 28, 1987
artist=Oscar Peterson Featuring Joe Pass - The Quartet Live
" > file.ffmeta

# Добавление частей с названиями, переделка времени в миллисекунды
$c = 0
$timeline |% {
"[CHAPTER]
TIMEBASE=1/1000
START=$($_.start.tostring("0.000") -replace '\D')
END=$($_.end.tostring("0.000") -replace '\D')
title=$($titles[$c])
"
$c++
} >> file.ffmeta

Сначала я округлял число до тысячных: [Math]::Round($_.start, 3), и получил вроде бы правдоподобный результат, но выяснилось, что если последней цифрой после запятой оказывался 0, то он, естественно, пропадал и ffmpeg при попытке вшить эти метаданные в видеофайл выдавал ошибку, так как такая метка была на один десятичный разряд меньше и оказывалась по времени раньше предыдущей. Поэтому задействовал .tostring("0.000"), что гарантировало корректный перевод в строку.

Фрагмент полученного файла file.ffmeta:

;FFMETADATA1
title=Recorded 'LIVE' at Kan-i Hoken Hall, Tokyo on February 28, 1987
artist=Oscar Peterson Featuring Joe Pass - The Quartet Live

[CHAPTER]
TIMEBASE=1/1000
START=0000
END=513689
title=Cakewalk

[CHAPTER]
TIMEBASE=1/1000
START=513689
END=989563
title=Love Ballade

[CHAPTER]
TIMEBASE=1/1000
START=989563
END=1420233
title=Soft Winds

С метаданными разобрались, теперь дело за перекодировкой. Параметры -analyzeduration 100M -probesize 100M нужны, чтобы увеличить глубину определения аудиопотоков, потому что без них ffmpeg иногда не видел в DVD аудиодорожку. На вход подаются все файлы VOB, склеенные через concat: по порядку, и файл file.ffmeta как метаданные.

DVD-Video — старый формат, и видео там чересстрочное (interlaced), когда кадр делится на полукадры — «поля» (fields) — которые выводятся на экран последовательно. Раньше я всегда оставлял чересстрочную развёртку, когда кодировал в H.264 (AVC), потому что качество работы фильтров деинтерлейса тогда часто вызывало вопросы. H.265 тоже вроде как-то поддерживает чересстрочное видео, но cмысла заниматься этой экзотикой сегодня я не вижу, потому что найден прекрасный фильтр деинтерлейса bwdif. Оказалось, что наилучшие результаты деинтерлейса достигаются при удвоении частоты кадров — видео получается плавное и никакой «гребёнки» и «теней» в кадре не наблюдается. Фильтр сам умеет определять порядок полей, так что в большинстве случаев никаких настроек не требуется. А то, что видео получается 60 кадров/сек, сегодня уже не проблема.

Мой процессор поддерживает аппаратное кодирование в H.265, поэтому используется кодировщик hevc_qsv, качество 26 (настройка в сторону лучшего качества, чем стандартные 28 в софт-варианте libx265), хотя можно поставить, к примеру, и 24, если источник шумный. Звуковой кодек — libopus, 192 кбит/сек, чего вполне достаточно для любого стереосигнала. Тэг-идентификатор формата видео я ставлю по старой памяти; полагаю, можно обойтись и без него.

ffmpeg -analyzeduration 100M -probesize 100M `
-i "concat:VTS_01_1.VOB|VTS_01_2.VOB|VTS_02_1.VOB|VTS_02_2.VOB|VTS_02_3.VOB" -i file.ffmeta `
-vf bwdif -c:v hevc_qsv -c:a libopus -b:a 192k `
-global_quality:v 26 -tag:v hvc1 "Oscar Peterson - The Quartet Live (1987).mp4"

Результат: конечный файл занимает 655 МБ против 4,2 ГБ у исходного DVD. Качество никак не пострадало, заголовки и навигация на месте.

P. S. Вместо ручного заполнения названий частей и заголовков можно взять их из онлайн-каталога, где есть API. Вот вариант с Discogs.

Cсылка на этот конкретный DVD: https://www.discogs.com/ru/release/19022464-Oscar-Peterson-Featuring-Joe-Pass-The-Quartet-Live, оттуда надо взять идентификатор — это цифры после слова release.

# Получение данных
$release = Invoke-WebRequest https://api.discogs.com/releases/19022464 -UserAgent "FooBarApp/3.0" |ConvertFrom-Json

# Cписок треков для метаданных и заголовки задаются уже из полученной информации с сайта
$titles = $release.tracklist.title

";FFMETADATA1
title=$($release.title)
artist=$($release.artists_sort)
" > file.ffmeta

Справка по Discogs API.

Собрал компьютер сыну

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

Сначала думал о чём-то покомпактнее, типа формата mini-ITX, но в результате не стал связываться с корпусами с нестандартными блоками питания и взял обычный microATX в Регарде, где есть удобный конфигуратор системного блока, чтобы не особенно раздумывать о совместимости того или иного железа. Можно было там же заказать и сборку, но мы хотели собирать сами.

Материнская плата MSI A520M-A PRO

Здесь у меня не было особых критериев, выбирал просто по отсутствию сильно устаревших разъёмов, давно знакомому бренду и компоновке задней панели, как это ни удивительно. Рассматривал также Gigabyte и ASRock.

Процессор AMD Ryzen 5 5600X OEM + кулер ID-COOLING FROZN A410 Black

Тут выбор базировался на показателе best value на сайте cpubenchmark.net, где процессор AMD Ryzen 5 5600, который я и хотел взять изначально, находится на первом месте.

PassMark - Price Performance

Я заказал боксовый вариант, но мне перезвонил менеджер (спасибо ему!) и предложил более высокочастотный 5600X и отдельный кулер-башню за те же деньги. Меня беспокоило, не повышен ли теплопакет у 5600X, но оказалось, что это те же 65 Вт, поэтому я согласился и не пожалел. Кулер отличный и даже, пожалуй, избыточный для моего случая, но это вряд ли можно назвать недостатком. Чтобы поставить его, нужно было открутить от материнской платы штатный крепёж и привинтить идущий в комплекте. Шприц с термопастой также прилагался.

Оперативная память Kingston Fury Beast Black 32GB DDR4 3200MHz 2x16GB kit (KF432C16BB1K2/32)

В принципе, хватило бы и 16 ГБ, но пусть уже будет, чтобы потом не возвращаться к этой теме. Тем более, цены на память пока не космические.

Накопитель SSD Samsung 970 EVO Plus 1TB (MZ-V7S1T0BW)

Те же соображения насчёт объёма, что и по памяти. В моём рабочем неттопе стоит та же модель, но вполовину меньше, а стоила она тогда примерно столько же, и это при том, что было совсем другое время и доллар стоил сильно дешевле.

Видеокарта NVIDIA GeForce RTX 3050 MSI 8Gb (RTX 3050 VENTUS 2X XS 8G)

Видеокарту я сначала хотел взять с 6 ГБ памяти, но потом прочёл, что у такой модификации уменьшена пропускная способность шины и соотношение цены и эффективности хуже, поэтому взял с 8 ГБ, где этого недостатка нет, а цена выше незначительно. А ещё у этих видеокарт есть аппаратный кодировщик NVENC, умеющий работать с AVC/HEVC — это пригодится для стримов и просто для кодирования видео.

Wi-Fi адаптер ASUS PCE-AXE5400

Чтобы не тянуть провод от роутера из коридора, занял единственный разъём PCI-E x1 на материнской плате этим устройством. Работает прекрасно.

Блок питания Zalman MegaMax 700W (ZM700-TXIIv2)

Другой блок питания Zalman замечательно работает у меня уже 5 лет в сетевом хранилище и я был рад, что БП этой фирмы доступны для покупки — они тихие и надёжные. Для видеокарты рекомендован БП в 500 Вт, так что, полагаю, мощность адекватная этой конфигурации.

Корпус AeroCool Cs-111 Black

Корпус удобный, с прозрачной дверцей, которая даже не прикручивается винтами, а просто удерживается магнитиком, и внутрь корпуса можно залезть без помех в любой момент. Есть пара мелких недостатков: внутри установлен 120-мм вентилятор без возможности регулировки оборотов, поэтому я заменил его на Aerocool Frost 12 PWM, у которого такая функция есть. Также, у этого корпуса есть два места под вентиляторы на крыше, а мне как раз это не нужно и я хотел бы эту перфорацию заглушить, чтобы туда не оседала пыль. Пока не придумал, как это сделать приличнее нарезания чёрного картона и его прикручивания к внутренней стороне, а 3d-печать мне сейчас недоступна. Каких-то фирменных заглушек в продаже я не нашёл. Ну, покамест накрыли эти отверстия расписанием уроков.

Монитор MSI 24″ Pro MP242A

«Раз уж и материнская плата, и видеокарта этой фирмы, то пусть уж и монитор будет», — подумал я опять в нерациональной манере. Рациональным было то, что этот монитор один из самых дешёвых FullHD с IPS-матрицей и HDMI-разъёмом. Написано «100 герц», хотя я никогда не понимал, какое отношение эти герцы имеют к ЖК-мониторам, ладно ЭЛТ были, там да. Показывает хорошо, и даже есть встроенные динамики.

Колонки Sven SPS-585 Black

Я выбирал колонки, чтобы у них не было эквалайзера, чтобы звук просто шёл напрямую без регулировок тембра. Таких колонок очень немного в потребительском сегменте. Эти оказались очень достойным вариантом. Из-за небольшого размера низкочастотного динамика пресловутые басы недостаточно глубокие, но колонки играют чисто и цену свою оправдывают полностью.

Микрофон Fifine K669 Black

Про микрофоны Фифайн я впервые услышал на Youtube-канале прекрасного Стива Сегуина, автора бесплатного сервиса видеоконференций VDO.Ninja. Микрофоны эти удивляют высоким качеством за свою достаточно скромную цену. Я купил один из самых дешёвых. Конечно, качество звука стало несопоставимо лучше, чем у микрофона, встроенного в веб-камеру Sven IC-950 HD, валявшуюся раньше у меня в ящике.

Прочее

  • Игровая клавиатура механическая проводная Redragon Fizz тихая, RED SWITCH
  • Игровая мышь проводная TechFurn, черный, серый
  • Коврик для компьютерной мышки и клавиатуры Aksholan, 800х300х3мм, «Карта мира», черный

Резюме

Машина-зверь, конечно, на мой неискушённый взгляд — где-то раза в 2-3 мощнее моего рабочего неттопа, который теперь перестанут мучать играми. BIOS материнской платы я сразу прошил на самую свежую бету, лежащую на сайте производителя, операционка установилась со свистом, грузится она за несколько секунд, всё прямо-таки летает. Тестированием не увлекались, бенчмарки всякие не запускали; пока в ходу Roblox и Minecraft, они работают отлично на максимальных настройках. В общем, хорошо получилось, и владелец доволен. Хватило бы лет на семь-восемь — было бы славно. Главное, чтобы все были живы-здоровы.

Новый домашний сервер

4-го августа я переехал со старого неттопа на новый GMKtec NucBox G3. Внутри — процессор Intel N100 Alder Lake 12-го поколения, 32 ГБ памяти Kingston PC4-25600 DDR4, 1 ТБ SSD Samsung 980 Pro и Wi-fi Realtek RTL8852BE (802.11ax), также имеется дополнительный порт для SSD-накопителя формата M.2 2242 (SATA). Я после установки системы Ubuntu 24.04 стал использовать wi-fi на время настройки, планируя потом подключиться к роутеру по проводу, да так на нём и остался — удобно, работает стабильно и скорость хорошая.

Внешний вид сервера Внешний вид сервера

Из минусов — мелкий вентилятор в нижней части корпуса, но пока минус этот скорее теоретический, потому что сейчас шума от него не слышно вообще после следующих настроек в BIOS, которые я подсмотрел на Reddit:

  1. Выставить TDP на 8 Вт (с 10 Вт), макс. потребление будет 19 Вт (с 24 Вт). В покое 8-9 Вт, выключенный — 1,2 Вт. Power -> Power limit select: 8W
  2. Включить Cstates (по умолчанию выключено). Advance -> CPU - Power management control -> C States: enabled
  3. Выключить турборежим процессора при загрузке. Advance -> CPU - Power management control -> Boot performance mode: Max Non-Turbo performance
  4. Повысить порог срабатывания вентилятора. Advance -> Hardware monitor -> Smart fan function -> Fan off: 40, Fan start: 65

Почему я решил поменять железо? Началось с того, что какой-то малолетний кретин бил мне по входной двери ногой и убегал. Чтобы выяснить, кто это делает, я организовал видеонаблюдение через глазок, на который установил альтернативный веб-сервер, позволивший мне получить с глазка потоки RTSP, которые шли на сервер Frigate NVR, поднятый всё в том же Докере. Для установки глазка пришлось немного рассверлить в двери дырку под него, купив очень красивое ступенчатое сверло.

Frigate Frigate

У Frigate есть возможность использовать различные варианты аппаратного видеоускорения и моделей обнаружения объектов, но на старом неттопе была доступна только чисто процессорная обработка. Это работало, но загрузка была довольно приличная, да и в целом эта конфигурация уже устарела — ей восемь лет, последние три из которых она работала круглосуточно. Так что в конце 2023 года я купил новый неттоп, который провалялся без дела до конца июля, когда, наконец, у меня дошли руки перевезти все сервисы со старого.

Сервисы на сегодняший день такие:

  • Реверс-прокси Træfik — обновил c версии 2.6 на 3.1, немного изменился синтаксис ярлыков, теперь там используются регулярные выражения вместо перечисления нескольких суффиксов или имён хоста, а ещё HTTP/3 сменил экспериментальный статус на стабильный.
  • Вышеупомянутый Frigate, у которого я включил видеоускорение VAAPI и модель OpenVino. Позже, наверное, надо бы попробовать добавить к нему Home Assistant как управляющую оболочку.
  • Блог на прекрасном движке Datenstrom Yellow
  • Вики на DokuWiki
  • Файловый сервис Nextcloud
  • Фотосервис Photoprism
  • Генеалогическое древо Webtrees
  • Страничка мониторинга (node_exporter, docker metrics, glances, smartctl_exporter, Prometheus, Grafana)

С мониторингом была сложная история. Я использовал PhpSysInfo в контейнере, и мне хотелось избавиться от довольно кривого способа сбора информации раз в полчаса на самом хосте и подкладывания файлов в веб-каталог приложения. Оказалось, что PhpSysInfo умеет работать через SSH, поэтому я создал на хосте выделенного пользователя и разрешил ему выполнять команды sensors и docker stats через sudo без запроса пароля.

sudo visudo /etc/sudoers.d/phpsysinfo

Cmnd_Alias PHPSYSINFO=/usr/bin/docker stats*, /usr/sbin/smartctl
phpsysinfo ALL = NOPASSWD: PHPSYSINFO

Пришлось открыть пару тем в репозитории Гитхаба из-за того, что какие-то моменты были непонятны, а какие-то не работали, но автор приложения, как и раньше, невероятно отзывчив и решает проблемы просто молниеносно, так что всё более-менее работало и прочитанные/записанные данные диска теперь показывались в понятных гигабайтах, а не в секторах, как раньше. Тем не менее, теперь вывод страницы после захода на неё стал очень тормозной — PhpSysInfo каждый раз лезет на хост по SSH и собирает данные (на полях отмечу утилиту sshpass, позволяющую автоматически подставлять пароль для входа). Вдобавок, перестало нормально работать отображение скопившихся системных обновлений, так что я начал искать альтернативу.

Glances Glances

Через некоторое время я нашёл Glances, у которого мне очень понравился внешний вид, но проект пока сыроват и документация у него достаточно фрагментарная, поэтому я решил вернуться к давней идее, на которую у старого неттопа не хватало ресурсов — Prometheus + Grafana. В конце концов, популярная вещь и по работе пригодится. Провозившись несколько дней, вспоминая, как там всё устроено, я нарисовал страницу более-менее в соответствии с моими предпочтениями. Что до производительности, то неттоп легко справляется с новым мониторингом. Glances я оставил как сборщик данных о контейнерах: он умеет экспортировать метрики для Prometheus.

Grafana Grafana

Все контейнеры с БД я обновил до MariaDB 11.4 с версии 10.6, завелось без проблем. PHP был обновлён на версию 8.3 ещё на старом неттопе. Docker volumes переделал просто в mount points — мне кажется, мигрировать данные и делать резервные копии так удобнее, каких-то преимуществ docker named volumes в моём случае я не вижу.

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

Пока мне нравится, поживём — увидим.