Как я превратил OnePlus 3T в домашний сервер на базе postmarketOS

от автора

Я давно хотел захостить свой небольшой проект — бота, которым пользуюсь сам. Останавливал меня не код, а хостинг. Не хотелось арендовать 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 containerdExecStart=/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   → okping 1.1.1.1      → 100% packet loss

Я заподозрил DNS. nft показал обратное:

nft list ruleset | grep masqueradeip 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.

Настроить устройство с нуля можно быстро — все исправления уже заложены в скрипты:

  1. Прошить образ — install.sh собирает его в контейнере и прошивает, с уже встроенными исправлениями для sector-size, GPT, ядра и WiFi.

  2. tailscale up и авторизоваться.

  3. Запустить setup-host.sh — он ставит Docker, исправляет юнит containerd, настраивает DNS для контейнеров и правило файрвола, поднимает Portainer и публикует Portainer и бота через Tailscale.

  4. Создать админ-аккаунт Portainer.

  5. Развернуть стек бота и заполнить его секреты.

Скриптами не охвачены только логин в Tailscale, пароль администратора и секреты сервиса — это намеренно оставлено на ручную настройку.

Итог

Старый телефон — вполне пригодный маленький сервер: ARM SoC, пара ватт потребления, собственная батарея. Главное препятствие между ним и этой ролью — программное обеспечение, которое всё ещё считает его телефоном. Вся работа и состояла в том, чтобы снять это допущение. Для одного устройства это немало, но проделать это нужно лишь однажды — и в результате рабочий компьютер снова приносит пользу, а не лежит без дела в ящике.

Если любопытно, что именно на нём работает: это Telegram-бот, который я написал, — он вручную реализует стратегию усреднения цены покупки(DCA) нескольких активов; проект я пока ещё дорабатываю, так что подробности оставлю на другой раз. Код — github.com/arttttt/CMIDCABot. Но самым интересным здесь был сам телефон.

ссылка на оригинал статьи https://habr.com/ru/articles/1054142/