lenec ru

← все посты

Go embed: встраиваем файлы в бинарник — статика, шаблоны, миграции

12K

Деплой Go-сервиса — это один бинарник. Никаких зависимостей, никакого рантайма. Но что делать со статикой, шаблонами и SQL-миграциями? Раньше приходилось таскать файлы рядом с бинарником или использовать сторонние генераторы вроде go-bindata. С Go 1.16 появился пакет embed — встраивание файлов прямо в бинарник на этапе компиляции.

Директива //go:embed: синтаксис и паттерны

Базовый синтаксис — комментарий-директива перед переменной:

package main

import "embed"

// Один файл как строка
//go:embed version.txt
var version string

// Один файл как байты
//go:embed config.json
var configBytes []byte

// Целая директория как файловая система
//go:embed static/*
var staticFS embed.FS

// Несколько паттернов
//go:embed templates/*.html templates/partials/*.html
var templatesFS embed.FS

Правила паттернов:

  • * — все файлы в директории (без поддиректорий)
  • ** — не поддерживается, используйте dir (вся директория рекурсивно)
  • Скрытые файлы (начинающиеся с . или _) исключаются по умолчанию — добавьте all: префикс: //go:embed all:static
  • Переменная должна быть типа string, []byte или embed.FS

Встраиваем статику: HTTP-сервер

Самый частый кейс — раздача фронтенда из Go-бинарника:

package main

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

//go:embed static
var staticFS embed.FS

func main() {
    // embed.FS содержит путь "static/..." — убираем префикс
    sub, _ := fs.Sub(staticFS, "static")
    fileServer := http.FileServer(http.FS(sub))

    http.Handle("/", fileServer)
    http.ListenAndServe(":8080", nil)
}

Важный момент: embed.FS сохраняет структуру директорий относительно Go-файла. Если файлы лежат в static/css/app.css, то в FS они будут по пути static/css/app.css. Используйте fs.Sub чтобы убрать лишний префикс.

Встраиваем SQL-миграции

Библиотеки миграций (goose, golang-migrate) поддерживают embed.FS из коробки:

package migrations

import (
    "embed"

    "github.com/pressly/goose/v3"
)

//go:embed sql/*.sql
var Migrations embed.FS

func Run(db *sql.DB) error {
    goose.SetBaseFS(Migrations)

    if err := goose.SetDialect("postgres"); err != nil {
        return err
    }

    return goose.Up(db, "sql")
}

Теперь миграции вшиты в бинарник. При деплое не нужно копировать папку sql/ — всё внутри. Откат тоже работает: goose.Down(db, "sql").

Встраиваем шаблоны: html/template + embed

Шаблоны с наследованием и partials:

package web

import (
    "embed"
    "html/template"
    "io"
)

//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS

var templates *template.Template

func init() {
    templates = template.Must(
        template.ParseFS(templateFS,
            "templates/*.html",
            "templates/partials/*.html",
        ),
    )
}

func RenderPage(w io.Writer, name string, data any) error {
    return templates.ExecuteTemplate(w, name, data)
}

Преимущество перед template.ParseGlob: файлы читаются из embed.FS, а не с диска. Бинарник самодостаточен.

Ограничения

  • Размер бинарника. Всё встроенное увеличивает бинарник 1:1. 50 МБ статики = +50 МБ к бинарнику. Для больших ассетов лучше CDN.
  • Нет symlinks. Символические ссылки игнорируются — только реальные файлы.
  • Только чтение. embed.FS — read-only. Нельзя записать файл в embedded FS.
  • Относительные пути. Паттерн в директиве — относительно Go-файла с директивой. Нельзя использовать .. или абсолютные пути.
  • Build tags. Можно условно встраивать разные файлы через build tags — полезно для dev/prod:
// embed_prod.go
//go:build !dev

package assets

//go:embed dist/*
var StaticFS embed.FS

// embed_dev.go
//go:build dev

package assets

import "os"

// В dev-режиме читаем с диска для hot-reload
var StaticFS = os.DirFS("./dist")

Практика: Go HTTP-сервер с embedded SPA

Полный пример — API + встроенный React/Vue SPA с fallback на index.html для client-side routing:

package main

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

//go:embed ui/dist
var uiFS embed.FS

func main() {
    sub, _ := fs.Sub(uiFS, "ui/dist")

    // API routes
    mux := http.NewServeMux()
    mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"status":"ok"}`))
    })

    // SPA fallback: если файл не найден — отдаём index.html
    mux.HandleFunc("/", spaHandler(sub))

    http.ListenAndServe(":8080", mux)
}

func spaHandler(assets fs.FS) http.HandlerFunc {
    fileServer := http.FileServer(http.FS(assets))
    return func(w http.ResponseWriter, r *http.Request) {
        path := strings.TrimPrefix(r.URL.Path, "/")
        // Проверяем, существует ли файл
        if _, err := fs.Stat(assets, path); err != nil {
            // Файл не найден — отдаём index.html (SPA routing)
            r.URL.Path = "/"
        }
        fileServer.ServeHTTP(w, r)
    }
}

Сборка и деплой:

# Собираем фронтенд
cd ui && npm run build && cd ..

# Собираем Go-бинарник со встроенным SPA
go build -o server .

# Один файл — весь сервис
./server
# API: http://localhost:8080/api/health
# SPA: http://localhost:8080/

Результат: один бинарник, который содержит и API, и фронтенд. Деплой — скопировать файл и запустить. Docker-образ можно собрать на базе scratch или distroless — ничего кроме бинарника не нужно.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.