
導入
私の使用しているiPhone14は、128GBなので結構カツカツである。
で、昨今のOSは容量を食い始めてきているが、バッテリーはまだ生きているし、しばらく機種変をする気もないので、懸念となるストレージを見直してみた。
iPhoneのストレージを圧迫している犯人を調べていくと、写真でも動画でもなく「同期されたメディア」だった。Finder経由でMac miniから手動で送り込んだ曲が、聴かなくなったものも含めてそのまま端末に居座り続けていたのである。
同期という運用そのものをやめて、ストリーミング再生に切り替えることにした。手元にはちょうど、以前の記事でUbuntuサーバとして復活させた古いノートPC「TOSHIBA KIRA V63」がある。ここにNavidromeを立て、Tailscale経由でだけ聴けるプライベート音楽サーバーにする、というのが今回のゴールだ。
要件はシンプルに絞った。
- Docker/docker-composeでNavidromeを構築する
- 音楽データはMac miniから手動コピー(Syncthingでの自動同期は将来検討、今回は対象外)
- 外部公開ポートは一切開けない。アクセスはTailscale経由に限定する
- Web UIとSubsonic API互換クライアント(play:Sub、Substreamerなど)の両方から接続確認まで行う
結論から言うと、素直に終わった作業は一つもなかった。SSH接続先を間違えるところから始まり、「動いているはずなのに繋がらない」に何度か振り回され、最後は再起動時のレース条件というシステム設計上の落とし穴まで踏むことになった。
つまずき:SSH接続の宛先を間違えて弾かれる
まずKIRA V63にSSHで入ろうとして、いきなり躓いた。
$ ssh -p 2222 -o ConnectTimeout=10 eseokun@100.89.94.32 "hostname && ..."
eseokun@100.89.94.32: Permission denied (publickey).
原因は単純で、~/.ssh/configにすでにkira-v63というHostエイリアス(ポート・秘密鍵込み)を用意していたのに、それを使わず生のIPアドレスとデフォルトの鍵で繋ぎに行っていたからだった。
Host kira-v63
HostName 100.89.94.32
User eseokun
Port 2222
IdentityFile ~/.ssh/id_ed25519_kira
ssh kira-v63と打ち直したらあっさり繋がった。過去の自分が用意した設定を無視して回り道をする、というのはこの手の作業でよくある事故だと思う。
Dockerが影も形もなかった
接続できたところでdocker --versionを叩くと、そもそも入っていなかった。
$ docker --version
bash: 行 1: docker: コマンドが見つかりません
Docker公式のインストールスクリプトでセットアップする。
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
sudo sh /tmp/get-docker.sh
このスクリプトは「Dockerのパッケージリポジトリを設定し、Docker Engineをインストールするための簡便な方法」として公式に提供されているものだが、本番環境向けには推奨されていない点には注意が必要だ。今回は個人のホームサーバーなので許容範囲とした。
インストール後、dockerグループに自分のユーザーを追加して非root運用にする。
sudo usermod -aG docker eseokun
グループの反映にはログインし直しが必要なので、新しいSSHセッションでdocker psが通ることを確認して次に進んだ。
docker-compose.ymlの設計:Tailscale IPに直接バインドする
要件に「外部公開ポートを一切開けない」があったので、Navidromeコンテナのポートマッピングを0.0.0.0:4533ではなく、KIRA V63のTailscale IPそのものに直接バインドすることにした。
services:
navidrome:
image: deluan/navidrome:latest
container_name: navidrome
restart: unless-stopped
ports:
# Tailscaleインターフェースにのみバインド。外部公開ポートは開けない。
- 100.89.94.32:4533:4533
environment:
ND_SCANSCHEDULE: 1h
ND_LOGLEVEL: info
ND_SESSIONTIMEOUT: 24h
volumes:
- /mnt/nas-data/music/navidrome/data:/data
- /mnt/nas-data/music/library:/music:ro
音楽データの置き場所は、Obsidianボルトのミラー先としてすでに使っている二台目ディスク/mnt/nas-dataの下にmusic/libraryとして新設した(Obsidianの同期先であるVault/ディレクトリとは完全に別系統)。Navidromeの公式Dockerドキュメントによれば、コンテナは/dataに読み書き権限、/musicに読み取り権限が必要とのことなので、その通りに:roを付けてマウントしている。
docker compose up -dで起動し、イメージのpullも無事完走した。
つまずき:localhostに繋がらない(が、これは正しい挙動だった)
起動後、動作確認のつもりで叩いたcurlが失敗して一瞬焦った。
$ curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:4533/
curl: (7) Failed to connect to localhost port 4533 after 0 ms: 接続を拒否されました
「起動ログは正常に見えるのに繋がらない」というのは一番嫌なパターンのバグに見える。だがssでリッスンソケットを確認してみると、答えは単純だった。
$ ss -tlnp | grep 4533
LISTEN 0 4096 100.89.94.32:4533 0.0.0.0:*
docker-compose.ymlで100.89.94.32:4533:4533と明示的に書いた通り、コンテナのポートはTailscale IPだけにバインドされていて、127.0.0.1(=localhost)には一切開いていない。これは設定ミスではなく、「外部公開ポートを一切開けない」という要件を字面通りに実現した結果だった。念のためTailscale IP宛に直接叩き直すと、期待通りのレスポンスが返ってきた。
$ curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://100.89.94.32:4533/
HTTP 302
Mac側からもTailscale経由で同じリクエストを投げて、同じくHTTP 302(ログイン画面へのリダイレクト)を確認できた。「繋がらない」と「正しく閉じている」は見分けがつきにくいので、ssやcurlで意図通りの範囲だけ開いているかを都度確認する癖はつけておいた方がいい。
つまずき:管理者ユーザー作成が「inappropriate ioctl for device」で落ちる
Web UIの初期セットアップウィザードを使わず、CLIで管理者ユーザーを作ろうとしたところ、ここでも一段落ちた。Navidromeにはuser createというサブコマンドがある(コンテナ内で/app/navidrome user create --helpを叩いて確認済み)。
$ docker exec -i navidrome /app/navidrome user create -u admin -a --name Admin
パスワードを標準入力からパイプで渡そうとしたが、結果はこうなった。
Enter new password (press enter with no password to cancel): time="2026-09-06T12:48:08Z" level=fatal msg="Error getting password" error="inappropriate ioctl for device"
Navidromeのパスワード入力は、入力内容を画面に表示しない(エコーバックしない)ために端末のtermios制御を直接叩いている。docker exec -iは標準入力をパイプ経由で渡すだけで疑似端末(pty)を割り当てないため、termios制御に必要なioctlが失敗してこのエラーになる。
対策は、SSH自体にpty割り当てを強制する-ttオプションと、docker exec側の-it(pty割り当て+標準入力アタッチ)を組み合わせることだった。
ssh -tt kira-v63 'docker exec -it navidrome /app/navidrome user create -u admin -a --name Admin'
これでパスワードプロンプトが正常に機能し、管理者ユーザーが作成できた。
Enter new password (press enter with no password to cancel):
Confirm new password (press enter with no password to cancel):
level=info msg="Successfully created user" id=e81Ql4poKcTKIYRwZwlzNa username=admin
docker execに-iだけ付けて「動くだろう」と決め打ちしたのが敗因で、pty周りは「非対話的に見えて実は端末機能に依存している処理」がある、という典型例だと思う。
つまずき:再起動するとNavidromeが起動に失敗するかもしれない問題
一通り動いたところで、念のため「サーバーを再起動したときにちゃんと復帰するか」を確認しておくことにした。ここで見つけたのが一番厄介な問題だった。
docker.serviceの起動条件を見てみると、ネットワーク関連ではnetwork-online.targetにしか依存していない。
$ systemctl show docker.service -p After -p Wants -p Requires | tr " " "\n" | grep -i network
network-online.target
network-online.target
一方でTailscaleはtailscaled.serviceという別のsystemdユニットで、network-online.targetのBefore/Wants関係には入っていない。つまり理論上、OS起動時に「Tailscaleのインターフェースがまだ立ち上がっていないのに、Dockerが先にdocker-compose.ymlのコンテナを起動しようとして100.89.94.32:4533へのbindに失敗する」というレースコンディションが起こり得る。
安直な対策として、まずはdocker.serviceにtailscaled.serviceへの依存を追加するsystemd drop-inを書いた。
# /etc/systemd/system/docker.service.d/after-tailscale.conf
[Unit]
After=tailscaled.service
Requires=tailscaled.service
これで解決したと思いきや、念のため調べてみるとTailscale自身のGitHub issueで、まさにこのケースが報告されていた。
tailscaledは、実際にIPがbind可能になる前にsystemdへ「準備完了(ready)」を通知してしまうことがある
つまりAfter=/Requires=で起動順序をtailscaled.serviceの後に固定しても、tailscaled.serviceがsystemdにreadyを伝えるタイミング自体が早すぎて、docker.serviceが動き出した瞬間にはまだtailscale0にIPが割り当たっていない可能性が残る。起動順序を保証しただけでは、レースコンディションを完全には潰せていなかったわけだ。
そこで同issueで紹介されていた対策を参考に、tailscale0に対象IPが実際に付与されるまでポーリングして待つスクリプトをExecStartPreとして挟むことにした。
# /usr/local/bin/wait-for-tailscale-ip.sh
#!/bin/bash
TARGET_IP="100.89.94.32"
for i in $(seq 1 30); do
if ip -4 addr show tailscale0 2>/dev/null | grep -q "$TARGET_IP"; then
exit 0
fi
sleep 1
done
echo "tailscale0 ($TARGET_IP) not ready after 30s" >&2
exit 1
# /etc/systemd/system/docker.service.d/after-tailscale.conf
[Unit]
After=tailscaled.service
Requires=tailscaled.service
[Service]
ExecStartPre=/usr/local/bin/wait-for-tailscale-ip.sh
daemon-reload後にdocker.serviceを再起動して確認すると、ExecStartPreが成功したことがログに残り、コンテナも問題なく復帰した。
Process: ExecStartPre=/usr/local/bin/wait-for-tailscale-ip.sh (code=exited, status=0/SUCCESS)
Main PID: dockerd
「順序を依存関係で縛ればレースは解決する」と思い込んでいたが、そもそも依存先のサービス自身のready通知が信用できないケースがある、というのは実際に一次情報(GitHub issue)に当たらないと気づけなかったポイントだった。
動作確認:Subsonic APIとWeb UI
最後に、要件通りの動作確認を行った。まずSubsonic互換APIの疎通確認。
$ curl "http://100.89.94.32:4533/rest/ping.view?u=admin&p=****&v=1.16.1&c=testclient&f=json"
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}}
$ curl "http://100.89.94.32:4533/rest/getLicense.view?u=admin&p=****&v=1.16.1&c=Substreamer&f=json"
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"license":{"valid":true}}}
openSubsonic: trueは、NavidromeがSubsonic API本体に加えてOpenSubsonicという拡張仕様にも対応していることを示すフラグで、play:SubやSubstreamerのようなSubsonic互換クライアントは、サーバーURLにhttp://100.89.94.32:4533を指定するだけでこのAPIを叩きに来る。認証もライセンスチェックも正常に通っているので、クライアント側からの接続は問題ないはずだ。
Web UI側のログインAPIも直接叩いて確認した。
$ curl -X POST http://100.89.94.32:4533/auth/login -d '{"username":"admin","password":"****"}'
{"id":"...","isAdmin":true,"name":"Admin","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}
JWTが発行されていることを確認できたので、ブラウザからの/app/アクセスも同じ経路で問題なく動く。仕上げにdocker.serviceを再起動して、追加したExecStartPre込みでコンテナが自動復帰することも確認済みだ。
iPhone側の設定
サーバーが動いたところで、肝心のiPhone側を設定していく。
1. Tailscaleに接続する
iPhoneに「Tailscale」アプリを入れ、KIRA V63と同じtailnetにログインしてVPN接続をONにする。これでiPhoneから100.89.94.32(KIRA V63のTailscale IP)に到達できるようになる。ここで繋がらない場合、iPhone側のtailnet未接続がほぼ唯一の原因になる。外部公開ポートを一切開けていない構成なので、Tailscaleが繋がっていなければ他に迂回する経路は存在しない。
2. Subsonic互換クライアントをインストールする
App StoreでSubstreamerをインストールする。どちらもSubsonic API(および今回確認したOpenSubsonic拡張)に対応したクライアントアプリだ。

3. サーバーを追加する
アプリの「サーバーを追加」画面で以下を入力する。
| 項目 | 値 |
|---|---|
| サーバーURL | http://100.89.94.32:4533 |
| ユーザー名 | 作成した管理者アカウント |
| パスワード | 同上 |
httpsではなくhttpで問題ない。Tailscaleのトンネル自体が暗号化されているので、tailnet内部の通信を素のHTTPで流しても外部から覗かれることはない。保存すると自動的にライブラリの読み込みが始まり、動作確認で見たping.view/getLicense.viewと同じAPIを叩いて認証・接続してくる。
4. Finderで「ミュージックを同期」をオフにする
最後に、今回そもそもの動機だった「同期されたメディア」を止める。iPhoneをMac miniにつないでFinderを開き、サイドバーからiPhoneを選択、上部タブの「ミュージック」で 「ミュージックをこの[iPhone名]に同期」 のチェックを外して「適用」を押す。これで次回同期時にFinder経由で送り込んでいた曲がiPhone本体から削除され、ストレージが解放される。

注意点として、このチェックを外すとプレイリストの同期も一緒に止まる。Finderの同期はミュージックとプレイリストがセットの機能なので、切り分けることはできない。手元でよく聴くプレイリストがある場合は、Music.appの「ファイル > ライブラリ > プレイリストを書き出す...」で.m3u形式にエクスポートし、音楽ライブラリと同じ/mnt/nas-data/music/library/に置いてスキャンし直せば、Navidrome側の自動インポート機能でそのまま読み込める。ただし、書き出した.m3uの中身はMac上の絶対パス(/Users/.../Music/iTunes/iTunes/...)のままなので、コンテナ内のパス(/music/...)へ一括置換する必要がある点と、macOSは大文字小文字を区別しない(APFS)のに対しLinux側は区別するため、experienceとExperienceのような表記ゆれがあると一致しないことがある点は覚えておきたい。
ここまで終わらせて、実機のplay:Subからライブラリとプレイリストが問題なく表示・再生できることを確認した。


まとめ
- 「同期されたメディア」によるiPhoneのストレージ圧迫を解消するため、KIRA V63にDocker+Navidromeでプライベート音楽ストリーミングサーバーを構築した
- ポートを
0.0.0.0ではなくTailscale IPに直接バインドすることで、外部公開ポートを一切開けずにTailscale経由限定のアクセスを実現した localhostへの接続拒否は設計通りの挙動であり、「繋がらない」=「バグ」とは限らないことを再確認した- CLIでのパスワード入力は
docker exec -iではptyが割り当てられず失敗する。ssh -tt+docker exec -itの組み合わせが必要だった docker.serviceをtailscaled.serviceの後に起動する設定だけでは不十分で、tailscaled自体のready通知が早すぎるという既知の問題があるため、ExecStartPreでのIP到達待ちポーリングまで実装して初めて再起動耐性を確保できた- Subsonic API・Web UIの両方で、Tailscale経由の実接続と認証まで確認できた
- iPhone側はTailscale接続+play:Sub/Substreamerへのサーバー登録だけで完了し、最後にFinderの「ミュージックを同期」をオフにして当初の目的(同期されたメディアによるストレージ圧迫の解消)を達成した
「順調に終わった手順は一つもなかった」と冒頭に書いたが、逆に言えばどのつまずきも一次情報(公式ドキュメントやGitHub issue)に当たれば必ず理由が説明できるものばかりだった。次はSyncthingでの自動同期化を検討したい。
参考
- 本ブログ内記事: 古くなったノートPCでサーバを作ろう ~React+Java+Nginx+SSH+Jenkinsまで全部載せた実践記録~(TOSHIBA KIRA V63をUbuntuサーバ化した経緯)
- Docker, "Install using the convenience script": https://get.docker.com
- Navidrome公式ドキュメント, "Installation - Docker": https://www.navidrome.org/docs/installation/docker/
- tailscale/tailscale, GitHub Issue #11504, "Tailscaled tells systemd that it is ready before its ip address is bindable": https://github.com/tailscale/tailscale/issues/11504
- Subsonic API仕様: http://www.subsonic.org/pages/api.jsp
- OpenSubsonic API: https://opensubsonic.netlify.app/