📖

Fly.ioでWebSocket VNCの接続エラー:ConnectionRefusedErrorを解決する

に公開

Fly.io, Xvib, Fluxbox, x11vncなどを使って、ConnectionRefusedErrorにはまった方のためにナレッジをウェブに記録しておきます。Geminiが執筆しました。

Fly.ioでVNCが繋がらないローカルとの違いと謎の ConnectionRefusedError

最近、プロジェクトでFly.ioを使い始めたんです。FastAPIをバックエンドに、フロントエンドからブラウザを操作するようなサービスを考えていて。その肝となる部分で、リモートデスクトップを実現するためにVNC接続を使うことにしたんですよね。ローカル環境でDocker使って試作していたときは、これが驚くほどスムーズに動いてくれて、バイブコーディングしながら「これはイケるかも」という感じがしていたんです。

ところが、Fly.ioにデプロイしてみると、なぜかフロントエンドからVNCサーバーに全く繋がらないという問題が出てきました。ブラウザのコンソールを覗くと、エンジニアならおそらく見覚えのある ConnectionRefusedError がくっきりと表示されていて。「ローカルでは問題なかったのになぁ」と少し戸惑いましたね。

気を取り直して、初期調査を開始したんです。まず疑ったのは、そもそもFly.io上でVNCサーバー関連のプロセスたちがちゃんと起動しているのかどうか、という点です。FastAPIのバックエンド (service.py というファイルで実装していました) では、Xvfb(仮想ディスプレイサーバーですね)、Fluxbox(軽量ウィンドウマネージャー)、そして肝心のx11vnc(VNCサーバー本体)を順番に立ち上げる仕組みにしていたので、それぞれの起動シーケンスでログを仕込んでみることにしました。

調査してみると、プロセス自体は起動しようとはしているみたいなんですよね。でも、WebSocket経由でいざ接続というタイミングで、やはり ConnectionRefusedError が出てしまうんです。なかなか難しい問題だなと感じました。

さらにログを深く追いかけてみると、x11vnc のエラー出力の中に、あまり一般的ではないメッセージを見つけたんです。caught XIO error: とか X connection to :XXX broken (explicit kill or server shutdown). といった、普段あまり見かけないエラーメッセージでした。X Window System関連で何か問題が起きているような気がします。Xvfbがうまく機能していないのか、あるいはx11vncとの連携がFly.ioの環境だと何か特殊な事情があるのか…。この時点では、原因特定にはまだ距離がある状態でしたね。とにかく、やみくもに対処するのではなく、まずは何が起きているのかを正確に把握するために、ひたすらログを確認する作業に取り組むことにしたんです。

インスタンスの壁と localhost の罠 - fly scale count 1から考える

X11関連のエラーログとにらめっこしながら、「Fly.ioの環境特有の設定が必要なのかな」とか、「そもそもリソース(CPUとかメモリとか)が足りていないのかな」とか、いろんな可能性を頭の中で巡らせていました。OOM Killerのログを探してみたり、fly.toml の設定を見直したりしてみたんですが、これといった決定的な手がかりは見つからず、デバッグ作業は暗礁に乗り上げかけていたんです。

そんな中、ふとFly.ioのアーキテクチャに関するドキュメントを眺めているときに、ある考えが頭をよぎりました。「もしかして、これって複数インスタンスで動いていることが原因なんじゃないかな?」という気がしてきたんです。Fly.ioって、デフォルトで複数のリージョンにインスタンスを分散して可用性を高めてくれる機能がありますよね。ローカルで開発しているときは、当然インスタンスなんて1つしかないので、その違いが影響しているのかもしれないと考えたわけです。

もしそうなら、リクエストを受け付けるFastAPIのアプリケーション(websocket.py で処理している部分ですね)と、実際にXvfbやx11vncが動いているバックグラウンドプロセス(service.py で管理している部分)が、別々のインスタンスで実行されている可能性があるんじゃないかと思ったんです。

この仮説を検証するために、思い切ってFly.ioのコマンドラインツールでインスタンス数を強制的に1つにスケールダウンしてみることにしました。fly scale count 1 -a your-app-name というコマンドを実行して、「うまくいくといいなぁ」という思いで再度接続を試みたんです。そしたら、VNC画面が表示されて解決されました。

これで原因はほぼ特定できました。複数インスタンス環境で、VNCサーバーが起動しているインスタンスと、クライアントからのWebSocket接続リクエストを処理するインスタンスが異なっていたんです。そして、WebSocketリクエストを受けたインスタンスは、RedisからVNCの接続情報(ポート番号とかトークンとか)は取得できていたものの、肝心の接続先ホストとして localhost を使おうとしていたんですよね。でも、その localhost はあくまで「リクエストを受けたインスタンス自身」を指すのであって、「VNCサーバーが実際に動いている別のインスタンス」ではないわけです。そう考えると ConnectionRefusedError になるのも納得できますよね。

ローカル環境では、FastAPIのアプリもVNCサーバーも同じ localhost 上にいたから問題なかったんですが、Fly.ioの複数インスタンス環境では、この localhost の解釈の違いが致命的な接続エラーを引き起こしていた、というわけです。いわゆる「灯台下暗し」というやつかもしれませんね。この気づきがなければ、もっと複雑な問題だと思い込んで、さらに混乱していたかもしれないと思います。

Fly.ioプライベートネットワークを活用したインスタンス間VNC接続の実現

原因が判明すれば、あとは解決に向けて突き進むだけですよね。問題は「VNCサーバーが動いているインスタンス」と「WebSocketリクエストを処理するインスタンス」が別々の場合に、後者が前者のIPアドレスを知らずに localhost に接続しようとしてしまうことでした。

これを解決するためのアプローチは、大きく分けて2つあると考えました。

  1. VNCサーバーを、それが起動しているインスタンスのプライベートIPアドレスで待ち受けるようにする。
  2. WebSocketリクエストを処理するインスタンスが、接続すべき正しいプライベートIPアドレスを知る手段を提供する。

まず、1つ目の「VNCサーバーの外部公開(といってもFly.ioのプライベートネットワーク内ですが)」について。これは、x11vnc を起動する際のコマンドラインオプションを変更することで対応してみました。具体的には、今まで暗黙的に指定されていた(あるいはデフォルトで有効だった)-localhost というオプションを明示的に外す、もしくはネットワークインターフェースを指定するオプションを使って、Fly.ioの内部ネットワークからの接続を受け入れられるようにしたんです。これで、VNCサーバーは自分自身 (localhost) だけでなく、同じプライベートネットワーク内の他のインスタンスからの接続も受け付ける準備が整いました。

次に、2つ目の「正しい接続先IPアドレスの特定と共有」についてです。ここが少し難しい部分だったのですが、Fly.ioが提供してくれている環境変数が役立ちました。Fly.ioの各インスタンスには、FLY_PRIVATE_IP という環境変数が設定されていて、これにはそのインスタンスのプライベートIPアドレスが格納されているんですよね。これを活用しない手はないと思いました。

具体的な実装としては、まずVNCサーバーを起動する側の service.py で、x11vnc のプロセスを立ち上げた後、この FLY_PRIVATE_IP 環境変数の値を読み取るようにしました。そして、そのIPアドレスを、VNCのポート番号やトークンといった他の接続情報と一緒にRedisに保存するように変更したんです。

そして、クライアントからのWebSocket接続リクエストを受け付ける websocket.py 側では、従来通りRedisからVNC接続情報を取得する際に、新たに追加された fly_private_ip の値も一緒に取得するようにしました。そして、asyncio.open_connection でVNCサーバーに接続する際、接続先ホストとしてハードコードされていた 'localhost' の代わりに、このRedisから取得した fly_private_ip を使うように修正しました。もしRedisに fly_private_ip が保存されていなかった場合(例えば古いデータやローカル開発環境との互換性のため)は、フォールバックとして 'localhost' を使うようにもしておきました。

この修正を加えて再度Fly.ioにデプロイし、今度はインスタンス数を複数に戻した状態でテストしてみると… 見事にVNC接続が成功しました。長い道のりでしたが、ようやく解決に至ることができました。

今回のトラブルシューティングで得られた最大の教訓は、Fly.ioのようなPaaS環境では、インスタンスの数やネットワーク構成がローカルとは大きく異なる場合があるので、localhost のような自明に見える設定も、クラウド上では思わぬ挙動を示すことがあるんですよね。そして、fly scale count 1 のようなシンプルなコマンド一つで問題が切り分けられたように、仮説を立ててそれを検証するサイクルを回すことが、複雑な問題解決への近道なんだなと改めて実感しました。

これからFly.ioで似たような構成を考えている方の、何かしらの参考になれば嬉しいなと思います。

Discussion