0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コンテナの開発サーバを「ports:」なしでホストに届ける xvia を作った

0
Posted at

結論

コンテナの中で 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. なぜ作ったか

コンテナで開発していると、開発サーバをホストのブラウザで開くまでに、地味な手間がいくつも挟まる。

  1. compose.yaml に ports: ["5173:5173"] を書く
  2. コンテナを作り直す
  3. 開発サーバがコンテナ内の 127.0.0.1 にしかbindしていないので外から見えない。--host 0.0.0.0 を足す
  4. 別プロジェクトのコンテナも 5173 を使っていてぶつかる。片方をずらす
  5. 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を有効にしている場合)。

~/.config/systemd/user/xvia-agent.service
[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 を付けるだけである。

compose.yaml
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 を使った書き方は、リポジトリの例がそのまま参考になる。

examples/docker-compose/compose.yaml(抜粋)
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 ────┘

流れは次のとおり。

  1. xvia <command> がコマンドを起動し、Unixソケット経由でエージェントにつなぐ
  2. 500msごとに /proc を調べ、コマンドとその子孫プロセスがLISTENしているTCPソケットを探す
  3. 新しいポートが見つかったら register を送る。エージェントはホストの 127.0.0.1:<port> でlistenを始める
  4. ホストに接続が来たら、エージェントはクライアントに「ストリームを開いて」と頼む。クライアントはコンテナ内のサーバにつなぎ、Unixソケットへもう1本接続を張る。エージェントがその間を双方向に中継する
  5. ポートの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標準ライブラリだけで書いてあるので、中身を読むのもそれほど大変ではないはずである。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?