my server is a phone now [перевод]
Вольный перевод статьи Fedor Indutny "my server is a phone now"
ручу cmf phone 1, чтобы гонять на нём домашнюю личную инфраструктуру.
некоторое время моя личная инфраструктура жила на маленьком vps от hetzner. там крутились несколько веб-приложений, удалённый браузер Surf, Caddy и остальная стандартная обвязка. ничего серьёзного. всё работало, но платить за это не нравилось.
одно из приложений — Surf — особенно хорошо показывало компромисс. самые дешёвые шаред-машины были нормальными, пока Chrome не начинал делать настоящую работу. тогда им явно не хватало ресурсов. dedicated cpu решали проблему, но стоили столько, что личный браузер превращался в сомнительную ежемесячную финансовую затею.
покупать ещё одну машину тоже не хотелось. цены на dram совсем поехали, и собирать новый короб с нормальным количеством памяти казалось особенно не вовремя. посмотрел на б/у мини-пк, немного подумал превращать свой десктоп в сервер, пока не пользуюсь им. а потом вспомнил про уже лежащий у меня cmf phone 1.
восемь arm-ядер, 8 гб оперативки, 128 гб флеша, wi-fi 6, 5g-модем и встроенный аккумулятор как резервное питание. всё это висит на soc, которой явно слишком жирно просто лежать в ящике. а ещё я за него уже заплатил.
стряхнул пыль, поковырялся и решил: будет сервером.
сейчас на нём крутятся Surf вместе с управляемым Chrome, мой личный трекер финансов, сервис для шаринга экрана и несколько небольших веб-приложений. они переживают перезагрузки, деплоятся из git и остаются доступными, когда телефон переезжает между сетями.
короче, он реально заменил vps.
первая плохая идея: снести android
самым чистым вариантом казалось прошить обычный linux-дистрибутив. для cmf phone 1 есть порт postmarketOS: он загружается, на странице устройства хватает зелёных галочек, чтобы безрассудный человек преисполнился оптимизмом.
этим человеком стал я.
меньше внимания я уделил тому, что помечено как сломанное: wi-fi, bluetooth, аппаратное ускорение и почти всё, из-за чего телефон мог бы быть полезным маленьким сервером. дошёл до заставки postmarketOS и чёрного экрана. в этот момент у меня не было ни сервера, ни телефона.
восстановление стоковой Nothing OS стало отдельным квестом. флешер требовал windows, поэтому я поставил windows в qemu, повоевал с usb passthrough и драйверами MediaTek, посмотрел, как утилита зависает, а потом перенёс всё на настоящую установку windows и восстановил заводские образы.
в какой-то момент телефон был в soft brick: показывал только чёрный экран. я уже всерьёз подумал, что превратил хороший девайс в пресс-папье.
но он вернулся.
и урок такой: в android уже есть рабочие драйверы на всё это железо. wi-fi, управление питанием, аккумулятор, gpu, модем и каждая странная деталь от вендора уже работают. выбрасывать это ради более привычного userspace было плохой сделкой.
телефон не нужно было превращать в нормальную linux-машину. нужно было надёжно запускать linux-приложения, пока android делает то, в чём он хорош — работает с железом.
termux — хостовая ос
вторая попытка оставила стоковый android и использовала Termux как хостовое окружение.
Termux даёт мне OpenSSH, runit, Caddy, Cloudflared, пакетный менеджер и достаточно нормальный unix-туллинг. Termux:Boot после перезагрузки запускает супервизор и ssh. Tailscale даёт телефону стабильный приватный адрес, так что с любой машины в моём tailnet я просто делаю:
ssh cmf
Termux — не виртуальная машина. его процессы всё ещё работают на linux-ядре android, но userspace на базе Bionic достаточно отличается от обычного Debian, поэтому нельзя просто взять и закинуть туда готовые образы linux-приложений.
но это разделение оказалось полезным. Termux остаётся небольшим control plane хоста, а каждое приложение приносит с собой linux-файловую систему, которую ожидает увидеть.
сами сервисы контролирует runit. android очень хорошо экономит батарею, когда занимается своей обычной работой, и очень плохо ведёт себя с устройством, которое притворяется сервером. поэтому ansible ещё применяет профиль для android-хоста: ставит постоянный wake lock, выключает light и deep idle, убирает Termux, Termux:Boot и Tailscale из фоновых ограничений, отключает лимитер дочерних процессов, запрещает wi-fi засыпать и настраивает Tailscale как always-on vpn.
важнее любой отдельной настройки здесь цепочка восстановления. android загружается, always-on vpn возвращает Tailscale, Termux:Boot поднимает runit, runit запускает все постоянные сервисы, а health checks проверяют локальный и публичный пути.
телефон может перезагрузиться, и мне не нужно замечать это вручную.
загрузка android ->
always-on vpn от tailscale ->
termux:boot ->
runit ->
постоянные сервисы ->
локальные и публичные health checks
это не обычный linux-сервер. здесь нет systemd, нет нормального docker daemon, и нет причин делать вид, что они есть.
но здесь есть linux-ядро с очень способным userspace поверх него. как оказалось, этого хватает.
вторая плохая идея: proot
большинство моих приложений уже поставлялись в виде linux arm64 oci-образов. proot-distro позволил довольно легко запустить их под Debian, не меняя сами приложения.
PRoot перехватывает файловые и процессные операции в userspace и заставляет обычный процесс Termux думать, что он живёт внутри Debian root filesystem. это не контейнерная граница. всё по-прежнему делит android-ядро, network namespace и uid от Termux.
но как слой совместимости приложений штука очень полезная, потому что ей не нужны ни root, ни специальное ядро.
обычные веб-сервисы сначала нормально работали так. каждому приложению — проверенная root filesystem, loopback-порт и сервис runit. Caddy крутился прямо в Termux и раскидывал хостнеймы по этим портам.
исключением стал Surf с браузером — нагрузка, которой важны производительность и задержки. запуск процессов, открытие библиотек, проход по путям, чтение профилей браузера и переброс данных захвата — всё это проходило через userspace-слой трансляции PRoot.
cpu был, но Chrome не мог эффективно до него добраться.
поэтому телефон я и рутнул. не чтобы заменить android, а чтобы нормально примонтировать ту же Debian-файловую систему и зайти в неё через настоящий chroot.
runit всё ещё управлял жизненным циклом из Termux, конфигурация всё ещё приезжала из того же места, данные приложений всё ещё лежали в хранилище Termux. просто теперь нагрузка доходила до android-ядра через нативные syscalls, а не через PRoot.
разница оказалась совсем не тонкой.
когда этот путь стабильно заработал, оставлять маленькие сервисы под PRoot уже не было смысла. теперь они работают так же. моя рабочая машина резолвит каждый arm64-образ в конкретный digest и экспортирует его файловую систему; ansible проверяет её и ставит на телефон.
небольшой root-helper создаёт приватный mount namespace, биндует нужные пути, входит в файловую систему через chroot, сбрасывает привилегии и запускает оригинальный entrypoint образа.
ни docker, ни компилятор на телефоне не нужны. это всё ещё окружения совместимости, а не security boundary: сервисы делят android-ядро и сетевой стек. приватные mount namespaces тут нужны в основном, чтобы маунты и очистка оставались предсказуемыми.
ещё я потратил слишком много времени, пытаясь прокинуть графический стек Debian к Mali gpu телефона через VirGL и Android Vulkan. получил галочки у аппаратного композитинга вместе с битыми страницами и худшей производительностью.
скучный software rendering в итоге работал лучше.
инфраструктура, а не куча shell history
к этому моменту телефон уже мог запускать всё, что нужно. но не хотелось получать pet server, собранный из команд, которые через неделю забуду.
поэтому я перевёл весь хост в ansible managed state: версии, определения сервисов, маршруты, настройки питания, секреты и health checks теперь лежат в одном приватном репозитории.
деплой примерно такой:
релиз или oci-образ ->
checksum/digest закреплён в git ->
ansible по ssh ->
версионированные файлы на телефоне ->
атомарный current symlink ->
сервис runit ->
локальный health check ->
публичная проверка edge
релизы закрепляются digest-ом или checksum-ом и ставятся в версионированные директории за атомарным symlink-ом current. если checksum или health check не проходит, деплой останавливается. откат — это вернуть прежний pin и применить конфигурацию заново.
данные приложений лежат отдельно от релизов.
после небольшого ручного bootstrap: поставить три android-приложения, зарутить телефон, дать Termux доступ суперпользователя и авторизовать ssh — остальным управляет тот же репозиторий.
с рабочей машины привести хост к описанному состоянию намеренно просто:
make phone
make phone-status
make phone-edge-check
повторное применение не перезаписывает неизменившиеся runtime-файлы и не перезапускает здоровые сервисы. и, что важнее, конфигурация остаётся полезной, если этот телефон умрёт: другой rootable arm64-телефон можно привести примерно к тому же состоянию, не восстанавливая археологию shell history.
секреты не лежат в git checkout на телефоне, потому что на телефоне вообще нет git checkout. значения Ansible Vault хранятся зашифрованными в репозитории инфраструктуры. пароль от vault получается через запрос к 1Password SSH agent: он подписывает фиксированный challenge, приватный ключ остаётся в 1Password, а телефону не нужен доступ к нему.
во время деплоя ansible рендерит только нужные каждому сервису runtime-значения в приватное хранилище Termux.
как пустить трафик на телефон за домашним интернетом
дальше нужно было решить ingress. домашнее подключение не даёт того статичного серверного сетапа, который есть у vps. открывать ssh или набор случайных портов приложений через роутер тоже не хотелось.
и хотелось, чтобы телефон оставался телефоном в одном важном смысле: я должен иметь возможность выдернуть его из розетки, унести куда-то, подключить к интернету — и сервер всё ещё работает.
http-приложения используют Cloudflare Tunnel. Cloudflared открывает одно исходящее соединение с телефона, Cloudflare прогоняет через него каждый хостнейм, а Caddy направляет запрос нужному loopback-сервису.
интернет ->
cloudflare tunnel ->
caddy на 127.0.0.1 ->
приложение на 127.0.0.1
никаких входящих правил на роутере для этих сервисов нет. Cloudflared нужен только исходящий интернет, поэтому, если я перенесу телефон в другую сеть, туннель переподключится, и хостнеймы поедут за ним.
Tailscale делает то же для администрирования. аккумулятор телефона переживает сам переезд, а публичным сервисам всё равно, под каким wi-fi он сейчас сидит.
но для удалённого браузера Surf понадобился другой путь. его прямое соединение чувствительно к задержкам, само завершает tls, а старый iPad, который к нему подключается, pin-ит идентичность сервера.
дома Cloudflare DDNS держит dns-only запись, указывающую на текущий публичный ip, а роутер форвардит один порт на Surf. внутри локалки iPad подключается к телефону напрямую.
оставалась проблема с поездками. обычный Cloudflare Tunnel завершает tls на стороне Cloudflare, а именно этого pinned-соединение Surf и не хочет. решение — завернуть весь tls-поток Surf внутрь обычного WebSocket.
Cloudflare видит и форвардит WebSocket, но настоящее аутентифицированное Surf-соединение остаётся зашифрованным end-to-end внутри него.
это, очевидно, добавляет задержку. вне дома я обычно получаю примерно ещё один network round trip, а iPad при первом тесте показывал около 60 мс.
но это работает через туннель, которому нужен только исходящий интернет, на ос из 2012 года и без установки Tailscale на iPad.
в этот момент телефонный сервер стал немного абсурдным — в хорошем смысле.
я оставил его включённым дома, поехал в офис, зашёл по ssh с MacBook через Tailscale в рабочую машину и телефон, а потом использовал оригинальный iPad через Surf, который хостится на телефоне.
vps больше не было. довольно освобождающее чувство.
что вообще там запущено
у машины два слоя. android и Termux владеют железом, сетью, ingress и супервизией. linux-резиденты получают ожидаемую файловую систему и в остальном не мешают хосту.
| слой | зона ответственности | компоненты |
|---|---|---|
| android / termux host | железо, сеть, ingress, супервизия | runit, Tailscale, Caddy, Cloudflared, DDNS, operations dashboard |
| rooted linux residents | совместимость приложений и нагрузки | Surf и Chrome, Finances, Screen Share, несколько других приложений |
самая тяжёлая нагрузка — Surf, который приносит современный Chromium в старые iPhone и iPad. телефон запускает desktop Chrome — свежий arm64-релиз — и backend Surf внутри Debian runtime. iPad mini получает h.264-видео и звук, а назад отправляет тапы, клавиатуру, вкладки и команды навигации.
из-за Surf я изначально так переживал за syscall overhead и latency.
заодно телефон хостит мой личный трекер финансов. он записывает регулярные доходы и расходы, разовые транзакции, месячные лимиты и строит прогноз баланса по дням.
в отличие от заменяемых артефактов приложения, его SQLite-база хранит важное состояние. поэтому для неё настроены автоматические бэкапы вне устройства и проверенный путь восстановления.
ни одно из этих имён не зашито в какой-то большой «phone server framework». новый резидент — это просто ещё один immutable-артефакт, определение runit, явный контракт данных, health check и, если нужно, маршрут в Caddy.
последнее добавление — наблюдаемость. я всё ещё могу зайти по ssh, посмотреть runit, почитать логи, проверить публичные маршруты и спросить что-то у android напрямую. но больше не нужно делать это только ради того, чтобы на глаз понять, что сейчас происходит с машиной.
один нативный сервис собирает загрузку всех восьми cpu-ядер, память, хранилище, uptime, батарею, температуры, локальную и публичную доступность, а ещё все найденные runit-сервисы. он хранит ограниченную историю и раздаёт встроенный Vue-интерфейс на https://dash.cmf, доступный только из локалки или tailnet.
лог-вьюха находит те же сервисные директории прямо во время работы. поэтому добавить нового резидента можно без того, чтобы отдельно объяснять ui его название.
намного приятнее, чем заходить по ssh только для того, чтобы вспомнить, какая штука снова шумит.
стоит ли так делать
если у тебя уже лежит достаточно современный rootable arm64-телефон, это намного менее глупо, чем звучит. получается тихое железо с низким энергопотреблением, флеш-хранилищем, wi-fi, встроенным экраном для восстановления и аккумулятором, который ведёт себя как маленький ups.
стоковый android уже умеет работать с железом. Termux и rooted chroot достаточно, чтобы запускать удивительно много обычного linux-софта.
я бы не держал на таком устройстве невосстановимые данные без автоматических бэкапов вне устройства. и я бы не считал chroot изоляцией для враждебной нагрузки. root расширяет доверенную зону, android остаётся странным хостом для сервера, а desktop Chrome с software rendering не обгонит нормальную рабочую станцию с gpu.
но для нескольких личных сервисов — особенно если альтернатива это бесконечно платить за vps, который либо медленный, либо раздражающе дорогой, — это действительно полезный вариант, а не просто трюк ради трюка.
телефон всё ещё странный сервер. он делит одно ядро, android иногда нужно напоминать не «оптимизировать» его, а будущий апдейт может принести новый сюрприз.
зато он тихий, с аккумулятором, достаточно быстрый, доступный отовсюду, воспроизводимый из git и уже стоит у меня дома.
началось всё с желания сэкономить на vps. закончилось рутованным телефоном, на котором работает моя личная инфраструктура.
и почему-то это намного приятнее. :)