Contents

【Web開発者向け】 自前のtailnetを使いながら取引先のtailnetにも同時に参加してみた話 【環境構築】

受託開発の案件で、取引先のステージング環境に取引先のTailscaleアカウントのexit-node経由でしかアクセスできないという状況になりました。 ところが手元ではすでに自前のTailscale環境を開発に使っていたので、「2つのtailnetをどう両立させるか」で少しハマりました。作業ログを残しておきます。 最終的には、DockerコンテナでTailscaleをuserspace networkingモードで動かし、SOCKS5プロキシとして必要なアプリからだけ使うという構成に落ち着きました。 さらに、このプロキシをClaude Codeにも使わせるようにしたところ、開発体験が大幅に改善しました。

背景

もともと自前でTailscaleを導入していて、自宅や外出先から開発用のマシンにSSHしたりと、日常的に開発に活用していました。

そこへ、取引先のステージング環境に以下の条件でアクセスする必要が出てきました。

  • 取引先が用意したTailscaleアカウントでtailnetに参加する
  • 指定されたexit-node経由の通信のみ許可される(IP制限)

素直に手元のPCで取引先のアカウントにログインすればよさそうですが、ここで問題が2つありました。

  • 問題1: 自前のtailnetと同時に使えない
    • Tailscaleの公式クライアントは、基本的に同時に1つのアカウント(tailnet)にしか接続できません
    • 複数のアカウントを登録して切り替えるUI(Fast User Switching)はあるのですが、あくまで「切り替え」なので、取引先側に切り替えると自前のtailnetからは抜けてしまいます
    • しかも切り替えの瞬間に、自前のtailnet経由でつないでいたSSHなどの通信も途切れてしまいます。作業のたびに行ったり来たりするのはさすがにつらい……
  • 問題2: exit-nodeを有効にするとLANにもアクセスできなくなる
    • exit-nodeは端末全体の通信の出口を切り替えるので、LAN内の開発用Linuxサーバーにもアクセスできなくなってしまいます。これでは開発が進みません

「専用のVM(Windowsゲストなど)を立てて、そこにTailscaleを入れるしかないのか?」とも考えましたが、VMを1台増やすのはさすがに重たいので、もう少し軽い方法を探してみることにしました。

作ったもの

最終的な構成は以下のような感じです。

[作業用PC] Firefox
   │  SOCKS5 (localhost:1055)
   │  ssh -L 1055:localhost:1055
   ▼
[開発用Linuxサーバー 192.168.x.x]
   ├─ Claude Code ── SOCKS5 (localhost:1055) ──┐
   └─ Docker: tailscale/tailscale (userspace networking + SOCKS5 :1055)
         │  取引先tailnet / exit-node経由
         ▼
     [取引先ステージング環境]
  • ホストOSのルーティングには一切手を入れないので、LAN内の通信はこれまでどおり
  • 取引先のtailnetに参加するのはコンテナだけなので、ホスト側の自前のtailnetはつないだまま使える(切り替えも不要)
  • プロキシを指定したアプリ(ブラウザやcurl、Claude Codeなど)だけがステージング環境にアクセスする

その前に: 「Allow local network access」で済むならそれが一番簡単

実はTailscaleには、exit-node利用時にLAN宛ての通信だけを直接流すオプションがあります。取引先のtailnetしか使わない端末で、問題2だけが困りごとなら、これで解決するので以降の作業は不要です。

  • Windows / macOS: トレイ(メニューバー)のTailscaleアイコン → Exit Node → 「Allow local network access」をオン

  • Linux:

    $ sudo tailscale set --exit-node=<exit-node> --exit-node-allow-lan-access=true
    
  • ただし注意点もあります

    • 対象になるのは端末が直接つながっているサブネットだけなので、ルーター越しの別セグメントには効かない場合があります
    • 取引先のセキュリティポリシーで、この設定の併用が認められているかも確認しておいた方がよいでしょう

ただ、このオプションで解決するのは問題2だけで、問題1(自前のtailnetと同時に使えない)はそのまま残ります。自分の場合は自前のtailnetから抜けたくなかったのと、端末全体の通信を取引先のexit-node経由にもしたくなかったので、次のコンテナ方式を採用しました。

作業環境

  • 開発用サーバー: Ubuntu 26.04.1 LTS / Docker 29.1.3
  • Tailscaleイメージ: tailscale/tailscale(Tailscale 1.102.5)

作業手順

1. コンテナを起動して取引先アカウントでログインする

  • まずはTailscaleのコンテナを、userspace networkingモード + SOCKS5プロキシ付きで起動します

    $ docker run -d --name ts-staging \
        -e TS_USERSPACE=true \
        -e TS_SOCKS5_SERVER=0.0.0.0:1055 \
        -e TS_STATE_DIR=/var/lib/tailscale \
        -v ts-staging-state:/var/lib/tailscale \
        -p 127.0.0.1:1055:1055 \
        tailscale/tailscale
    
    • 環境変数の意味は以下のとおりです
      環境変数意味
      TS_USERSPACE=trueTUNデバイスを使わないuserspace networkingモード。ホストのルーティングに影響しない
      TS_SOCKS5_SERVERコンテナ内でSOCKS5プロキシを待ち受けるアドレス
      TS_STATE_DIR認証情報などの保存先。名前付きボリュームに永続化する
  • 起動したら、ログから認証URLを探します

    $ docker logs -f ts-staging          # "To authenticate, visit:" の行を待つ
    $ docker exec ts-staging tailscale status   # 未ログインならURLが表示される
    
    • ハマりどころ: 起動直後にログを見ると RegisterReq の行で止まっていて、認証URLがどこにも見当たらない……ということがありました
      • これはコントロールサーバーの応答待ちらしく、少し待てば普通に出てきます。焦って再起動したりしなくて大丈夫です
  • 表示されたURLをブラウザで開き、取引先から指定されたアカウントでログインします

2. exit-nodeを設定する

  • ログインできたら、まずは使えるexit-nodeの一覧を確認します

    $ docker exec ts-staging tailscale exit-node list
     IP                HOSTNAME                                COUNTRY     CITY      STATUS
     100.x.y.z         oreno-exit-node.tailxxxx.ts.net         -           -         -
    (中略)
    # To use an exit node, use `tailscale set --exit-node=` followed by the IP or hostname.
    
  • 一覧に出てきたexit-nodeを指定します

    $ docker exec ts-staging tailscale set --exit-node=oreno-exit-node --exit-node-allow-lan-access
    
    • --exit-node には、一覧の IP列(tailnet上のIP 100.x.y.z)かHOSTNAME列(ノード名)のどちらを指定してもOK です(出力の最後にもそう書いてあります)

      • 自分はノード名で指定することにしました。IPだとあとで設定を見返したときに「これどのノードだっけ?」となりがちですが、ノード名なら一目で分かるので、管理する側としては楽です
      • ノード名は oreno-exit-node のような短い名前だけでも、.ts.net まで含めた完全な名前でも通ります
    • ハマりどころ: 取引先から伝えられていたのが「出口のグローバルIP」だったので、最初はそれを --exit-node に指定していました

      • 上のとおり、ここに指定するのはtailnet上のIPかノード名で、グローバルIPではありません。当たり前といえば当たり前なのですが、IP制限の話と混ざって見事に勘違いしていました
  • この時点で、開発サーバー上のFirefoxでSOCKS5 localhost:1055 を指定すれば、ステージング環境にアクセスできることを確認できました

3. 作業用PCから使う(SSHトンネル)

  • 次に作業用PCのFirefoxから使おうとして、プロキシに 192.168.x.x:1055 を指定してみたのですが、つながりませんでした

    • 原因は -p 127.0.0.1:1055:1055 で、1055番ポートがサーバーのループバックでしか待ち受けていなかったためです
  • -p 0.0.0.0:1055:1055 にすれば接続自体はできます。が、これはやめておいた方がよいです

    • このSOCKS5プロキシには認証が無いので、LAN内の誰でも取引先ネットワークへの踏み台にできる状態になってしまいます
    • しかも、Dockerが公開したポートはiptablesを直接操作するので、ufwで制限しても効かないことがあります
  • というわけで、ポートはループバックのままにして、SSHトンネルで転送することにしました

    $ ssh -N -L 1055:localhost:1055 orenoaccount@192.168.x.x
    
    • 毎回打つのは面倒なので、~/.ssh/config に書いておくと便利です

      Host dev-server
        HostName 192.168.x.x
        User orenoaccount
        LocalForward 1055 localhost:1055
      
  • Firefox側の設定は以下のとおりです

    • SOCKSホスト: localhost、ポート: 1055
    • SOCKS v5
    • 「SOCKS v5 を使用するときは DNS もプロキシーを使用する」にチェック
    • 普段使いのブラウジングと分けたい場合は、別プロファイルを作るか、FoxyProxyなどでドメインごとに振り分けるとよいかと思います

これで、作業用PC(Windows 11)のFirefoxからだけ、取引先のステージング環境にアクセスできるようになりました。 もちろん、作業用PC側は自前のtailnetにつないだままなので、自前のtailnet経由でのサーバー操作やLAN内の開発サーバーへのアクセスも、これまでどおり両立できています。

4. Claude Codeからも使う(ここが本題かも)

  • 正直なところ、ここまでのブラウザでの確認は疎通確認くらいの意味合いでした

  • 本当に効いたのは、開発サーバー上で動かしているClaude Codeに**「localhost:1055 にSOCKS5プロキシがあって、そこ経由でステージング環境に届く」と伝えた**ことです

    • Claude Codeは開発サーバー上で動いているので、SSHトンネルも要らず、localhost:1055 をそのまま使えます
    • 後は、Claude Codeが必要に応じて自分でプロキシ経由のコマンドを組み立ててくれます
    $ curl --proxy socks5h://127.0.0.1:1055 https://stg.example.com/admin/login
    
    • socks5h の h は「名前解決もプロキシ側でやる」という意味です(Firefoxの「DNSもプロキシーを使用する」と同じ)。socks5:// だと名前解決だけ手元で行われてしまうので、socks5h:// にしておくのが無難です
  • 実際にやってもらったこと

    • ステージング環境の管理画面にログインして、まだ確認できていなかった画面のHTMLを取得
    • 画面の入力項目を全部洗い出して、手元で開発中の新しい実装やDBスキーマと突き合わせ、足りない項目や扱いが決まっていない項目を整理
    • 取引先への確認事項の文面まで作成
  • それまでは、ステージングの画面を自分でブラウザで開いて、DOMをコピーしてClaudeに渡していました

    • 画面が多いとこれが地味に大変で、しかも渡し漏れがあると、Claudeは推測で話を進めるしかありません
    • 「実物を見に行ける」ようになったことで、推測ではなく実際の画面に基づいて作業を進めてもらえるようになったのが大きいです
  • ただし、以下は気をつけた方がよいと思います

    • ログイン情報は、画面やファイルに書き出さずに変数で渡すように指示しておく(今回はClaude Codeが自主的にそうしてくれましたが、明示しておく方が安心です)
    • 作業が終わったらログアウトし、Cookieファイルなども消させる
    • アクセスしてよいのはステージング環境だけで、本番環境には向けない
    • そもそも取引先のポリシー上、AIエージェント経由でのアクセスが許されるかは事前に確認しておく
    • 毎回説明するのが面倒なら、プロジェクトの CLAUDE.md にプロキシの存在と上記のルールを書いておくとよいでしょう

運用メモ

再起動のたびに再認証が必要?

  • 不要です。認証情報は名前付きボリューム ts-staging-state に保存されるので、以下の操作ではログイン状態が維持されます

    • docker restart / stop / start
    • ホストの再起動
    • 同じボリュームを指定してのコンテナ再作成
  • 逆に、以下のような場合は再認証が必要になります

    • ボリュームを削除した(docker volume rm、docker system prune --volumes など)
    • ノードキーの有効期限が切れた(デフォルトは約180日。管理者が「Disable key expiry」を設定すれば無期限)
    • tailnetの管理者がデバイスを削除・無効化した

ポートやネットワーク設定を変えたい

  • -p やネットワーク設定はコンテナ作成時に固定されるので、docker restart や docker update では変更できません
    • 変更するにはコンテナを作り直す必要がありますが、認証はボリュームに残っているので、手間はほとんどかかりません

TS_EXTRA_ARGS にexit-nodeを書くと起動に失敗する

  • 起動のたびに tailscale set を打つのも面倒なので、TS_EXTRA_ARGS="--exit-node=oreno-exit-node ..." で起動時に指定しようとしたところ、「ノードが見つからない」というエラーでコンテナが停止してしまいました

    • 一方、起動後に tailscale set で指定すると問題なく通ります
  • 原因はおそらく、containerbootが起動時に tailscale up を実行する時点では、まだピア一覧(netmap)を受け取れていないためです

    • ノード名でもtailnet上のIPでも、その時点では「存在しないノード」と判定されてしまうのだと思われます(ソースまでは追えていないので推測です)
  • そこで、エントリポイントを差し替えて、containerbootをバックグラウンドで起動したあと、tailscale set が成功するまでリトライする形にしました

最終的な起動コマンド

$ docker run -d --name ts-staging \
    --restart unless-stopped \
    -e TS_USERSPACE=true \
    -e TS_SOCKS5_SERVER=0.0.0.0:1055 \
    -e TS_AUTH_ONCE=true \
    -e TS_STATE_DIR=/var/lib/tailscale \
    -v ts-staging-state:/var/lib/tailscale \
    -p 127.0.0.1:1055:1055 \
    --entrypoint sh \
    tailscale/tailscale -c '
      /usr/local/bin/containerboot &
      until tailscale --socket=/tmp/tailscaled.sock set \
            --exit-node=oreno-exit-node --exit-node-allow-lan-access 2>/dev/null; do
        sleep 2
      done
      echo "exit-node configured"
      wait'
  • --restart unless-stopped: ホスト再起動時に自動起動する

  • TS_AUTH_ONCE=true: ログイン済みなら tailscale up を再実行しない

  • --socket=/tmp/tailscaled.sock: containerbootが起動するtailscaledのソケットの場所です

    • 起動後は通常の場所(/var/run/tailscale/tailscaled.sock)にもシンボリックリンクが張られるので、docker exec で打つぶんには省略しても動きますが、起動直後のリトライではリンクがまだ無い可能性があるので明示しています
  • ログに exit-node configured が出れば準備完了です

  • containerbootのパスはイメージのバージョンによって変わる可能性があります。起動しない場合は以下で確認してみてください

    $ docker inspect tailscale/tailscale --format '{{.Config.Entrypoint}}'
    

まとめ

  • Tailscaleの公式クライアントは同時に1つのtailnetにしかつなげないので、もう1つのtailnetはコンテナ側に参加させると、切り替えなしで両立できる
  • exit-nodeでLANが使えなくなる問題は、まず「Allow local network access」を試す
  • 端末全体を取引先の経路に乗せたくない場合は、Docker + userspace networking + SOCKS5で、プロキシを指定したアプリだけを取引先のexit-node経由にできる
  • そのプロキシをClaude Codeにも教えると、ステージング環境の実物を見ながら作業してもらえるようになり、開発体験が大きく変わる
  • --exit-node に指定するのは、グローバルIPではなくtailnet上のIPかノード名(見返したときに分かりやすいのでノード名がオススメ)
  • SOCKS5プロキシはLANに公開せず、ループバックで待ち受けてSSHトンネルで使う
  • 起動時のexit-node指定は、tailscale set をリトライするラッパーで確実に適用する

VMを用意するよりずっと軽量ですし、自前のtailnetにつないだまま使えるので、複数の取引先環境を扱う場合にもオススメです(取引先ごとにコンテナとポートを分ければ、いくつでも並べられるはずです)。

この記事は qiita.com にも掲載しています。