🐈

PaaSに「VPN機能付きGoバイナリ」をポン置きする技術 (Tailscale / tsnet)

に公開

RailwayやFly.ioなどのPaaSは、OSの設定を意識せずに手軽にデプロイできるため、内部向けのWebアプリ運用において非常に便利です。

しかし、社内ツールなどを運用する場合、認証やIPアドレス制限といった「セキュリティ」が課題になります。私のチームではTailscaleを利用してVPNによるアクセス制限を行っていますが、PaaS環境とTailscaleの組み合わせには特有の難しさがあります。

本記事では、Go言語のライブラリである tsnet を使い、PaaS上に「VPN機能を内包した単一バイナリ」をデプロイするという、シンプルかつ強力な解決策を紹介します。

PaaS × Tailscale の課題

通常、サーバーをTailscaleネットワークに参加させる場合、ホストOSにTailscaleクライアント(デーモン)をインストールします。しかし、PaaS環境では以下の理由からこれが困難です。

  • OSレベルの操作制限: そもそもOSにログインしてインストール作業ができない。
  • 特権権限の欠如: root 権限や、ネットワークインターフェース(TUNデバイス)の作成権限がないことが多い。

これらを解決するために「Subnet Router」を用意したり、コンテナに Tailscale を同居させる方法もありますが、構成が複雑になりがちです。

もっとシンプルに、「アプリのバイナリを1つ置くだけ」 で解決する方法があります。それが tsnet です。

tsnet とは何か?

tsnet は、Tailscaleが公式に提供しているGo言語用ライブラリです。

  • 特徴: Goのプロセス自体が「Tailscaleノード」として振る舞います。
  • メリット: OSのroot権限やTUNデバイスを必要とせず、ユーザー空間(Userland)だけで動作します。

つまり、OS側にTailscaleをインストールする必要はなく、Goのコード内で完結します。これにより、PaaSのような制約の多い環境でも、環境変数さえ渡せればTailscaleネットワークに参加できるようになります。

活用例:PocketBase × tsnet

私がよく利用する PocketBase(Go製のBaaS)は、この構成と相性が抜群です。
PocketBaseを使えばフロントエンドもバックエンドも1つのGoバイナリにまとめることができますが、そこに tsnet を組み込むことで、「外部からは一切アクセスできないが、社内VPNからは繋がるフルスタックアプリ」 を、バイナリ一つをPaaSに置くだけで実現できます。

実装:GoコードにTailscaleを組み込む

使い方は非常にシンプルです。標準の net/http を使う感覚で実装できます。

  1. tsnet.Server を作成する
  2. Server.Listen() でリスナーを取得する
  3. http.Serve() にそのリスナーを渡す

以下は、"Hello from tsnet" を返すだけの最小構成のサンプルコードです。

package main

import (
	"fmt"
	"log"
	"net/http"
	"os"

	"tailscale.com/tsnet"
)

func main() {
	srv := &tsnet.Server{
		Hostname: "my-tsnet-app", // Tailscale用のホスト名
	}
	defer srv.Close()

	// Tailscaleネットワーク上でのみアクセス可能なリクエストを待ち受ける
	ln, err := srv.Listen("tcp", ":80")
	if err != nil {
		log.Fatal(err)
	}
	defer ln.Close()

	// ハンドラの設定
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello from tsnet!")
	})

	log.Printf("listening on tailnet at http://%s", srv.Hostname)
	
	// http.ListenAndServe ではなく、tsnetのリスナーを使って Serve する
	log.Fatal(http.Serve(ln, mux))
}

ポイントは、http.ListenAndServe の代わりに、tsnetが提供する ln (net.Listener) を使っている点だけです。これだけで、通信経路はTailscaleによって暗号化・保護され、コードの中身は通常のWebサーバー実装のまま維持できます。

動作確認

まずはローカルで動かしてみましょう。環境変数が設定されていない場合、初回起動時に認証用URLが表示されます。

go run .
...
2025/11/24 18:58:56 To start this tsnet server, restart with TS_AUTHKEY set, or go to: https://login.tailscale.com/a/10e01b23345bc

ターミナルに表示されたURLへブラウザでアクセスし、認証を行います。成功すると、TailscaleのAdmin Consoleに my-tsnet-app というデバイスが追加されます。
別のTailscale参加端末から、MagicDNS(例: http://my-tsnet-app)でアクセスしてレスポンスが返ってくれば成功です。

curl http://my-tsnet-app
Hello from tsnet!

PaaSへのデプロイ

デプロイは標準的なGoのDockerfileでOKです。環境変数 TS_AUTHKEY にキーを渡すだけで動作しますが、データの永続化設定によって挙動が異なります。用途に合わせて使い分けてください。

1. Ephemeral(使い捨て)運用

最も手軽な方法です。デプロイのたびに新しいデバイスとして認識され、IPも変わります。

  • 設定: Auth Keyを Ephemeral (使い捨て) に設定。
  • コード: Dir 指定なし。

2. Stateful(永続化)運用

IPアドレスを固定したい場合に利用します。

  • 設定: PaaS側でVolumeを作成し(例: /data)、コンテナにマウント。
  • コード: Dir にマウントしたパスを指定。
srv := &tsnet.Server{
    Hostname: "my-tsnet-app",
    AuthKey:  os.Getenv("TS_AUTHKEY"),
    Dir:      "/data/tsnet", 
}

Dir を指定すると、初回認証後のトークンがそこにキャッシュされるため、2回目以降は TS_AUTHKEY が無効になっても再接続が可能になります

まとめ

tsnet を利用することで、PaaSの便利なデプロイ体験を損なうことなく、セキュアな内部向けアプリケーションを構築できます。

  • OS設定不要、サイドカー不要
  • 環境変数を一つ足すだけでVPNに参加
  • Goバイナリ一つで完結するポータビリティ

社内ツールの置き場所に困っているGoエンジニアの方は、ぜひ試してみてください。

Discussion