root@homelab

TeamSpeak 6 вместо TS3, тар-архив, съевший свой же бинарник, и гонка со свежим контейнером

Черновик плана миграции начинался с TS3 — identity-файл, ts3server.sqlitedb, перенос состояния бит-в-бит. Ошибочное предположение: домашний голосовой сервер уже давно и целиком на TS6, TS3 — это вообще из школьных времён, к текущей инфраструктуре отношения не имеет. Официальный путь установки TS6 — Docker, разработчики прямым текстом рекомендуют именно его. Отказался ради самой идеи IaC — и тут же поймал тар-архив, который при неаккуратной распаковке тихо съедал не только сам бинарник сервера, но и нужные ему динамические библиотеки. А когда всё наконец завелось, плейбук стабильно падал с первого раза на только что созданном контейнере — и виноват в этом не код роли, а то, что Terraform и гостевая ОС не разговаривают друг с другом синхронно.

Второй сервис в переезде на IaC после Caddy — TeamSpeak, домашний голосовой сервер на 3-4 человека (я и друзья). Ожидал рутинный перенос по уже обкатанной на Caddy схеме: .tfcheck-ip.shtofu apply → Ansible-роль → cutover. Получилось куда интереснее.

Первая неожиданность: план изначально был про не тот сервер

В заметках по миграции перенос был расписан под TS3 — identity-файл, ts3server.sqlitedb, перенос состояния бит-в-бит. Предположение оказалось попросту неверным с самого начала: TS3 я гонял разве что в школе, а домашний сервер, который нужно было мигрировать, уже год как целиком на TS6 — без какого-либо перехода посреди работы, просто заметки изначально описывали не тот продукт. TS6 — не новая версия того же сервера, а полностью другой бинарник, другой формат конфига (YAML вместо .ini), другая модель лицензии. Из хорошего — в базе всего 3-4 записи (я и друзья), которые не жалко потерять, так что решил не тащить состояние вообще, а поставить последнюю бету с нуля.

HA-ресурс без префикса и vm: вместо ct:

Пока писал .tf для нового контейнера, заодно решил повесить его на Proxmox HA (до этого ни один хост в новой инфраструктуре HA не использовал). Провайдер bpg/proxmox, которым тут всё описано, называет вообще все свои ресурсы с префиксом proxmox_virtual_environment_* — кроме одного: proxmox_haresource идёт без него, что сначала выглядело как опечатка или вообще несуществующий ресурс. Оказалось, реальный, просто исключение из общего правила именования.

Отдельно чуть не подловился на формате resource_id. У Proxmox HA идентификаторы ресурсов — это <тип>:<vmid>, где тип vm для QEMU-виртуалок и ct для LXC. Первая версия ресурса ссылалась на vm:104, хотя сам контейнер — LXC, то есть ct:104. Не заметил бы — получил бы HA-запись, указывающую в пустоту (VM с таким ID просто не существует).

Новая схема нумерации: VMID = VLAN + IP

Заодно поменял подход к номерам контейнеров: раньше шли просто по порядку (101, 102, 103...), теперь номер собирается из VLAN и последнего октета IP — контейнер на VLAN10 с адресом .4 становится 104. Мнемонически удобно: по одному числу сразу видно, где искать хост в сети. Расплата — в этом же диапазоне (VLAN10, 100-199) с домиграционных времён живут легаси-контейнеры под номерами вроде 100, 121, 125, так что при выборе следующих IP в этом VLAN теперь придётся вручную проверять, не попадёт ли новый номер на ещё не снесённый старый хост.

Docker — официально рекомендованный путь, который я всё равно не взял

README TS6-сервера прямым текстом советует ставить его через официальный Docker-образ, а не голый бинарник — версии образа даже совпадают с релизными тегами на GitHub, что закрывало бы вопрос автообновлений практически бесплатно. Соблазн был; отказался. Вся эта миграция — про то, чтобы состояние инфраструктуры целиком описывалось в git и накатывалось Terraform/Ansible, а не пряталось внутри ещё одного слоя оркестрации со своим собственным жизненным циклом контейнеров. Раз уж взялся за самодельный IaC — довёл принцип до конца: голый бинарник, скачанный из GitHub Releases, свой systemd-юнит с нуля.

Архив, который выборочно терял файлы

Первая попытка распаковать релизный tar.xz через unarchive со --strip-components=1 (стандартный приём для архивов с общей корневой директорией) вместо ожидаемого запуска дала status=203/EXEC — systemd не смог даже вызвать execve() на бинарнике. Оказалось, у архива TS6 нет общей обёрточной директории: tsserver лежит прямо в корне архива, рядом с несколькими вложенными папками документации разной глубины.

--strip-components=N у GNU tar не «поднимает файл на уровень выше», если снимать нечего — он молча выбрасывает такие записи при экстракции. В корне архива, помимо самого бинарника, лежали ещё и нужные ему разделяемые библиотеки (libssh.so.4, libtsdb_mariadb.so, libtsdb_sqlite3.so) — все они пропадали одинаково тихо, без единой ошибки на этапе распаковки. Файлы документации на глубине 1-2 уровня просто сдвигались и оставались на месте, что и запутывало картину — часть архива выглядела распакованной нормально.

Фикс — убрать strip вообще, распаковывать как есть.

Следующий раунд обнаружился уже после первого успешного старта процесса: setSQLfromFile(file:/opt/teamspeak6/data/sql/create_sqlite/create_tables.sql) failed. Сервер резолвит путь к SQL-схеме базы относительно своей рабочей директории — а она у меня осознанно указывает на отдельную папку с данными (data/), не на директорию с бинарником текущего релиза, именно чтобы апдейт версии никогда не задевал живые данные. Схема же — не пользовательские данные, а часть релиза, и лежит внутри current/sql/. Решилось символической ссылкой data/sql -> current/sql, которая переезжает вместе с релизом при каждом апдейте версии — data и релиз остаются раздельными, а бинарник получает то, что ожидает найти у себя под ногами.

Плейбук, который всегда падает с первого раза

Отдельно всплыла закономерность, не специфичная для TeamSpeak: первый запуск ansible-playbook сразу после tofu apply на новый контейнер стабильно падает на Gathering Facts с Connection timed out, а второй запуск тут же подключается без единой проблемы. Причина — гонка между API Proxmox (который считает создание контейнера завершённым, как только гипервизор отчитался) и гостевой ОС внутри (которой ещё нужно время поднять сеть и sshd). Gathering Facts — неявный самый первый таск в любом play, и именно в него упирается race condition раньше, чем успевает сработать любая защита внутри плейбука.

Лечится модулем wait_for_connection — в отличие от банального sleep, он реально пытается установить соединение через SSH в цикле, пока не получится или не кончится таймаут. Единственная тонкость: положить его нужно строго в pre_tasks, а не в tasks — Ansible выполняет секции play в фиксированном порядке (pre_tasksrolestaskspost_tasks) независимо от того, в каком порядке ключи идут в самом YAML-файле. Спутать это несложно, и тогда ожидание просто не успевает сработать до того, как роль уже попыталась подключиться.

Что в итоге

Главный урок этого захода — не доверять собственной памяти о том, что запущено «уже год как настроено и работает»: TS3 давно превратился в TS6, и если бы не истекающая лицензия, узнал бы об этом только в процессе переноса, а не до него.

Второй — про tar --strip-components: удобный флаг для типового случая (архив с одной общей директорией) молча ломается на нетиповом, и ломается он не явной ошибкой, а тихим исчезновением файлов, которое легко принять за что-то другое.

Третий — что гонка между провижининг-слоем (Terraform/OpenTofu) и готовностью гостевой ОС не специфична для TeamSpeak и будет повторяться на каждом новом хосте, если не заложить ожидание в сам плейбук с самого начала.

Дальше в очереди — proxy (SOCKS5, завязанный на openvpn-туннель) и сам openvpn.

proxmox ansible teamspeak iac

Спасибо, что дочитали до конца <3