Какое-то время личная инфраструктура жила на небольшом VPS от Hetzner. Там крутилось несколько веб-приложений, удалённый браузер под названием Surf, Caddy и обычный сопутствующий набор сервисов. Ничего особо серьёзного — всё работало, просто не нравилось за это платить.

Одно из приложений, Surf, сделало компромисс с дешёвым железом невозможным. Самые бюджетные виртуальные машины справлялись нормально, пока Chrome не начинал реально нагружать процессор — тогда ресурсов начинало не хватать. Машины с выделенным 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 и драйверами MediaTek, наблюдать, как инструмент прошивки зависает, и в итоге перенести весь процесс на настоящую установку Windows и восстановить заводские образы.

В какой-то момент телефон оказался в состоянии soft-brick и показывал только чёрный экран — на этом этапе появилась вполне серьёзная мысль, что вполне рабочее устройство превратилось в бесполезный кусок металла и стекла.

Телефон удалось вернуть к жизни, а вместе с этим пришло и понимание: у Android уже есть рабочие драйверы для каждого компонента этого железа. Wi-Fi, управление питанием, батарея, GPU, модем и все странные особенности конкретного вендора — всё это уже работает. Выбрасывать всё это ради более традиционного пользовательского окружения было неверным решением.

На самом деле не требовалось превращать телефон в обычную Linux-машину. Требовалось, чтобы он надёжно запускал Linux-приложения, пока Android продолжает заниматься специфичной для железа работой, с которой справляется хорошо.

Termux как хост-система

Вторая попытка сохранила стоковый Android, а роль хост-окружения досталась Termux.

Termux даёт OpenSSH, runit, Caddy, Cloudflared, управление пакетами и достаточно привычный набор Unix-инструментов. Termux:Boot запускает супервизор и SSH после перезагрузки. Tailscale выдаёт телефону стабильный приватный адрес, так что с любой машины в tailnet достаточно выполнить:

ssh cmf

Termux — не виртуальная машина. Его процессы по-прежнему исполняются поверх ядра Linux в составе Android, но пользовательское окружение на базе Bionic достаточно отличается от обычной установки Debian, так что готовые образы Linux-приложений нельзя просто взять и запустить внутри него. Впрочем, это разделение оказалось полезным: Termux остаётся небольшой хост-плоскостью управления, а каждое приложение приносит с собой ожидаемую им файловую систему Linux.

Сами сервисы управляются через runit. Управление батареей в Android отлично справляется со своей обычной задачей, но плохо подходит для устройства, притворяющегося сервером, поэтому Ansible-сборка также применяет специальный профиль хоста Android: устанавливает постоянный wake lock, отключает light и deep idle, освобождает Termux, Termux:Boot и Tailscale от фоновых ограничений, отключает лимитер дочерних процессов, предотвращает засыпание Wi-Fi и настраивает Tailscale как постоянно активный VPN.

Цепочка восстановления важнее любой отдельной настройки. Android загружается, всегда включённый VPN поднимает Tailscale, Termux:Boot запускает runit, runit поднимает все резидентные сервисы, а проверки состояния подтверждают доступность локальных и публичных маршрутов. Телефон может перезагрузиться и без того, чтобы кто-то это заметил.

Android boot
  -> Tailscale always-on VPN
  -> Termux:Boot
  -> runit
  -> resident services
  -> local and public health checks

Это не обычный Linux-сервер. Здесь нет systemd, нет привычного демона Docker, и нет смысла делать вид, что это иначе. Но это ядро Linux с достаточно мощным пользовательским окружением сверху — и этого оказывается достаточно.

Вторая плохая идея: PRoot

Большинство приложений уже поставлялись в виде Linux ARM64 OCI-образов. proot-distro позволил на удивление легко запускать их под Debian, не меняя сами приложения.

PRoot перехватывает операции с файловой системой и процессами на уровне пользовательского пространства и заставляет обычный процесс Termux думать, что он живёт внутри корневой файловой системы Debian. Это не граница контейнера в привычном смысле — всё по-прежнему разделяет ядро Android, сетевое пространство имён и UID Termux. Но как слой совместимости приложений это чрезвычайно полезно, поскольку не требует ни root-прав, ни специального ядра.

Обычные веб-сервисы поначалу прекрасно работали таким образом. Каждое приложение получило проверенную корневую файловую систему, loopback-порт и сервис под управлением runit. Caddy работал напрямую в Termux и маршрутизировал имена хостов на эти порты.

Исключением стала чувствительная к производительности и задержкам нагрузка от браузера Surf. Запуск процессов, открытие библиотек, обход путей, чтение профилей браузера и перемещение данных захвата экрана — всё это проходило через слой трансляции PRoot в пользовательском пространстве. Свободные ресурсы CPU были, но Chrome не мог эффективно до них дотянуться. Поэтому телефон был рутирован — не для того, чтобы заменить Android, а чтобы правильно смонтировать ту же файловую систему Debian и войти в неё через настоящий chroot.

Runit по-прежнему управлял жизненным циклом из Termux, конфигурация приходила оттуда же, а данные приложений по-прежнему хранились в хранилище Termux. Изменилось только то, что нагрузка теперь обращалась к ядру Android через нативные системные вызовы, а не через PRoot. Прирост производительности оказался далеко не незаметным!

Как только этот путь стал надёжным, оставлять более мелкие сервисы под PRoot перестало иметь смысл. Теперь они работают точно так же. Рабочая станция разрешает каждый ARM64-образ до точного дайджеста и экспортирует его файловую систему; Ansible проверяет и устанавливает её на телефон. Небольшой root-хелпер создаёт приватное пространство имён для монтирования, привязывает нужные пути, входит в файловую систему через chroot, сбрасывает привилегии и запускает исходную точку входа образа.

Ни Docker, ни компилятор на телефоне не нужны. Эти окружения по-прежнему остаются слоями совместимости, а не границами безопасности: резиденты разделяют ядро и сетевой стек Android, а приватные пространства монтирования в основном обеспечивают предсказуемость монтирования и очистки.

Немало времени было потрачено и на попытку связать графический стек Debian с GPU Mali телефона через VirGL и Android Vulkan. В итоге удалось получить галочки аппаратной композиции вместе с испорченными страницами и худшей производительностью… Скучный путь с программным рендерингом в итоге показал себя лучше.

Инфраструктура, а не куча команд из истории shell

К этому моменту телефон уже мог запускать всё что нужно, но не хотелось получить «ручного питомца», собранного из команд, которые забудутся через неделю. Весь хост был перенесён в состояние, управляемое Ansible: версии, определения сервисов, маршруты, настройки питания, секреты и проверки состояния — всё это живёт в одном приватном репозитории.

Поток деплоя выглядит примерно так:

release or OCI image
  -> checksum/digest pinned in Git
  -> Ansible over SSH
  -> versioned files on the phone
  -> atomic current symlink
  -> runit service
  -> local health check
  -> public edge check

Релизы фиксируются по дайджесту или контрольной сумме и устанавливаются в версионированные каталоги за атомарной символической ссылкой current. Неудачная проверка контрольной суммы или состояния останавливает деплой, а откат означает возврат к предыдущей закреплённой версии и повторное применение. Данные приложений хранятся отдельно от релизов.

После небольшого ручного бутстрапа — установить три приложения Android, рутировать телефон, выдать Termux права суперпользователя и авторизовать SSH — всё остальное берёт на себя тот же репозиторий. С рабочей станции привести хост к декларируемому состоянию можно намеренно простыми командами:

make phone
make phone-status
make phone-edge-check

Повторное применение не заменяет неизменившиеся рабочие файлы и не перезапускает здоровые сервисы. Что важнее, эта конфигурация полезна даже в случае, если этот телефон выйдет из строя: другой рутируемый ARM64-телефон можно привести к тому же состоянию, не восстанавливая историю команд по памяти.

Секреты не хранятся в Git-репозитории на телефоне, потому что никакого Git-репозитория на телефоне нет. Значения Ansible Vault хранятся в зашифрованном виде в репозитории инфраструктуры. Пароль от vault выводится путём запроса к SSH-агенту 1Password с просьбой подписать фиксированный вызов, так что приватный ключ остаётся в 1Password, а телефону доступ к нему никогда не требуется. Во время деплоя Ansible рендерит в приватное хранилище Termux только те рабочие значения, которые нужны каждому конкретному сервису.

Как трафик доходит до телефона за домашним интернетом

Следующей задачей стал входящий трафик. Домашнее подключение не даёт того статического серверного окружения, которое предоставляет VPS, а открывать SSH или набор случайных портов приложений через роутер не хотелось. Кроме того, было важно, чтобы телефон остался телефоном в одном ключевом смысле: его должно быть можно отключить, взять с собой, подключить к интернету в другом месте — и сервер продолжит работать.

HTTP-приложения используют Cloudflare Tunnel. Cloudflared устанавливает одно исходящее соединение с телефона, Cloudflare направляет каждое имя хоста через него, а Caddy маршрутизирует запрос к нужному loopback-сервису.

Internet
  -> Cloudflare Tunnel
  -> Caddy on 127.0.0.1
  -> application on 127.0.0.1

Для этих сервисов нет ни одного входящего правила на роутере. Cloudflared нужно лишь исходящее соединение, так что при переезде телефона в другую сеть туннель просто переподключается, и имена хостов следуют за ним. Tailscale делает то же самое для администрирования. Батарея телефона способна пережить сам переезд, а публичные сервисы совершенно не заботит, какая именно Wi-Fi-сеть находится под ними в данный момент.

Бэкенду удалённого браузера Surf потребовался другой путь. Его прямое соединение чувствительно к задержкам, самостоятельно завершает свой TLS, а старый iPad, который к нему подключается, привязывает идентичность сервера по отпечатку. Дома Cloudflare DDNS поддерживает DNS-запись, указывающую на текущий публичный адрес, а роутер пробрасывает один порт на Surf. В локальной сети iPad подключается к телефону напрямую.

Но оставался вопрос перемещения вне дома. Обычный Cloudflare Tunnel завершает TLS на стороне Cloudflare — а это как раз то, чего не хочет привязанное по отпечатку соединение Surf. Решением стало обернуть весь TLS-поток Surf в обычный WebSocket. Cloudflare видит и пересылает только WebSocket, а настоящее аутентифицированное соединение Surf остаётся зашифрованным сквозным образом внутри него.

Это, разумеется, добавляет задержку — вне дома обычно добавляется примерно ещё один сетевой круговой обход, а соединение с iPad при первом тестировании составляло около 60 мс. Но всё это работает через туннель, которому нужна лишь исходящая связность, на операционной системе 2012 года, и без установки Tailscale на сам iPad.

Именно в этот момент вся затея с телефоном стала слегка нелепой — в лучшем смысле. Телефон остался подключён дома, а из офиса через SSH по Tailscale удалось подключиться сначала к рабочей станции, а затем и к телефону, после чего оригинальный iPad заработал через размещённый на телефоне экземпляр Surf. VPS больше не существовало — и это ощущалось как настоящее освобождение.

Что на самом деле работает

У машины два слоя. Android и Termux владеют железом, сетью, входящим трафиком и супервизией. Linux-резиденты получают ожидаемую файловую систему и в остальном не мешают хосту.

слойответственностькомпоненты
хост Android / Termuxжелезо, сеть, входящий трафик, супервизияrunit, Tailscale, Caddy, Cloudflared, DDNS, дашборд для операций
рутированные Linux-резидентысовместимость приложений и рабочие нагрузкиSurf и Chrome, Finances, Screen Share, ещё несколько приложений

Самая требовательная нагрузка — Surf, который доставляет современный браузер на базе Chromium на старые iPhone и iPad. Телефон запускает десктопный Chrome (свежий arm64-релиз) и бэкенд Surf в своём Debian-окружении; iPad mini получает видео и аудио в H.264, отправляя обратно команды касаний, клавиатуры, вкладок и навигации. Именно из-за Surf так важны были накладные расходы на системные вызовы и задержки.

Помимо этого, на телефоне размещён личный трекер финансов. Он фиксирует регулярные доходы и расходы, разовые транзакции, месячные лимиты трат и строит прогноз баланса на день вперёд. В отличие от заменяемых артефактов приложений, его база данных SQLite содержит по-настоящему важное состояние, поэтому для неё настроены автоматические резервные копии за пределами устройства и проверенный путь восстановления.

Ни одно из этих названий не зашито в некий грандиозный «фреймворк для телефона-сервера». Новый резидент — это просто ещё один неизменяемый артефакт, определение runit, явный контракт данных, проверка состояния и, при необходимости, маршрут в Caddy.

Последним добавлением стала наблюдаемость. Возможность подключиться по SSH, изучить runit, посмотреть логи, проверить публичные маршруты и напрямую опросить Android никуда не делась, но теперь не приходится делать это лишь для того, чтобы одним взглядом понять, что происходит с машиной: один нативный сервис собирает загрузку всех восьми ядер CPU, память, хранилище, время работы, состояние батареи, температуру, локальную и публичную доступность, а также данные по каждому обнаруженному резиденту runit. Он хранит ограниченную по объёму историю и отдаёт встроенный интерфейс на Vue по адресу https://dash.cmf, доступный только из локальной сети или tailnet.

dashboard

Представление логов обнаруживает те же каталоги сервисов во время выполнения, так что добавление ещё одного резидента не требует отдельно «объяснять» интерфейсу его имя. Это намного приятнее, чем подключаться по SSH только для того, чтобы вспомнить, что именно шумело.

Стоит ли так делать?

Если под рукой уже есть достаточно современный рутируемый ARM64-телефон, который просто лежит без дела, всё это выглядит куда менее нелепо, чем звучит на словах. В итоге получается тихое железо, низкое энергопотребление, флеш-накопитель, Wi-Fi, встроенный экран на случай восстановления и батарея, ведущая себя как миниатюрный ИБП. Стоковый Android уже поддерживает всё железо, а Termux вместе с рутированным chroot достаточно для запуска на удивление большого количества обычного Linux-софта.

Незаменимые данные на такое устройство без автоматических резервных копий за его пределами лучше не класть, а chroot-окружения не стоит считать изоляцией от враждебной нагрузки. Root расширяет границу доверия, Android остаётся необычным хостом для сервера, а программный рендеринг десктопного Chrome не превзойдёт выделенную рабочую станцию с GPU.

Но для горстки личных сервисов, особенно когда альтернатива — бесконечно платить за VPS, который либо медленный, либо неоправданно дорогой, это по-настоящему полезный вариант, а не просто трюк ради трюка.

Телефон всё ещё остаётся странным сервером. Он разделяет одно ядро с Android, который время от времени нужно останавливать от попыток его «оптимизировать», а будущее обновление Android всегда может преподнести новый сюрприз.

Но он тихий, с резервным питанием от батареи, достаточно быстрый, доступный откуда угодно, воспроизводимый из Git — и уже стоит дома. Всё начиналось с попытки сэкономить на VPS, а закончилось рутированным телефоном, на котором крутится вся личная инфраструктура, и почему-то это ощущается куда более приятным результатом. :)