使わなくなったAndroid端末にLinux開発環境を構築した。TermuxPRoot-Distroを使えばroot化なしでUbuntuが動く。動くのだが、いくつか盛大にハマったポイントがあった。

PRootで動かないもの一覧

PRootはカーネルレベルの仮想化ではなく、システムコールをユーザー空間で変換する仕組みだ。このため、カーネルに直接アクセスする機能は使えない。

動かないものを先に整理しておく。

Dockerは動かない。Dockerはcgroupsやnamespacesといったカーネル機能を直接使うため、PRootでは原理的に不可能。Podmanも同様だ。コンテナ技術は全滅だと思っておいた方がいい。

systemdも動かない。PRootではPID 1がsystemdにならないため、systemctlコマンドは使えない。サービスの自動起動はbashスクリプトで代替することになる。

一部のシステムコールが失敗する。これが一番厄介で、どのシステムコールが使えないかは実行してみないと分からない場合がある。

os.networkInterfaces() の罠

Node.jsのアプリケーションを動かしていて、特定の条件下でクラッシュする問題に遭遇した。エラーメッセージは以下の通り。

Error: uv_interface_addresses: permission denied

原因はNode.jsos.networkInterfaces()。ネットワークインターフェースの情報を取得するこの関数が、PRootでは権限エラーで失敗する。

厄介なのは、手動でTermuxを開いてPRoot環境にログインした場合は問題なく動くこと。自動起動スクリプトから非対話的に呼び出した時だけ失敗する。対話的なログインではTermuxの擬似TTYが一部のシステムコールを通すらしく、この違いに気づくまで原因の特定に時間がかかった。

対処法は、該当する関数呼び出しをtry-catchで囲むこと。

// Before
const interfaces = os.networkInterfaces();

// After
let interfaces;
try {
  interfaces = os.networkInterfaces();
} catch (e) {
  interfaces = {};
}

自分が書いたコードなら簡単に修正できるが、依存ライブラリの内部で呼ばれている場合はnode_modulesの中を直接編集する必要がある。grepで該当箇所を探して、ひとつずつパッチを当てていった。

OOMキラー対策

Android端末でNode.jsとHeadless Chromeを同時に動かすと、メモリが逼迫する場面がある。AndroidのOOMキラーは容赦なく、メモリが足りなくなるとTermuxプロセスごと殺してくる。signal 9で即死。ログも残らない。

完全に防ぐ方法は見つからなかった。代わりに、死んでも自動復帰する仕組みを作ることにした。

#!/data/data/com.termux/files/usr/bin/bash
# watchdog.sh

termux-wake-lock  # スリープ防止

while true; do
  proot-distro login ubuntu -- bash -c \
    "source ~/.env && node app.js" \
    >> watchdog.log 2>&1
  
  echo "[$(date)] Process exited. Restarting in 15s..." >> watchdog.log
  sleep 15
done

termux-wake-lockは必須で、これがないと画面オフ時にAndroidがTermuxをサスペンドする。バックグラウンド実行が止まってしまう。

安全弁として、短時間に連続で失敗した場合は自動停止するロジックも入れた。設定ミスなどで起動直後にクラッシュするケースでは、無限再起動ループに入ってしまうためだ。

Chromiumの起動が遅い

PuppeteerでHeadless Chromeを使う場合、デスクトップ環境なら一瞬で起動するが、Android端末では20〜30秒かかる。CPUとストレージの速度がボトルネックになっている。

起動オプションも重要で、以下のフラグを付けないとそもそも起動しないか、すぐにクラッシュする。

const browser = await puppeteer.launch({
  headless: 'new',
  args: [
    '--no-sandbox',
    '--disable-gpu',
    '--disable-dev-shm-usage',
    '--single-process'
  ]
});

--disable-dev-shm-usageは共有メモリの制約を回避するためのフラグで、PRoot環境では特に重要。--single-processはメモリ消費を抑えるために付けている。

ブラウザインスタンスは使い終わったらすぐにbrowser.close()する。常駐させるとメモリが圧迫されてOOMキラーの餌食になる。

aptで入るパッケージは普通に使える

ここまでハマりポイントばかり書いてきたが、通常の開発ツールは問題なく動く。Node.js、Python、git、vim、curl、jq、ffmpeg。全部aptで入れて普通に使える。

apt update && apt install -y nodejs python3 python3-pip git curl jq ffmpeg

npmもpipも普通に動く。Webアプリの開発、スクリプトの実行、APIの呼び出しといった日常的な作業は問題ない。ハマるのは、カーネルに近い機能を使おうとした時だけだ。

結局のところ

PRootの制約を理解した上で使う分には、かなり実用的な環境が手に入る。Dockerが使えない、systemdが使えない、一部のシステムコールが通らない。これらを前提として設計すれば、24時間動くLinux環境がAndroid端末の上で動く。

ハマりポイントの大半は情報が少ないことに起因していた。PRootの制約に関するドキュメントは限られていて、「動かしてみて初めて分かる」ことが多い。この記事が同じことをやろうとしている人の助けになれば。

関連記事


参考リンク

関連記事