Содержание урока · 13 разделов
- деплой gin: что меняется между ноутбуком и продом
- Сборка: CGO_ENABLED=0 и линковка версии
- Конфигурация окружением: секреты не в образе
- Dockerfile: multi-stage и ничего лишнего
- Таймауты http.Server: голый Run не для прода
- Graceful shutdown: SIGTERM как приглашение
- Health-чеки: liveness, readiness и правда о процессе
- Прод-чеклист: настройка и её значение
- Грабли: то, что роняет релизы
- Шесть правил прод-сервера gin
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
деплой gin: что меняется между ноутбуком и продом#
Go — редкий язык, где деплой может быть тривиальным: компилятор выдаёт единственный статический бинарник без рантайма, интерпретатора и зависимостей, и этот файл копируется на любую Linux-машину с той же архитектурой. Но тривиальность обманчива: вокруг бинарника живёт целая дисциплина. Конфигурация должна приходить из окружения, а не из файлов в репозитории — иначе один образ на стейджинг и прод не собрать. Процесс должен сообщать о здоровье: балансировщик и оркестратор не телепаты, им нужны эндпоинты /healthz и /readyz. Остановка должна быть graceful: SIGTERM приходит всегда, и обрыв 500 активных запросов — это ваши 500 ошибок в метриках и разъярённые клиенты. Логи идут в stdout структурными строками — их собирает платформа, а не ваши ротоции на диске. Всё это называют двенадцатью факторами: конфиг из окружения, одинаковые релизы, логи как поток событий, порт из переменной. Хороший gin-проект соответствует им ещё на этапе кода: конфиг-пакет из урока про слои читает окружение, main собирает граф и стартует http.Server с таймаутами — дальше остаётся только обернуть в образ.
Сборка: CGO_ENABLED=0 и линковка версии#
Прод-сборка gin-сервиса — одна команда, но с тремя важными флагами. CGO_ENABLED=0 выключает cgo: бинарник становится полностью статическим, без ссылки на системную libc, и запускается где угодно — от scratch-контейнера до alpine с другим glibc; бонусом включается нативный DNS-резолвер Go, поведение которого одинаково везде. GOOS=linux GOARCH=amd64 — кросс-компиляция с MacBook на прод-архитектуру прямо в CI. -ldflags "-s -w" вырезает таблицу символов и debug-информацию: минус треть размера, а отладка в проде всё равно идёт по логам и pprof. Четвёртый приём — вшить версию: пакетная переменная var version = "dev" в main подменяется при сборке через -X main.version=1.4.2, и /healthz отдаёт точный коммит, по которому собран процесс. Разница между «у вас там старая версия» и «у нас версия 1.4.2 от такого-то хеша» — это десятки минут дебага. Отдельно про размер: чистый gin-сервис весит 10-15 мегабайт — против сотен мегабайт рантаймов других экосистем; образ на базе scratch или distroless добавляет к этому ноль уязвимостей от шелла и пакетных менеджеров, которых там просто нет.
Одна команда релиза
GO// cmd/server/main.go
var version = "dev"
func main() {
log.Printf("starting todoapp %s", version)
// ... граф зависимостей и старт сервера
}
// сборка в CI:
// CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
// go build -trimpath \
// -ldflags "-s -w -X main.version=${VERSION}" \
// -o bin/server ./cmd/server-trimpath вырезает локальные пути из бинарника — воспроизводимая сборка и никакой приватной информации. -X main.version=... вшивает строку в пакетную переменную: значение должно быть литералом, потому и var version = "dev", а не вычисление. Флаги -s -w не мешают pprof: профилирование живёт в рантайме, а не в таблице символов.
Проверка: зачем CGO_ENABLED=0
Почему в CI собирают с CGO_ENABLED=0, если код чисто на Go и ничего не импортирует C-библиотек?
Конфигурация окружением: секреты не в образе#
Правило двенадцати факторов: конфиг — это окружение, а не код. Пароль базы, отличный на стейджинге и проде, порт, который назначает платформа, адрес редиса — всё это переменные, а не константы и не файлы в репозитории. В gin-проекте за это отвечает пакет config из урока про слои: Load() читает os.Getenv, применяет дефолты и возвращает ошибку, если обязательная переменная пуста — падать на старте честнее, чем узнавать о пустом пароле на первом запросе. Три практики, которые экономят ночи. Первая: никаких секретов в слоях образа — переменные передаются на запуске (docker run -e, секреты оркестратора), а образ остаётся одинаковым для всех сред; секрет, зашитый в ENV-инструкцию Dockerfile, виден в истории слоёв навсегда. Вторая: GIN_MODE=release приходит окружением — и один бинарник на стейджинге логирует debug, а в проде молчит. Третья: валидация конфига с человекочитаемой ошибкой — «DATABASE_URL обязателен» вместо паники в глубине драйвера. Отдельно — 12-факторный порт: ListenAndServe берёт адрес из cfg.Addr, который собрался из PORT-переменной, и платформа сама скажет, где вы живёте. Никакого чтения config.yaml с диска: в контейнере у вас нет ни файлов, ни прав на них.
Конфиг: окружение, дефолты, честный фейл
GOpackage config
type Config struct {
Addr string
DSN string
}
func Load() (Config, error) {
cfg := Config{
Addr: ":8080", // дефолт для локального пуска
}
if p := os.Getenv("PORT"); p != "" {
cfg.Addr = ":" + p
}
cfg.DSN = os.Getenv("DATABASE_URL")
if cfg.DSN == "" {
return cfg, errors.New("DATABASE_URL обязателен")
}
return cfg, nil
}
// в main.go:
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}Дефолт для локального пуска, окружение решает остальное: PORT назначает платформа, DATABASE_URL приходит из секретов. main падает сразу с внятной строкой — CI и оркестратор покажут её в логе старта, а не traceback на сотом запросе. Тот же пакет при желании читает GIN_MODE и передаёт в gin.SetMode: единая точка правды о среде.
Dockerfile: multi-stage и ничего лишнего#
gin docker — это почти всегда один и тот же рецепт: multi-stage сборка. Стадия build берёт полный образ Go, собирает бинарник; финальная стадия берёт пустую или минимальную базу и копирует туда один файл. Итог — образ в двадцать мегабайт вместо гигабайта с тулчейном, и поверхность атаки в разы меньше: в финальном слое нет компилятора, нет шелла в distroless-варианте, нет вашего исходного кода. Три механики, от которых зависит жизнь CI. Кэш слоёв: COPY go.mod go.sum и go mod download идут отдельным слоем ДО копирования исходников — пока зависимости не менялись, скачивание берётся из кэша за секунды. Пользователь: RUN adduser и USER 10001 — процесс не должен быть root'ом; контейнер с root'ом при пробитии изоляции отдаёт атакующему всё, что видит пользователь root. HEALTHCHECK — докер сам долбит /healthz и помечает контейнер нездоровым; оркестраторы обычно игнорируют эту инструкцию в пользу своих проб, но для голого докера она спасает. Плюс .dockerignore: node_modules, .git и сборочные артефакты не должны попадать в контекст — сборка быстрее, образ чище. EXPOSE — документация порта, ENTRYPOINT в exec-форме — чтобы процесс получал сигналы напрямую, о чём дальше и поговорим.
Dockerfile на двадцать строк
GOFROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # слой кэша зависимостей
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags "-s -w" \
-o /out/server ./cmd/server
FROM alpine:3.19
RUN adduser -D -u 10001 app
COPY --from=build /out/server /usr/local/bin/server
USER app
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["server"]Две стадии: в финальном образе нет тулчейна и исходников — только бинарник, alpine с CA-сертификатами для TLS-ходов и непривилегированный пользователь. wget из busybox бьёт в healthz каждые 15 секунд. ENTRYPOINT в exec-форме запускает сервер как PID 1 — именно так он получит SIGTERM от docker stop, а не просрочит grace-period до SIGKILL.
Проверка: порядок слоёв для кэша
Сборка в CI занимает минуты, и почти всегда виноват go mod download. В каком порядке должны идти инструкции, чтобы кэш работал?
Таймауты http.Server: голый Run не для прода#
r.Run(":8080") — обёртка над http.Server без единого таймаута: соединение может висеть вечно, медленный клиент — держать горутину и буферы сколько угодно. Прод требует явного Server с настроенными пределами. ReadHeaderTimeout — время на заголовки: главный щит от slowloris-атак, которые держат соединения недописанными заголовками. ReadTimeout — на чтение всего тела: ставьте с запасом к самому большому загружаемому файлу из урока 13. WriteTimeout — на запись ответа: но помните урок про стриминг и SSE — он убивает любые долгие потоки, поэтому для сервисов с потоками его либо обнуляют, либо выносят потоковые маршруты на отдельный сервер. IdleTimeout — сколько keep-alive соединение живёт между запросами: 60-120 секунд, иначе балансировщики будут пересоздавать соединения чаще, чем нужно. Плюс HeaderTimeout и MaxHeaderBytes по вкусу. Конфигурация этих значений — из окружения: на стейджинге и проде бюджеты разные. Так сервер превращается из «запустился и молится» в честный контракт: за сколько он готов отвечать и что будет с запросом, который в этот бюджет не уложился.
Явный сервер с бюджетами
GOsrv := &http.Server{
Addr: cfg.Addr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}Тот же движок gin, но с бюджетами: заголовки за 5 секунд, тело за 15, ответ за 60, простаивающее keep-alive — не дольше 2 минут. ListenAndServe вернёт http.ErrServerClosed при штатном Shutdown — это не ошибка, и фильтр через errors.Is спасает от ложного Fatal. Значения — из конфига окружения, не константы: у потоковых сервисов WriteTimeout будет другим.
Что напечатает лог при дедлайне shutdown
Прочитайте код и выберите, что он выведет:
shutCtx, cancel := context.WithTimeout(
context.Background(), 5*time.Second)
defer cancel()
err := srv.Shutdown(shutCtx)
log.Printf("shutdown: %v", err)Graceful shutdown: SIGTERM как приглашение#
docker stop и катающиеся деплои посылают процессу SIGTERM — и ждут десять-тридцать секунд (terminationGracePeriodSeconds), прежде чем SIGKILL добьёт его. Процесс без обработчика умирает мгновенно: активные запросы обрываются, клиенты видят connection reset, балансировщик — всплеск ошибок на ровном месте. Graceful shutdown переворачивает сценарий: поймали сигнал, перестали принимать новое, дождались активных запросов, закрыли базу, вышли. В Go механика стандартная: signal.NotifyContext ловит SIGTERM и SIGINT в контекст, main ждёт <-ctx.Done(), затем зовёт srv.Shutdown с дедлайном — немного меньше grace-period платформы, чтобы уложиться с запасом. После Shutdown закрывают то, что держат долгоживущие части: пул базы, продюсеры очередей, SSE-хаб из урока про стриминг — иначе утечёте с незакрытыми соединениями. Отдельная тонкость — readiness: перед Shutdown снимите эндпоинт с балансировщика — проба /readyz начнёт отвечать 503, трафик перетечёт на соседнюю реплику, и уже она примет новых клиентов. Десять строк кода отделяют «деплой без ошибок в графике» от «каждая выкатка — миллисекунды 5xx». Заодно и локальный Ctrl-C работает прилично: процесс доедает запросы и пишет прощальную строку в лог.
Полный цикл остановки
GOfunc main() {
// ... конфиг, граф, маршруты, srv из прошлого примера
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done() // SIGTERM от платформы или Ctrl-C
log.Println("сигнал: дренаж соединений")
shutCtx, cancel := context.WithTimeout(
context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Printf("принудительное закрытие: %v", err)
}
pool.Close() // после запросов — пул базы
log.Println("выход чисто")
}ListenAndServe уехал в горутину, main блокируется на сигнале. Shutdown с дедлайном 25 секунд — при типичном grace-period 30 остаётся запас на закрытие пула. Порядок важен: сначала перестали принимать и доели запросы, только потом закрыли базу. log.Fatalf в горутине — осознанный выбор для фатальных сбоев старта: он завершает процесс, если порт занят.
Health-чеки: liveness, readiness и правда о процессе#
Оркестратору нужны два разных вопроса, и путать их нельзя. Liveness — «жив ли процесс»: если проба падает, контейнер перезапускают. Readiness — «готов ли принимать трафик»: если падает, реплику просто выводят из балансировки и возвращают, когда готово. /healthz отвечает 200 всегда, пока процесс способен ответить — единственная логика внутри: ничего не проверять; иначе зависшая база уронит liveness, и оркестратор устроит рестарт-бурю вместо перелива трафика. /readyz — вся правда о зависимостях: ping базы с таймаутом в секунду, доступность кэша, заполненность очереди; таймаут обязателен — иначе проба сама зависнет на недоступной базе, а это ровно тот случай, когда она должна сказать 503. Оба эндпоинта: без авторизации, без логов, без звена лимита — они дергаются каждые несколько секунд и не должны ни копить мусор, ни маскироваться под бизнес-запросы. Отдельно — version в ответе: /healthz отдаёт вшитую через -X версию, и вопрос «а что там крутится?» решается curl-ом, а не археологией по дате сборки образа. Для съезжающих деплоев это главная страница правды.
Две пробы и версия
GOr.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "alive",
"version": version,
})
})
r.GET("/readyz", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(
c.Request.Context(), time.Second)
defer cancel()
if err := repo.Ping(ctx); err != nil {
c.JSON(http.StatusServiceUnavailable,
gin.H{"status": "db unreachable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ready"})
})Liveness без зависимостей: процесс дышит — значит 200. Readiness с секундным таймаутом на ping: недоступная база даёт 503, балансировщик снимает реплику, рестартов нет. Метод Ping добавляется в контракт репозитория из урока про слои — ещё один довод в пользу интерфейсов: он расширяется одной строкой.
Соберите readiness-пробу
Допишите пробу готовности: таймаут, ping и статус отказа.
Соберите жизнь одного релиза
От команды сборки до тихого ухода реплики — расставьте шаги по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Прод-чеклист: настройка и её значение#
| Настройка | Дефолт для ленивых | Прод-значение |
|---|---|---|
| Режим gin | debug с дампами | GIN_MODE=release |
| Сервер | r.Run без таймаутов | http.Server: ReadHeader/Read/Write/Idle |
| Остановка | умереть по SIGTERM сразу | Shutdown с дедлайном 25 из 30 секунд |
| Конфиг | константы в main | окружение, секреты вне образа |
| Пользователь контейнера | root | USER 10001, distroless или alpine |
| Здоровье | ничего | /healthz всегда 200, /readyz с ping-ом |
| Бинарник | с debug-таблицей | CGO_ENABLED=0, -ldflags -s -w, версия через -X |
Грабли: то, что роняет релизы#
Грабль первый — SIGTERM не доходит: ENTRYPOINT в shell-форме ("/bin/sh -c server") делает шелл PID 1, сигнал уходит ему, а сервер добивается SIGKILL без шанса попрощаться; exec-форма из примера решает это раз и навсегда. Второй — дедлайн shutdown больше grace-period платформы: вы дожидаетесь запросы двадцать секунд, а SIGKILL прилетает на десятой — настраивайте дедлайн на треть меньше terminationGracePeriodSeconds. Третий — liveness с проверкой базы: зависший Postgres роняет пробы на всех репликах разом, оркестратор перезапускает весь сервис, и вместо деградации вы получаете полный отказ — базу проверяет только readiness. Четвёртый — секреты в Dockerfile: ENV DATABASE_URL=postgres://... живёт в слоях образа вечно, даже если следующая инструкция её перепишет; секреты передаются на запуске. Пятый — логи в файлы внутри контейнера: диск контейнера эфемерен, а ротоции негде жить; только stdout, сборка — забота платформы. Шестой — порт из константы: платформа назначает PORT, и захардкоженный :8080 в окружении с динамической сеткой просто не стартует. Седьмой — забыли Закрыть пул и продюсеров: Shutdown вернулся, а соединения с базой остались до принудительного убийства. Восьмой — HEALTHCHECK с wget, которого нет в distroless: проверяйте, чем именно вы долбите в healthz, или выносите проверку наружу.
Шесть правил прод-сервера gin#
- Сборка: CGO_ENABLED=0, -trimpath, -ldflags с версией; бинарник без окружения не зависит ни от чего.
- Один образ на все среды, конфиг — окружение: секреты на запуске, падение на старте при пустом обязательном.
- Docker: multi-stage, кэш слоёв через go.mod до исходников, USER 10001, HEALTHCHECK, ENTRYPOINT в exec-форме.
- http.Server с таймаутами вместо r.Run: ReadHeaderTimeout против slowloris, WriteTimeout с оглядкой на потоки.
- Graceful shutdown: NotifyContext, Shutdown с дедлайном меньше grace-period, потом пулы и продюсеры.
- Пробы честно разделены: /healthz — жив, без зависимостей; /readyz — готов, с ping-ом и таймаутом.
Карточки: деплой
Соберите образ по Dockerfile из урока, запустите docker run -p 8080:8080 -e DATABASE_URL=... и постреляйте: kill -TERM по PID процесса должен дать в логе «сигнал: дренаж соединений» и чистый выход; открытый в соседнем терминале curl должен доедать свой ответ. Сломайте readiness — остановите базу — и убедитесь, что /readyz отвечает 503, а /healthz продолжает 200. Войдите в контейнер (docker exec) и проверьте id: пользователь должен быть 10001, а не root. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а сборка и запуск — в локальном docker, как и весь курс gin. Дальше — финальный проект курса: REST API с CRUD, JWT, валидацией и тестами, где вся маршрутизация курса соберётся в один сервис по канону слоёв из урока 22.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Деплой gin начинается со сборки: CGO_ENABLED=0 даёт статический бинарник, -ldflags режет вес и вшивает версию через -X main.version.
- Конфигурация — окружением: PORT и секреты приходят на запуске, config.Load падает на старте с внятной ошибкой, один образ на все среды.
- Docker для gin — multi-stage: кэш зависимостей через слой go.mod до исходников, USER 10001, HEALTHCHECK и ENTRYPOINT в exec-форме для сигналов.
- r.Run не для прода: явный http.Server с ReadHeaderTimeout против slowloris, Read/Write/Idle-таймаутами и оглядкой на потоковые маршруты.
- Graceful shutdown: NotifyContext ловит SIGTERM, Shutdown с дедлайном меньше grace-period дожидается запросов, потом закрываются пулы.
- Пробы разделены навсегда: /healthz — жив, без зависимостей; /readyz — готов, с ping-ом и таймаутом; версия в ответе healthz экономит часы.
Домашнее задание
Соберите сервис по канону урока: CGO_ENABLED=0 сборка с -ldflags "-s -w -X main.version=1.0.0", config.Load с PORT и DATABASE_URL и честным падением на пустом DSN. Явный http.Server с четырьмя таймаутами из примера. Пробы: /healthz всегда 200 с вшитой версией, /readyz с repo.Ping и секундным контекстом. Graceful shutdown через signal.NotifyContext и srv.Shutdown с дедлайном 25 секунд, после — pool.Close. Dockerfile multi-stage с кэшем go mod download, USER 10001 и HEALTHCHECK. Проверьте вживую: kill -TERM даёт в логе дренаж и чистый выход с кодом 0; остановленная база переводит /readyz в 503, не трогая /healthz; docker exec показывает uid 10001; docker inspect показывает HEALTHY через полминуты после старта.
Личные заметкиПусто
Вопросы и ответы по уроку
Docker или чистый бинарник на VPS?
Чистый бинарник + systemd — легитимный минимальный путь: Restart=always, EnvironmentFile для секретов, watchdog. Docker даёт воспроизводимость, изоляцию и одинаковый запуск везде — это стоит образа в двадцать мегабайт. Выбор определяется платформой: один сервер — systemd, команда и CI — контейнеры, кластер — только они.
Какой дедлайн ставить в srv.Shutdown?
Чуть меньше grace-period платформы, чтобы уложиться с запасом на закрытие пулов: при terminationGracePeriodSeconds 30 — дедлайн 20-25 секунд. Ориентир по трафику — p99 латентности: если 99% запросов живут меньше секунды, вам хватит и пяти; долгие SSE и long-poll останавливаются через Context.Done, а не через дедлайн shutdown.
Нужен ли nginx перед gin-контейнером?
Для TLS-терминации, gzip, раздачи статики и лимитов на периметре — да, или их берёт на себя ingress. Если платформа уже даёт ingress с TLS, gin остаётся чистым приложением и получает X-Forwarded-* — не забудьте SetTrustedProxies из урока про лимиты, чтобы ClientIP был честным.
Как гонять миграции при деплое?
Отдельным шагом до раскатки новой версии: job/init-контейнер с тем же образом и командой миграций, а не в main() при старте каждого реплики — иначе десять реплик дерутся за схему. Приложение только проверяет совместимость через /readyz; откат версии обязан переживать предыдущую схему — обратная совместимость на один релиз назад.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию