TeamSpeak 6 вместо TS3, тар-архив, съевший свой же бинарник, и гонка со свежим контейнером
9 сентября 2026
Черновик плана миграции начинался с TS3 — identity-файл,
ts3server.sqlitedb, перенос состояния бит-в-бит.
Ошибочное предположение: домашний голосовой сервер уже давно и
целиком на TS6, TS3 — это вообще из школьных времён, к текущей
инфраструктуре отношения не имеет. Официальный путь установки TS6 —
Docker, разработчики прямым текстом рекомендуют именно его. Отказался
ради самой идеи IaC — и тут же поймал тар-архив, который при
неаккуратной распаковке тихо съедал не только сам бинарник сервера,
но и нужные ему динамические библиотеки. А когда всё наконец
завелось, плейбук стабильно падал с первого раза на только что
созданном контейнере — и виноват в этом не код роли, а то, что
Terraform и гостевая ОС не разговаривают друг с другом синхронно.
Второй сервис в переезде на IaC после Caddy — TeamSpeak, домашний
голосовой сервер на 3-4 человека (я и друзья). Ожидал рутинный
перенос по уже обкатанной на Caddy схеме: .tf →
check-ip.sh → tofu 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_tasks → roles → tasks →
post_tasks) независимо от того, в каком порядке ключи
идут в самом YAML-файле. Спутать это несложно, и тогда ожидание
просто не успевает сработать до того, как роль уже попыталась
подключиться.
Что в итоге
Главный урок этого захода — не доверять собственной памяти о том, что запущено «уже год как настроено и работает»: TS3 давно превратился в TS6, и если бы не истекающая лицензия, узнал бы об этом только в процессе переноса, а не до него.
Второй — про tar --strip-components: удобный флаг для
типового случая (архив с одной общей директорией) молча ломается на
нетиповом, и ломается он не явной ошибкой, а тихим исчезновением
файлов, которое легко принять за что-то другое.
Третий — что гонка между провижининг-слоем (Terraform/OpenTofu) и готовностью гостевой ОС не специфична для TeamSpeak и будет повторяться на каждом новом хосте, если не заложить ожидание в сам плейбук с самого начала.
Дальше в очереди — proxy (SOCKS5, завязанный на
openvpn-туннель) и сам openvpn.
Спасибо, что дочитали до конца <3