Goのembedで作るSPA配信サーバー—all:とfs.Subで1バイナリ化する

Goのembedで作るSPA配信サーバー—all:とfs.Subで1バイナリ化する | mohablog

結論から書くと、SPAをGoのバイナリに同梱するときに押さえるべきは all: 接頭辞fs.Subindex.html フォールバックの3つだけです。逆に言うと、この3つを知らないとReact Routerの直接URLアクセスで404を踏んだり、Viteが吐く .vite/ や Next.js の _next/ が配信されずにJSが読み込めない、という地味なハマり方をします。Go 1.26.2 標準ライブラリの embed を前提に、最小構成のサンプルと落とし穴を整理してみました。

目次

バイナリ同梱で何が嬉しいのか

そもそも net/http はディスク上の ./dist をそのまま配ることもできます。それでも embed でバイナリに焼き込む価値があるのは、運用の事故が目に見えて減るからです。

デプロイ時のバージョンずれが消える

SPAビルド成果物とAPIサーバーを別々にアップロードすると、フロントの index.html だけ新しい・APIは古い、という瞬間が必ず生まれます。リクエストペイロードのスキーマが片側だけ変わっていると、ユーザーから「画面が真っ白で動かない」という問い合わせが飛んできます。embed で1バイナリにすれば、scp 一発でアトミックに切り替わるのでこの種のズレは原理的に発生しません。

scpやrsyncが楽になる

SPAアセット込みで20MB前後のバイナリ1つを scp app prod:/usr/local/bin/ で送って終わり、というのは想像以上に楽です。Dockerfile もマルチステージで FROM scratch + COPY --from=build /app /app の数行で済みます。さらに、embed はコンパイル時にパスが解決されるので、./dist../frontend/dist のどちらが正か毎回確認するワーキングディレクトリ依存のバグも原理的に出なくなります。

//go:embed の基本と blank import の罠

公式パッケージドキュメント (pkg.go.dev/embed) の「Directives」セクションによると、//go:embed で受け取れる型は3つだけです。

用途備考
string単一テキストファイルUTF-8として保持
[]byte単一バイナリファイル画像・PDFなど
embed.FS複数ファイル/ディレクトリio/fs.FS を実装

blank importを忘れない

意外と踏みやすいのが、string[]byte で受け取る場合でもファイル先頭で import _ "embed" が必要、という制約です。これを忘れると次のエラーが出ます。

package main

import "fmt"

//go:embed version.txt
var version string

func main() { fmt.Println(version) }
$ go build .
./main.go:6:3: //go:embed only allowed in Go files that import "embed"

embed.FS を使うときは import "embed"(blankなし)で済みますが、string/[]byte 用途では使い道がない側のパッケージを _ で読ませる必要があります。「embed を直接使ってないのに import するの気持ち悪い」と感じても、ここは仕様なので諦めましょう。

ディレクトリ全体は embed.FS で取る

SPA配信用途では実質 embed.FS 一択になります。ディレクトリツリーごと取り込めて、しかも net/httphtml/template がそのまま受け付ける io/fs.FS インターフェースを満たしています。

package frontend

import "embed"

//go:embed dist
var DistFS embed.FS

これで dist 配下のすべてが入る、と思いきや、ここに最初の落とし穴があります。

落とし穴その1—all:を忘れると.vite/が消える

embed はデフォルトで . または _ で始まるファイル・ディレクトリを除外します。これが意外と厄介で、モダンなフロントエンドのビルド成果物にぶつかります。

Viteの.vite/とNext.jsの_next/

Vite は dist/.vite/manifest.json、Next.js のSSG出力は out/_next/static/... というパスを生成します。素の //go:embed dist ではこれらが含まれません。実際にビルドしてみると、ブラウザのコンソールに次のような404が並びます。

GET https://example.com/_next/static/chunks/main-abc123.js 404 (Not Found)
GET https://example.com/_next/static/css/app.css 404 (Not Found)
Uncaught SyntaxError: Unexpected token '<' (at main-abc123.js:1:1)

「JSのところに '<' って何?」のお決まりパターンで、原因はだいたい _next ディレクトリがバイナリに含まれていないことです。

all:接頭辞で隠しディレクトリも取り込む

公式ドキュメントの「Patterns」項に書かれているとおり、all: 接頭辞を付けるとアンダースコア・ドット始まりも含まれるようになります。

package frontend

import "embed"

//go:embed all:dist
var DistFS embed.FS

これだけで dist/.vite/manifest.jsondist/_next/static/... も含まれます。Vue + Vite 構成で初回これに気づかず、コンソールが赤いまま30分悩んだことがあります。

それでも含まれないもの

公式ドキュメントによれば、all: を付けても以下は含まれません。

  • vendor/ ディレクトリ
  • .git/ など Git の管理ディレクトリ
  • サブディレクトリ内に go.mod があるもの(別モジュール扱い)
  • シンボリックリンク(リンク先がモジュール外を指す可能性があるため)

ビルド成果物が dist/vendor 配下に置かれるような変則構成だと事故るので、自前で吐いた成果物のパスは事前に ls -la で覗いておくのが安全です。

落とし穴その2—fs.Subでルートを繰り上げる

//go:embed all:dist と書いた場合、embed.FS 内のパスは dist/index.html のように先頭に dist/ が付いたままです。これを意識せず http.FileServer に渡すと、ブラウザからのリクエストパス /index.htmlindex.html を探しに行き、実体は dist/index.html なので404になります。

NGパターン—embed.FSを直接渡す

// これは動かない
http.Handle("/", http.FileServer(http.FS(frontend.DistFS)))
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/
404

OKパターン—fs.Subでサブツリーを取り出す

io/fs パッケージの Sub 関数は func Sub(fsys FS, dir string) (FS, error) というシグネチャで、サブディレクトリをルートと見なした新しい FS を返してくれます。

import (
    "io/fs"
    "net/http"
)

func DistHTTPFS() http.FileSystem {
    sub, err := fs.Sub(frontend.DistFS, "dist")
    if err != nil {
        panic(err) // コンパイル時にパスを書いているので、実行時に失敗するなら設定ミス
    }
    return http.FS(sub)
}

func main() {
    http.Handle("/", http.FileServer(DistHTTPFS()))
    http.ListenAndServe(":8080", nil)
}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/
200
$ curl -s http://localhost:8080/index.html | head -1

ちなみに、fs.SubSubFS インターフェースを実装している型の場合だけ最適化された実装を呼び、それ以外は path.Join(dir, name) でパスを書き換えるラッパーを返す、という挙動です。embed.FSSubFS を実装しているので、軽量に動きます。

落とし穴その3—SPAルーティングのフォールバック

React Router・Vue Router・SvelteKit のようなクライアントサイドルーティング前提のSPAでは、/users/123 をブラウザで直接叩かれた場合に index.html を返してJSに処理を渡す必要があります。標準の http.FileServer はファイルがなければ素直に404を返すので、ここに自前のロジックが必要です。

アンチパターン—すべてindex.htmlを返す

雑にすべてのリクエストで index.html を返す実装にすると、本物の静的アセットも index.html に置き換えられます。

// やってはいけない
func spaHandler(w http.ResponseWriter, r *http.Request) {
    b, _ := fs.ReadFile(frontend.DistFS, "dist/index.html")
    w.Write(b)
}

こうすると /assets/main-abc.js へのリクエストもHTMLが返るので、ブラウザに Uncaught SyntaxError: Unexpected token '<' がまた出ます。「困ったら全部 index.html」は罠です。

正しい実装—Openでファイル存在を確認する

パスが embed.FS 上に存在するかを Open で確認し、なければ index.html を返す、という分岐を噛ませます。エラー判定は errors.Is(err, fs.ErrNotExist) で行います。

package main

import (
    "errors"
    "io/fs"
    "net/http"
    "path"
    "strings"
)

func spaHandler(distFS fs.FS) http.Handler {
    fileServer := http.FileServer(http.FS(distFS))
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 先頭の / を取り除いて fs 上のパスに変換
        reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
        if reqPath == "" {
            reqPath = "index.html"
        }

        f, err := distFS.Open(reqPath)
        if errors.Is(err, fs.ErrNotExist) {
            // クライアントサイドルーティング用のフォールバック
            r.URL.Path = "/"
            fileServer.ServeHTTP(w, r)
            return
        }
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        f.Close()
        fileServer.ServeHTTP(w, r)
    })
}

動作確認の例です。

$ curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" \
    http://localhost:8080/ \
    http://localhost:8080/assets/main-abc.js \
    http://localhost:8080/users/123 \
    http://localhost:8080/api/healthz
200 http://localhost:8080/
200 http://localhost:8080/assets/main-abc.js
200 http://localhost:8080/users/123     # index.htmlにフォールバック
404 http://localhost:8080/api/healthz   # APIは別ハンドラに振る

/api 配下はSPAではなくAPIサーバー側のハンドラに振りたいので、http.ServeMux 側で mux.Handle("/api/", apiHandler) を先に登録し、SPA用ハンドラは mux.Handle("/", spaHandler(distFS)) として最後に登録するのが定番です。ServeMux はより長いパスを優先する仕様なので、これでうまく分離できます。

最小構成サンプル—main.go全体

ここまでをひとつの main.go にまとめると、APIエンドポイント1本+SPA配信で50行ちょっとに収まります。

package main

import (
    "embed"
    "encoding/json"
    "errors"
    "io/fs"
    "log"
    "net/http"
    "path"
    "strings"
)

//go:embed all:dist
var distFS embed.FS

func main() {
    sub, err := fs.Sub(distFS, "dist")
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/api/healthz", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })
    mux.Handle("/", spaHandler(sub))

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

func spaHandler(distFS fs.FS) http.Handler {
    fileServer := http.FileServer(http.FS(distFS))
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
        if reqPath == "" {
            reqPath = "index.html"
        }
        if f, err := distFS.Open(reqPath); errors.Is(err, fs.ErrNotExist) {
            r.URL.Path = "/"
        } else if err == nil {
            f.Close()
        }
        fileServer.ServeHTTP(w, r)
    })
}

ビルドして実行するとこんな感じです。

$ npm run build --prefix frontend  # dist/ を生成
$ go build -o spa-server .
$ ls -lah spa-server
-rwxr-xr-x  1 user  staff   18M May  5 22:14 spa-server
$ ./spa-server
2026/05/05 22:14:32 listening on :8080

18MB前後のバイナリ1つで全部入り、というのは本当に運用がラクです。CIで go build したアーティファクトをそのまま配って終わりにできます。

本番に出す前に押さえる2点—キャッシュとgzip

標準実装でも動きますが、本番環境に置くなら最低限の最適化は入れておきたいところです。

ETagが効かない問題

http.FileServer はファイルの ModTimeLast-Modified ヘッダに使います。ところが、embed.FS が返す ModTime はゼロ値(0001-01-01 00:00:00 UTC)で固定です。これだとブラウザの If-Modified-Since が機能しません。

$ curl -sI http://localhost:8080/assets/main-abc.js | grep -i last-modified
Last-Modified: Mon, 01 Jan 0001 00:00:00 GMT

対応としては、ビルド時のコミットハッシュを ETag として付与するミドルウェアを噛ませるのが楽です。Vite/Next.js ともにファイル名にハッシュが入る前提のビルド設定なので、Cache-Control: public, max-age=31536000, immutable/assets/ 配下にだけ付ける運用も多いと思います。

gzip圧縮はビルド時に済ませる

embed.FS は素のバイト列を持つので、ランタイムでgzipするとリクエストごとにCPUを食います。Vite なら vite-plugin-compression.gz 済みファイルをビルド時に作り、all:dist でまとめて取り込む方が現実的です。送出側では Accept-Encoding: gzip を見て .gz ファイルを返します。

このあたりのリクエスト前後処理をどう構造化するかは、Goのミドルウェアパターンを理解する—net/httpで柔軟なリクエスト処理を実装する方法 を読んでおくと http.Handler をラップして層を重ねる感覚がつかめます。

まとめ

  • //go:embed all:dist で Vite/Next.js の隠しディレクトリ(.vite/_next/)も取り込む
  • fs.Sub でサブディレクトリをFSのルートに繰り上げて、http.FileServer に正しいパスで認識させる
  • SPAルーティングは fs.ErrNotExist 判定index.html にフォールバックする。すべて index.html を返す雑実装はJS配信を壊すのでやらない
  • APIとの分離は http.ServeMux の最長一致ルールで /api/ を先に登録する
  • embed.FSModTime はゼロ値なので、キャッシュは自前でヘッダを付けないと効かない
  • gzip はランタイム圧縮ではなくビルド時に .gz を吐かせて配るのが現実的

SPA配信は net/http の組み合わせ問題で、ライブラリを増やすより標準ライブラリの挙動を理解した方が結局早いことが多いです。Echo や Fiber の HTML5 モードを使えば fs.ErrNotExist 判定も内蔵されていますが、自前で書ける範囲で済ませた方がデバッグも追いやすいと思います。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次