結論
コンテナの中で pnpm run dev を叩いたら、そのままホストのブラウザで http://localhost:5173 が開ける。
そのための小さなツール xvia を作った。
# ホスト側(例: WSL2のUbuntu)
xvia agent
# コンテナ側
xvia pnpm run dev
# コンテナ内でViteが127.0.0.1:5173をlistenする
# -> ホストから http://localhost:5173 で開ける
いつものコマンドの頭に xvia を付けるだけである。
リポジトリ: https://github.com/tamuto/xvia
何が便利なのか
先に、使っていて「これは楽だ」と感じている点を並べておく。
-
ports:を書かなくていい
開発サーバのポートを増やすたびにcompose.yamlを書き換えて、コンテナを作り直す、という作業がなくなる。 -
ポート番号を事前に知らなくていい
xvia は起動したコマンドが実際にlistenしたポートを自動で検出して転送する。Vite が空き番号を探して 5174 にずれても、そのまま追従する。 -
開発サーバの設定を変えなくていい
--host 0.0.0.0を付け忘れて「コンテナの外から見えない」と悩む、あの時間がなくなる。コンテナ内で127.0.0.1にbindしたままで届く。 -
Dockerソケットも特権も要らない
/var/run/docker.sockのマウント、privileged: true、network_mode: host、コンテナ内のSSHサーバ、いずれも不要。ホストとコンテナで共有するのは、Unixドメインソケットが入ったディレクトリ1つだけである。 -
WebSocketもHMRもDBも、TCPならそのまま通る
中身を解釈せず生のバイト列を中継しているので、HTTP/2でもVite HMRでもPostgreSQLのプロトコルでも区別しない。 -
Windowsのブラウザからも開ける
WSL2標準のlocalhostフォワーディングに乗るので、xvia側でWindows向けの細工は何もしていない。 -
依存ゼロの単一バイナリ
Go標準ライブラリのみで書いた静的バイナリ1つ。Debian / Ubuntu / Alpine のどこにでも置ける。
本記事の構成
- 1章 … なぜ作ったか
- 2章 … 使い方(インストール、ホスト側エージェント、Docker Compose への組み込み)
- 3章 … 仕組み(ポートをどう見つけ、どう中継しているか)
- 4章 … セキュリティの考え方
- 5章 … 制限事項とハマりどころ
使うだけなら2章まで読めば足りる。3章以降は「なぜそう作ったか」の話なので、気になったところだけ拾ってほしい。
対象読者
- WSL2やLinux上のDocker Engineで、コンテナに開発環境を閉じ込めている人
- 複数プロジェクトのコンテナを並べていて、
ports:の管理に疲れている人 - 「ポートを自動で公開するツール」がDocker APIを要求してくることに、ちょっと抵抗がある人
1. なぜ作ったか
コンテナで開発していると、開発サーバをホストのブラウザで開くまでに、地味な手間がいくつも挟まる。
-
compose.yamlにports: ["5173:5173"]を書く - コンテナを作り直す
- 開発サーバがコンテナ内の
127.0.0.1にしかbindしていないので外から見えない。--host 0.0.0.0を足す - 別プロジェクトのコンテナも 5173 を使っていてぶつかる。片方をずらす
- Storybookを追加したら 6006 も要る。1に戻る
1つ1つは大したことがない。ただ、プロジェクトが増えるとこれが延々と続く。
VS CodeのDev Containersを使っていれば、エディタ側がポートを自動で転送してくれる。
しかし、それはエディタに紐づいた機能であり、docker compose up を素で使う場合や、ターミナルだけで作業したい場合には頼れない。
ポートを自動で公開してくれるツールを探すと、たいていDocker API(/var/run/docker.sock)へのアクセスを要求される。
Dockerソケットへのアクセスは、実質的にホストのroot権限と同じである。
開発中のコンテナ、つまり npm install で何が入ってきているか分からない環境にそれを渡すのは、正直あまり気持ちのいいものではない。
そこで発想を変えた。
Dockerに頼らず、開発サーバを起動するコマンドそのものを包んでしまえばいい。
包んだコマンドが何番をlistenしたかを見張り、ホスト側の小さなエージェントに「同じ番号で待ち受けて、こっちに流して」と頼む。
xvia はこれだけをするツールである。
2. 使い方
2-1. インストール
xvia は Linux(amd64 / arm64)向けの静的バイナリ1つである。
ホストとコンテナの両方に置き、両者のバージョンは揃えておく。
GitHub Releases から入れる場合:
arch=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
curl -fsSLo xvia "https://github.com/tamuto/xvia/releases/latest/download/xvia-linux-$arch"
curl -fsSLo SHA256SUMS https://github.com/tamuto/xvia/releases/latest/download/SHA256SUMS
grep "xvia-linux-$arch\$" SHA256SUMS | sed "s/xvia-linux-$arch/xvia/" | sha256sum -c -
sudo install -m 0755 xvia /usr/local/bin/xvia
Goがあるなら:
go install github.com/tamuto/xvia/cmd/xvia@latest
コンテナ側はDockerfileに焼いておくのが楽である。
ARG XVIA_VERSION=v0.1.0
ARG TARGETARCH
RUN curl -fsSLo /usr/local/bin/xvia \
"https://github.com/tamuto/xvia/releases/download/${XVIA_VERSION}/xvia-linux-${TARGETARCH}" \
&& chmod +x /usr/local/bin/xvia
2-2. ホスト側でエージェントを起動する
xvia agent
root権限は要らない。デフォルトでは次のように動く。
| 項目 | デフォルト |
|---|---|
| ソケットの場所 |
$XDG_RUNTIME_DIR/xvia/agent.sock(たいてい /run/user/1000/xvia/agent.sock) |
| ソケットの権限 | ディレクトリ 0700、ソケット 0600
|
| 転送ポートのbind先 |
127.0.0.1 のみ |
| 受け付けるポート | 1024〜65535 |
毎回手で起動するのが面倒なら、systemdのユーザーサービスにしておく(WSL2でsystemdを有効にしている場合)。
[Unit]
Description=xvia host agent
[Service]
ExecStart=/usr/local/bin/xvia agent
Restart=on-failure
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now xvia-agent
2-3. Docker Compose に組み込む
ソケットが入ったディレクトリを、コンテナの /run/xvia にbind mountする。
あとは起動コマンドの頭に xvia を付けるだけである。
services:
app:
image: my-dev-image # xviaを入れたイメージ
volumes:
- ${XDG_RUNTIME_DIR}/xvia:/run/xvia
command: ["xvia", "pnpm", "run", "dev"]
ports: もDockerソケットも特権もない。これで全部である。
ここで3つだけ注意がある。どれも一度は踏みそうなので、先に書いておく。
-
ソケットファイルではなく、ディレクトリをマウントする
エージェントを再起動するとソケットファイルは作り直される。ファイル単位のbind mountだと、古いinodeを掴んだままになり新しいソケットが見えない。 -
docker compose upより先にエージェントを起動する
マウント元のディレクトリが無いと、Dockerがrootで作ってしまう。するとエージェントは「他人のディレクトリ」として使用を拒否する(owned by uid 0)。
long syntaxでbind: {create_host_path: false}を指定しておけば、ディレクトリが無いときにcomposeの方がエラーで止まってくれる。 -
コンテナのユーザーが
0600のソケットを開けること
コンテナ内でrootか、エージェントと同じUIDである必要がある。WSLの既定ユーザーも、公式NodeイメージのnodeユーザーもUID 1000なので、たいていはそのまま通る。
create_host_path: false を使った書き方は、リポジトリの例がそのまま参考になる。
x-xvia-socket: &xvia-socket
type: bind
source: ${XVIA_SOCKET_DIR:-${XDG_RUNTIME_DIR:?set XDG_RUNTIME_DIR or XVIA_SOCKET_DIR}/xvia}
target: /run/xvia
bind:
create_host_path: false
services:
web:
command: ["xvia", "python3", "-m", "http.server", "8000", "--bind", "127.0.0.1"]
volumes:
- *xvia-socket
api:
# シェル経由で起動しても、孫プロセスのポートまで検出される
command: ["xvia", "sh", "-c", "exec devserver --addr 127.0.0.1:8001"]
volumes:
- *xvia-socket
2つのコンテナがどちらも 127.0.0.1 にしかbindしていないのに、ホストから curl http://127.0.0.1:8000 と curl http://127.0.0.1:8001 の両方が通る。
2-4. CLIの使い方
xvia pnpm run dev # 自動検出
xvia --port 5173 pnpm run dev # 5173は検出されなくても転送する
xvia --port 5173,8000 pnpm run dev # 複数指定
xvia --no-detect --port 5173 pnpm run dev # 指定したポートだけ
xvia -- python3 -m http.server 8000 --bind 127.0.0.1
xvia のオプションはコマンドより前に置く。最初の非オプション引数(または --)以降は、そのまま包むコマンドに渡す。
--port のようにありがちな名前のオプションでも、包んだコマンド側のフラグと混ざることはない。
起動するとこんな出力になる。
$ xvia pnpm run dev
xvia: connected to agent at /run/xvia/agent.sock
VITE v6.0.0 ready in 300 ms
➜ Local: http://localhost:5173/
xvia: forwarding 127.0.0.1:5173 (host) -> port 5173 (container)
「包んでいること」を意識させないための振る舞い
包む系のツールで一番嫌なのは、包んだせいで挙動が変わることである。そこはかなり気を使った。
- 作業ディレクトリ、環境変数、stdin / stdout / stderr はそのまま引き継ぐ
- 終了コードは包んだコマンドの終了コード。シグナルNで死んだら
128+N、コマンドが見つからなければ127、実行できなければ126 - エージェントがいなくても、コマンドは普通に動く。警告を出してバックグラウンドで再接続を試み続け(250msから最大10秒の指数バックオフ)、エージェントが戻ってきたら現在のポートを全部登録し直す
最後の点は地味に効く。ホスト側でエージェントを再起動しても、コンテナ側の開発サーバを止める必要はない。
3. 仕組み
3-1. 全体像
ホストのブラウザ / curl
| 127.0.0.1:5173
v
xvia agent(ホスト) ── Unixソケット ── xvia(コンテナ) ── TCP ── Vite(127.0.0.1:5173)
$XDG_RUNTIME_DIR/xvia/agent.sock /run/xvia/agent.sock
└──── 同じディレクトリをbind mount ────┘
流れは次のとおり。
-
xvia <command>がコマンドを起動し、Unixソケット経由でエージェントにつなぐ - 500msごとに
/procを調べ、コマンドとその子孫プロセスがLISTENしているTCPソケットを探す - 新しいポートが見つかったら
registerを送る。エージェントはホストの127.0.0.1:<port>でlistenを始める - ホストに接続が来たら、エージェントはクライアントに「ストリームを開いて」と頼む。クライアントはコンテナ内のサーバにつなぎ、Unixソケットへもう1本接続を張る。エージェントがその間を双方向に中継する
- ポートのlistenが止まる、コマンドが終わる、クライアントが消える、のいずれかでホスト側のlistenも閉じる
ポイントは、Unixドメインソケットはbind mountをまたいでも使えることである。
ホストとコンテナが同じカーネルを共有している限り、ソケットファイルを置いたディレクトリを共有するだけで、ネットワークの設定に一切触れずに通信路ができる。
3-2. ポートの自動検出: /proc だけで見つける
xvia は外部コマンド(ss や lsof)を一切呼ばず、/proc を読むだけでポートを見つけている。
(1) 子孫プロセスを取りこぼさない
pnpm run dev は、pnpm → node → esbuild…と、プロセスを何段も生やす。途中の親が先に終わることもある。
普通は、親を失ったプロセスはPID 1に引き取られ、プロセスツリーから外れてしまう。
そこで xvia は自分自身を child subreaper(prctl(PR_SET_CHILD_SUBREAPER))にしている。
こうすると、孤児になった子孫はPID 1ではなく xvia の下に付け替えられ、ツリーから逃げなくなる。
子孫は /proc/<pid>/task/<tid>/children でたどる(無いカーネルでは /proc/<pid>/stat の親PIDで代用する)。
(2) ソケットの持ち主を突き合わせる
子孫プロセスごとに /proc/<pid>/fd/* を読み、socket:[inode] になっているリンクからinode番号を集める。
(3) LISTENしているものだけを拾う
/proc/<pid>/net/tcp と tcp6 を読み、状態が LISTEN(0A)のエントリを拾う。
ここで1つ落とし穴がある。net/tcp はプロセス単位ではなくネットワーク名前空間単位の一覧で、その名前空間にあるソケットが全部載っている。
そのまま使うと、xvia を起動する前から動いていた無関係なサービスまで転送してしまう。
これを防いでいるのが (2) のinode照合である。
「LISTENしていて、かつinodeが自分の子孫のものであるソケット」だけを対象にしている。
net/tcp は名前空間ごとに1回だけ読み、無駄な読み込みはしない。
(4) 接続先アドレスを決める
| サーバのbind先 | コンテナ内での接続先 |
|---|---|
0.0.0.0 / 127.0.0.1
|
127.0.0.1 |
::1 |
[::1] |
:: |
127.0.0.1、だめなら [::1]
|
| 特定のアドレス | そのアドレス |
スキャンの間隔とキャッシュ
検出は500msごと(--poll-interval で変更可)。
プロセスツリーは毎回自分のPIDから作り直し、スキャン間でキャッシュしない。
PIDが再利用されても、その瞬間に本当に自分の子孫でない限り見に行かないので、他人のプロセスを取り違えることがない。
結果として、サーバがlistenを始めてからおよそ0.5秒以内にホスト側に現れ、止まれば同じくらいで消える。
3-3. 中継: 1本のTCP接続に1本のUnix接続
ここは設計で一番迷ったところである。
当初は、1本のUnix接続に制御メッセージとすべてのTCPストリームのデータを多重化する案を考えていた。
しかしそれをまともにやろうとすると、
- ストリームごとのフロー制御(ブラウザのタブ1つが詰まると全ストリームが止まる、を避けるため)
- half-closeやリセットの明示的なメッセージ
- バッファ上限とスケジューラ
が必要になる。つまり yamux や HTTP/2 のフロー制御を自前で作り直すことになる。開発用ツールに対しては明らかにやりすぎだ。
そこで、制御用に1本、転送するTCP接続ごとにもう1本ずつUnix接続を張る方式にした。
制御接続(クライアントごとに1本)
client agent
hello {version, software} ->
<- welcome {version, software, error?}
register {port} ->
<- registered {port, address | error}
<- open {port, stream, token} (TCP接続が来た)
ping ->
<- pong
データ接続(転送するTCP接続ごとに1本)
client attach {version, stream, token} -> agent
... 以降は生のTCPバイト列を双方向に流すだけ ...
各メッセージは「4バイトのビッグエンディアン長 + JSON(最大16KiB)」のフレームである。
Unixソケットの接続はハンドシェイクらしいものがほぼなく安いし、開発用途なら毎秒せいぜい数十本である。
この方式にすると、面倒なことをカーネルが全部やってくれる。
- バックプレッシャー: 送信バッファが詰まったストリームだけが遅くなり、他は影響を受けない
-
half-close: TCP側とUnix側の
CloseWriteがそのまま1対1で対応する - 分離と後始末: 1本閉じても他は壊れない。クライアントが落ちれば、そのソケットはカーネルがまとめて閉じる
結果として、中継部分の本体は2つのgoroutineで io.CopyBuffer しているだけになった。
コードが少ないということは、バグが入る場所も少ないということである。
データ接続と、ホスト側で待たされているTCP接続との紐づけには、ストリームIDと128bitのランダムなトークンを使う。
トークンは、そのポートを持っているクライアントの制御接続にだけ送られ、使い捨てで、定数時間で比較する。
10秒以内に引き取られなかったストリームは閉じる。
3-4. プロセス管理の細かい話
包む系のツールは、シグナルと終了処理で泣くことが多い。ここも書いておく。
- 端末がある場合(stdinが制御端末): 包んだコマンドは xvia と同じプロセスグループに入る。端末から入力を読めるし、Ctrl+C もカーネルから直接届く。二重に届かないよう、xvia は受け取った SIGINT / SIGQUIT を転送しない
- 端末がない場合: コマンドは自分のプロセスグループを持ち、SIGINT / SIGTERM / SIGHUP / SIGQUIT はグループ全体に転送する
-
終了時: メインのコマンドが終わると、まず制御接続を閉じてホスト側のlistenを即座に消す。残った子孫には SIGTERM、3秒後に SIGKILL を送る。kill の直前には
/proc/<pid>/statの開始時刻を見直し、PID再利用で別のプロセスを殺さないようにしている -
xvia自身がSIGKILLされた場合: 直接の子には親死亡シグナル(
Pdeathsig)でSIGTERMが届く。このシグナルはforkしたスレッドに紐づくため、子を起動するスレッドはruntime.LockOSThreadで固定し、子が終わるまで生かしておく
最後のものは、Goで Pdeathsig を使うときの地味なハマりどころである。
goroutineを動かしているOSスレッドが途中で終了すると、子プロセスにはスレッドの消滅が「親の死」として伝わってしまう。
4. セキュリティの考え方
xvia は「開発者1人のマシン」で使う前提で、デフォルトを絞ってある。
- 転送ポートは
--bindを指定しない限り127.0.0.1でしかlistenしない。--bind 0.0.0.0にすると警告を出す - ソケットのディレクトリは
0700、ソケットは0600。他人のUIDのディレクトリやシンボリックリンクは拒否する(ディレクトリの持ち主ならソケットを差し替えられるため) - エージェントはコマンドを実行しない。クライアントが頼めるのは「ホストのTCPポートNでlistenして、こっちに流して」だけ
- ポートは
--allow-portsで範囲を制限。クライアント数(64)、クライアントあたりのポート数(64)、同時接続数(--max-conns、1024)にも上限がある - 不正な入力は、その接続だけを閉じる。ハンドシェイクやストリーム確立にはタイムアウトがある
- 中継したデータはログに出さない
ただし、1点だけはっきり書いておく。
ソケットは信頼できるコンテナにだけマウントすること。
ソケットを開けるプロセスなら、許可範囲内でホストのポートをlistenさせ、そこで好きな応答を返せる。
たとえば、ホストのアプリが「別の何か」だと思って接続しにいくポートを、悪意あるコンテナが先に取ることもできる。
Dockerソケットのような「ホストのroot相当」ではないが、「自分のユーザーアカウントへの入口の1つ」くらいには扱ってほしい。
5. 制限事項とハマりどころ
5-1. できないこと
- Linux専用: ホストもコンテナもLinuxである必要がある(WSL2、Linuxデスクトップ、Linux VM)。macOSとWindowsのDocker Desktopは、ファイル共有がUnixソケットを通さないので今のところ対象外
- 同じカーネルが前提: Unixソケットをbind mount越しに使えるのは、ホストとコンテナがカーネルを共有しているときだけ。リモートのDockerホストでは動かない
- ポーリングである: 最大で1間隔分の遅れがある。一瞬だけlistenするようなサーバは見逃すことがある
- ポート番号は変えない: 番号が衝突したら拒否するだけで、別番号への自動割り当てはしない
-
権限の違うプロセスは見えない: 別ユーザーで動くプロセス(setuidなヘルパーなど)の
/proc/<pid>/fdは読めないので、そのポートは--portで指定する
ポート番号を変えないのは意図的である。
localhost:5173 を開いたら、どのプロジェクトでも5173が出てくる、という素直さを優先した。
衝突したときは、開発サーバは止めずに転送だけを諦め、次のように知らせる。
xvia: port 5173 is already in use
5-2. 詰まったときの早見表
| メッセージ / 症状 | 原因と対処 |
|---|---|
agent socket /run/xvia/agent.sock not found |
エージェントが動いていない、またはディレクトリがマウントされていない。ホストで ls -l $XDG_RUNTIME_DIR/xvia/、コンテナで ls -l /run/xvia/ を確認 |
permission denied on agent socket |
コンテナのユーザーのUIDがエージェントと違う。user: "1000:1000" にするか、コンテナ内ではrootで動かす |
socket directory ... is owned by uid 0 |
エージェントより先にDockerがディレクトリを作った。sudo rm -r $XDG_RUNTIME_DIR/xvia → エージェント起動 → コンテナ作り直し |
port N is already in use |
他のコンテナかホストのプログラムが使っている。ホストで ss -ltnp 'sport = :N'
|
protocol version mismatch |
ホストとコンテナで xvia のバージョンが違う。揃える |
| ポートが検出されない |
--log-level debug で確認。サーバが xvia の子孫として起動されているかを見る(npm run dev & してから xvia sleep infinity、では検出されない)。最後の手段は --port N
|
XDG_RUNTIME_DIR が空 |
sudo や最小構成のセッションでよくある。export XDG_RUNTIME_DIR=/run/user/$(id -u) するか、エージェントに --socket を渡し、composeでも同じディレクトリを使う |
まとめ
xvia がやっていることは、突き詰めると次の2つだけである。
- 包んだコマンドの子孫が何番をlistenしたかを
/procから見つける - ホストで同じ番号を開き、共有ディレクトリのUnixソケット越しにバイト列を流す
Dockerのネットワーク設定にも、Docker APIにも触らない。
だからこそ compose.yaml は短いままで済み、開発サーバの設定もいじらずに済む。
ports: を書いてコンテナを作り直す、あの小さな手間の積み重ねに心当たりがあれば、一度試してみてほしい。
Go標準ライブラリだけで書いてあるので、中身を読むのもそれほど大変ではないはずである。