Go embed: встраиваем файлы в бинарник — статика, шаблоны, миграции
Деплой 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 — ничего кроме бинарника не нужно.