Я давно хотел захостить свой небольшой проект — бота, которым пользуюсь сам. Останавливал меня не код, а хостинг. Не хотелось арендовать VPS и настраивать всё удалённо — а потом обновлять и следить, чтобы не упало. Переплачивать за готовый хостинг, лишь бы этим не заниматься, тоже не хотелось. Так это и оставалось идеей.
Зато был старый OnePlus 3T в ящике: флагман 2016 года, Snapdragon 821, 6 ГБ оперативной памяти — рабочий, но не включавшийся годами. Выбрасывать рабочий компьютер не хотелось, а для одного небольшого always-on сервиса это более чем достаточно: пара ватт в простое, своя батарея, никаких ежемесячных платежей.
Загвоздка в том, что это телефон. Загрузчик с завода заблокирован, стоит Android, батарея не рассчитана на постоянную зарядку, и нет вентилятора. Дальше — как я разбирался с каждым из этих пунктов.
Одно условие: всё это возможно только потому, что загрузчик OnePlus 3T в принципе можно разблокировать. На новых телефонах это всё чаще невозможно, так что переиспользование телефона во многом упирается в выбор модели, которую вообще дают разблокировать.
Ставим Linux на телефон
Я ставил postmarketOS: mainline-Linux, systemd, пакеты Alpine — обычная Linux-система, а не Android.
Если нужно просто Linux-окружение, чтобы поэкспериментировать, ничего заменять не обязательно. Termux на Android вместе с proot-distro даёт окружение Debian или Alpine поверх штатной системы, без разблокировки. Но это всё равно Android со всеми его минусами.
Разблокировка и бэкап
OnePlus 3T разблокируется командой fastboot oem unlock — без запроса кода у вендора и без периода ожидания. Разблокировка стирает устройство, поэтому первым делом — полный бэкап штатной системы: все разделы, 56 из 56, 3.4 ГБ, с манифестом имён и размеров. Он пригодился: позже я восстанавливался из него, когда первая сборка не загрузилась.
lk2nd
На msm8996 практичный способ загрузить mainline-ядро — lk2nd, небольшой открытый вторичный загрузчик. Первичный загрузчик подписан Qualcomm и остаётся на месте; lk2nd ставится рядом. lk2nd-msm8996.img прошивается в раздел boot; он загружается при старте, даёт чистый интерфейс fastboot и загружает mainline boot-образ со смещением 512 КБ (первые 512 КБ раздела занимает сам). pmbootstrap про него знает и пишет настоящий boot-образ по нужному смещению автоматически.
Зависает при загрузке
Первая собранная мной postmarketOS зависала на сплэше lk2nd при каждой загрузке. На маке не появлялось USB-сетевого устройства, экран оставался на сплэше, и понять — рано упало ядро или lk2nd не передал управление — было нельзя. Без консоли остаётся только гадать, а msm8996 — это 2016 год: в нём нет того аппаратного USB-отладчика, что есть в более новых Snapdragon, так что единственный отладочный serial — UART на 1.8 В, выведенный на USB-разъём, до которого нужен специальный джиг; его у меня не было, и возиться с ним ради этого не хотелось. Я восстановил бэкап, вернулся на Android и стал искать другой способ увидеть, что происходит.
Получить логи загрузки без UART всё-таки можно: fastboot oem log выводит лог работы lk2nd — какой DTB он нашёл и куда передал управление, — а ядро можно настроить так, чтобы оно сохраняло последний лог в pstore, и зависшую загрузку можно было прочитать потом. Пробовал и обычные для Qualcomm параметры ядра против таких зависаний — clk_ignore_unused, arm-smmu.disable_bypass, console=ttyMSM0 — ничего не дало.
Самый свежий релиз postmarketOS разом обновил многое, в том числе ядро — с 6.3.1, которое не меняли годами, до 6.19.5, и этот релиз вешает OnePlus 3T до initramfs. Я не выяснял, дело в самом ядре или в чём-то ещё новом в релизе; откатился на предыдущую версию, где ядро всё ещё 6.3.1, пересобрал — и оно загрузилось. USB-сеть поднялась на 172.16.42.1, фреймбуфер ожил.
Не монтируется файловая система
Загрузка доходила до initramfs и останавливалась:
Trying to mount subpartitions .. ERROR: failed to mount subpartitions
и сваливалась в отладочную консоль, доступную по telnet на 172.16.42.1:23. Дело в размере сектора накопителя. Раздел userdata на телефоне — устройство с 4-КБ сектором UFS, а таблица разделов в образе была записана из расчёта 512-байтных секторов, так что ядро не могло прочитать вложенный GPT и не создавало подразделы. Лечится одним флагом — pmbootstrap install --sector-size 4096, — которого в профиле OnePlus 3T просто нет, хотя у его собратьев по msm8996 он выставлен.
fastboot не пишет userdata
Ещё одно: fastboot flash userdata у меня просто не работает — пишет «успех», но ничего не записывает, старая файловая система остаётся на месте. Прошивать приходится через TWRP recovery, записывая образ прямо в блочные устройства by-name через dd (или simg2img для sparse-образа). dd TWRP урезанный(как минимум в той версии, которую я использую): размеры — только в байтах, а conv=fsync он не понимает.
После этого телефон загрузился как обычный Linux: Linux op3t 6.3.1-msm8996 aarch64, postmarketOS, 6 ГБ памяти, корневой раздел растянут на весь диск, есть доступ по SSH.
Делаем сборку воспроизводимой
Собрать образ один раз руками — нормально. Повторять все после каждого изменения — нет, и одно из моих правил было: каждый ручной шаг должен оказаться в скрипте. Так что весь процесс я свёл в один скрипт, который запускает pmbootstrap в контейнере и прошивает с мака.
pmbootstrap не работает на macOS — ему нужны Linux-chroot и loop-устройства — поэтому он работает в привилегированном Linux-контейнере. Docker Desktop на Apple Silicon поднимает arm64 Linux виртуальную машину, так что pmbootstrap собирает aarch64-образ нативно, без QEMU в цепочке. Каталог проекта примонтирован внутрь, собранные образы попадают обратно на мак, а прошивка идёт с мака по USB.
Чтобы скрипт заработал надёжно, пришлось исправить несколько багов.
Пустой образ
Одна сборка сообщила об успехе, прошивка побитово сошлась — и телефон сразу оказался в отладочной консоли initramfs. Образ был валидным GPT поверх 1.3 ГБ нулей — около 271 ненулевого байта на весь файл.
Оказалось, что внутри контейнера pmbootstrap не мог создать узлы loop-разделов:
ERROR: Unable to find the first partition of /dev/loop0, expected it to be at /dev/loop0p1!
Docker монтирует /dev как tmpfs, поэтому когда losetup -P просит ядро создать /dev/loop0p1, узел не появляется в /dev контейнера. pmbootstrap пишет таблицу разделов, но не может создать и заполнить файловые системы внутри. Монтирование настоящего devtmpfs поверх /dev в контейнере сборки заставляет узлы появиться.
А «успехом» это обернулось из-за второго бага. Установка шла как pmbootstrap … install | tail внутри docker exec sh -lc '…'. У внешнего скрипта стоял set -o pipefail, но он не действует во внутренней оболочке, которую запускает docker exec, так что конвейер вернул код выхода tail — ноль — и реальный сбой остался невидимым. Решение: pipefail во внутренней оболочке плюс проверка после экспорта, которая ищет в образе содержимое файловой системы и прерывается, если его нет.
Backup-GPT не на месте
Запись образа, размером по содержимому (около 1.3 ГБ), на 53-ГБ раздел userdata оставляет backup-заголовок GPT там, где кончается образ, а не в конце раздела:
GPT: Alternate GPT header not at the end of the disk.
Ядро отвергает GPT, у которого backup-заголовок не там, где положено, и подразделы не отображаются. Шаг прошивки теперь перемещает backup-GPT в конец устройства после записи, а потом проверяет, что подразделы появились.
Прошивка
У прошивки нашлось ещё несколько способов сломаться:
Образ raw, а не Android-sparse, так что
simg2imgего отвергает. Скрипт определяет формат по magic-числу и для raw используетdd.adbd отваливается посреди многогигабайтной записи —
failed to read copy response: EOF, и устройство пропадает изadb devices. Поэтому скрипт запускается на телефоне отдельно (nohup), а хост опрашивает файл с результатом и переподключает adb, если тот отвалился.Однажды push прошёл проверку по размеру и всё равно дал незагружаемую систему, так что скрипт проверяет, считывая записанную область обратно с устройства и сравнивая SHA-256 с локальным образом, а не только длину.
Серверные проблемы, свойственные телефону
Батарею нельзя просто вынуть
Сервер постоянно подключён к питанию, а литиевый аккумулятор от того, что его держат заряженным и в тепле, стареет быстрее. Напрашивается решение — вынуть аккумулятор и работать от зарядного устройства. Так не получится: контроллер питания рассчитывает на установленную батарею, и без неё кратковременные скачки тока SoC вызывают такую просадку напряжения на шине питания, что устройство перезагружается. (Можно поставить плату-заглушку с мощным стабилизированным источником, но добавлять отдельное железо ради этого не хочется.)
Так что батарея остаётся — как буфер для скачков тока и коротких просадок, не для автономности; переживать длительное отключение питания мне не нужно.
Для буфера заряд лучше держать низким, около 3.7–3.8 В. Как буфер батарея работает одинаково хорошо и при половинном заряде, и при полном, а чем ниже напряжение, тем медленнее она стареет и меньше вздувается, — так что держать её полной незачем. Заряд удерживает небольшой systemd-сервис: каждые 20 секунд он чуть подстраивает лимит входного тока зарядки, чтобы заряд держался у нужного процента. Ток через батарею при этом почти нулевой — она не заряжается и не разряжается, просто держит уровень.
WiFi
WiFi — это чип QCA6174, подключённый по PCIe, и на mainline-ядре PCIe-линк не поднимался:
qcom-pcie 600000.pcie: Phy link never came up
Хост-контроллер PCIe определялся, а устройство за ним — нет; Bluetooth (тот же чип) отдавал -110, таймаут. Я проверил питание и тактирование: регулятор включения WiFi был на 1.8 В, сброс PERST снят, опорная частота — 19.2 МГц. Вся последовательность инициализации была правильной, а линк всё равно мёртв — и я решил, что чип неисправен.
Оказалось, нет. Я загрузил сток-ядро Android через TWRP — и оно сразу увидело чип, [168c:003e]. Железо было в порядке; проблема целиком в инициализации PCIe в mainline-ядре. Я перебрал несколько ядер, чтобы локализовать: 6.3.1 и 6.12.10 одинаково не поднимают линк, 6.19.5 вообще не грузится. Значит, это не регрессия между версиями, а давняя недоработка именно для этого устройства.
Исправление — одна строка в командной строке ядра, на 6.12.10:
pcie_aspm=off pci=nomsi
ASPM отвечает за энергосбережение PCIe; стоит его выключить — и линк поднимается. pci=nomsi форсирует legacy-прерывания, обходя капризный MSI на этом SoC. С обоими:
ath10k_pci 0000:01:00.0: enabling device
wlan0 поднялся, отсканировал, подключился. Bluetooth тоже снова заработал — он на UART, не на PCIe, так что флаги командной строки на него напрямую не влияли; он вернулся, когда чип стал нормально инициализироваться. Та версия pmbootstrap, которуя я использовал, игнорирует командную строку ядра из профиля устройства, так что рабочие флаги пришлось вписать прямо в заголовок Android-boot-образа, по фиксированному смещению, а не штатным конфигом.
Сетевой доступ
Иногда боту нужно показать мне веб-страницу — одноразовую ссылку, которую он присылает в Telegram, — а значит, нужен HTTPS-адрес, который я смогу открыть, и доступ к нему должен быть только у меня. Я перепробовал три варианта, прежде чем остановиться на самом простом.
Tailscale — это mesh-VPN. Его функция Funnel выставляет сервис в публичный интернет по стабильному адресу https://<имя>.ts.net, без домена и без статического IP. (Cloudflare Tunnel делает похожее, но требует домен, поэтому я его не рассматривал.)
Потом я попробовал свести публичную часть к минимуму: включать funnel только пока жива хотя бы одна свежая короткоживущая ссылка и выключать, когда истекает последняя. Бот сам поднимает и гасит тоннель через Tailscale, а небольшой вотчер следит за активными ссылками. Работает, но это слишком громоздко ради того, что вообще не должно быть публичным.
Но публичным ему быть и не нужно. Сервис только мой, и ссылки нужны лишь мне — поэтому хватает tailscale serve: он выставляет локальный порт по адресу https://op3t.<tailnet>.ts.net, и открыть его можно только с моих устройств в tailnet. Валидный HTTPS, без домена, без проброса портов, ничего в публичном интернете — и никакой логики тоннелей в боте: serve настраивается на хосте один раз и остаётся включённым.
Компромисс, на который я пошёл: mesh-only означает, что устройство, с которого я открываю ссылку, тоже должно быть в tailnet, так что Tailscale стоит и на телефоне, включаю по необходимости. Прежние варианты как раз и придумывались, чтобы этого избежать, — но эта сложность себя не оправдывала.
Деплой сервиса
Сервис — небольшой Telegram-бот: Node и TypeScript, хранилище на SQLite. Обычно такой сервис собирают в Docker-образ и запускают в контейнере. Собирать образ на телефоне не хотелось, потому что это могло быть медленно, плюс сильно нагрузило бы само устройство.
Дефицитный ресурс здесь не оперативка — 6 ГБ боту с огромным запасом, — а износ накопителя, нагрузка на CPU и нагрев; телефон охлаждается пассивно, вентилятора в нём нет. Установка зависимостей, компиляция нативного SQLite-биндинга из исходников и прогон TypeScript-компилятора — это длительная нагрузка на CPU пассивно охлаждаемого телефона, ровно то, что он переносит хуже всего, и деплой по принципу «забрать и пересобрать на устройстве» повторяет это каждый раз.
Поэтому ничего из этого на телефоне не происходит. GitHub Actions собирает образ на arm64-раннере — npm ci, нативная компиляция, tsc, всё на настоящей Linux-машине в CI — и отправляет его в реестр. Устройство только забирает готовый образ и запускает. Парадоксально, но контейнеры здесь бережнее к железу, чем запуск бота напрямую: единственный дорогой шаг уходит в CI, а не на телефон.
Для управления контейнерами я поставил Portainer — веб-интерфейс к Docker с логами, состоянием контейнеров и просмотром реестра. Опасался, что он будет постоянно потреблять ресурсы, замерил: в простое он занимает около 80 МБ оперативной памяти и примерно 0% CPU — на устройстве с 6 ГБ это незаметно. Удобно, и ресурсов почти не требует.
В итоге весь деплой выглядит так: CI собрал свежий образ, а Portainer забирает его и пересоздаёт контейнер из небольшого compose-файла.
Сеть в контейнерах
Образ собирается в CI, Portainer готов — казалось, деплой бота окажется простой частью. Но и здесь обнаружились две проблемы.
containerd не стартует
Установка Docker на этой systemd-сборке postmarketOS тянет всё ожидаемое — движок, CLI, containerd, compose-плагин, даже подходящие правила nftables. Но сервис не поднимался. dockerd висел в activating (start) и не доходил до конца, а виноват был containerd:
systemctl status containerd ExecStart=/usr/local/bin/containerd (code=exited, status=203/EXEC)
203/EXEC означает, что systemd не смог выполнить файл по этому пути — и не мог, потому что там ничего нет. Пакет ставит бинарь в /usr/bin/containerd, а юнит, который он же кладёт, указывает на /usr/local/bin/containerd, апстримный путь по умолчанию. Так как Docker лишь хочет containerd, а не требует его, dockerd не падал сразу — он ждал сокет, который никогда не появится. Решается это drop-in-файлом для systemd, который исправляет путь в ExecStart.
Поднялся, но unhealthy
Docker заработал, контейнер бота поднялся — и завис со статусом unhealthy. В логе одна стартовая строка и тишина, а wget http://127.0.0.1:8000/health отдавал Connection reset by peer. Порт был проброшен наружу, но приложение внутри контейнера ещё не начало его слушать, поэтому docker-proxy принимал соединение и тут же закрывал его: процесс не доходил до запуска своего HTTP-сервера. Первым делом при запуске бот отправляет исходящий запрос — поэтому я стал разбираться с сетью.
Из одноразового контейнера в той же compose-сети шлюз пинговался, а интернет — нет:
ping 172.18.0.1 → ok ping 1.1.1.1 → 100% packet loss
Я заподозрил DNS. nft показал обратное:
nft list ruleset | grep masquerade ip saddr 172.18.0.0/16 ... masquerade ... counter packets 0 bytes 0
Ноль пакетов через правило masquerade означает, что пакеты до него не доходят, так что разрешение имён ни при чём — из контейнера вообще ничего не выходило. Виноват был host-файрвол. Его цепочка forward по умолчанию drop и пропускает форвард для интерфейсов с именами docker*, но бот работает в compose-сети, чей бридж называется br-<hash>, а его не пропускало ни одно правило. В nftables пакет, отброшенный в любой базовой цепочке на его пути, отбрасывается окончательно — поэтому до правила masquerade ниже по цепочке дело уже не доходило.
За этим скрывалась вторая проблема — на этот раз с DNS: в /etc/resolv.conf контейнеру прописывался 100.100.100.100, MagicDNS от Tailscale, доступный с хоста, но не изнутри контейнера. Так что даже после починки форвардинга разрешение имён всё равно не заработало бы. Понадобились две правки: правило nftables, пропускающее compose-бриджи, и публичный DNS в конфигурации демона Docker, чтобы контейнеры не наследовали недостижимый адрес. После этого бот подключился к Telegram, начал слушать свой порт и перешёл в статус healthy.
Производительность и масштабирование
Когда всё заработало, остаётся вопрос: справится ли телефон 2016 года с такой нагрузкой. Portainer строит график загрузки CPU по контейнерам, и у бота это ровный 0% в простое с короткими всплесками до 6–8%.
Линейно это не масштабируется. Большая часть всплесков — фиксированная работа, не растущая с числом пользователей: плановый опрос раз в несколько минут, цикл long-poll Telegram, базовая цена пробуждения Node-процесса. Остальное — работа на конкретный запрос, и она ограничена вводом-выводом: эти миллисекунды бот проводит в ожидании ответов по сети, а не в вычислениях, поэтому один event loop успевает чередовать множество таких ожиданий, не загружая ядро. С ростом числа пользователей увеличивается только эта переменная часть, а фиксированная не копируется заново.
Для Node-сервиса важна не «доля от четырёх ядер», а загруженность единственного потока. Node исполняет бота в одном потоке, и остальные три ядра этому процессу ничем не помогают. Поток почти не загружен, и раньше всего дело упрётся не в CPU этого SoC, а во внешние ограничения — лимиты сторонних API и единственного писателя в SQLite.
Поэтому и привычный рецепт против «однопоточности» — запустить по копии процесса на каждое ядро — здесь не подходит и сразу сломал бы бота. Telegram-бот на long polling допускает только одного поллера на токен: второй получит 409 Conflict. А четыре процесса вступили бы в конфликт за один файл SQLite. Настоящее горизонтальное масштабирование выглядело бы иначе: отдельный входной слой, который только принимает сообщения и кладёт их в очередь; очередь с разбиением по пользователю, чтобы действия одного шли строго по порядку; пул stateless-воркеров; Postgres вместо файлового SQLite; и один планировщик с выбором лидера, чтобы каждая задача выполнялась ровно один раз. Одному пользователю ничего из этого не нужно — под такую нагрузку у телефона огромный запас.
Полная картина
Вот общая схема:
GitHub Actions (arm64) │ build + push ▼ GHCR ──pull──► ┌───────────────────────────┐ │ OnePlus 3T │ me ──Tailscale (mesh)──────────┤ postmarketOS / systemd │ https://op3t.<tailnet>.ts.net │ └─ Docker │ │ ├─ Portainer (UI) │ │ └─ service (bot) │ │ battery-guard(systemd) │ └───────────────────────────┘
Телефон работает на postmarketOS — mainline-Linux с systemd. Docker запускает два контейнера: Portainer для управления и самого бота. Образ бота собирается в CI и забирается из реестра; телефон ничего не собирает. Всё доступно только внутри моего Tailscale, ничего в публичном интернете. systemd-сервис держит батарею около половины заряда, а DNS для контейнеров, правило файрвола и остальное встроены в сборку.
Как повторить
Весь проект — это два репозитория. Хостовая часть — пайплайн сборки и прошивки, вспомогательные сервисы и setup-скрипт — находится в github.com/arttttt/oneplus3t-pmos-server.
Настроить устройство с нуля можно быстро — все исправления уже заложены в скрипты:
Прошить образ —
install.shсобирает его в контейнере и прошивает, с уже встроенными исправлениями для sector-size, GPT, ядра и WiFi.tailscale upи авторизоваться.Запустить
setup-host.sh— он ставит Docker, исправляет юнит containerd, настраивает DNS для контейнеров и правило файрвола, поднимает Portainer и публикует Portainer и бота через Tailscale.Создать админ-аккаунт Portainer.
Развернуть стек бота и заполнить его секреты.
Скриптами не охвачены только логин в Tailscale, пароль администратора и секреты сервиса — это намеренно оставлено на ручную настройку.
Итог
Старый телефон — вполне пригодный маленький сервер: ARM SoC, пара ватт потребления, собственная батарея. Главное препятствие между ним и этой ролью — программное обеспечение, которое всё ещё считает его телефоном. Вся работа и состояла в том, чтобы снять это допущение. Для одного устройства это немало, но проделать это нужно лишь однажды — и в результате рабочий компьютер снова приносит пользу, а не лежит без дела в ящике.
Если любопытно, что именно на нём работает: это Telegram-бот, который я написал, — он вручную реализует стратегию усреднения цены покупки(DCA) нескольких активов; проект я пока ещё дорабатываю, так что подробности оставлю на другой раз. Код — github.com/arttttt/CMIDCABot. Но самым интересным здесь был сам телефон.
