Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 24 из 25 ~20 мин +50 XP

Развертывание в продакшене

Сервис, который живёт только под go run, — это демо. Деплой gin превращает его в релиз: статический бинарник, который собирается одной командой, контейнер, который запускается одинаково на ноутбуке и в кластере, health-чеки, по которым оркестратор знает правду о вашем процессе, и graceful shutdown, который не рвёт соединения на ровном месте. Всё, что вы построили раньше, встаёт на свои места: [release-режим из урока 23](/courses/gin/lessons/23-optimizaciya/), [граф зависимостей из урока 22](/courses/gin/lessons/22-struktura/), [конверт ошибок из урока 17](/courses/gin/lessons/17-oshibki/). Деплой gin на практике — это четыре темы: сборка и окружение, Docker-образ, здоровье процесса и корректная остановка. Плюс вечная тема таймаутов http.Server — потому что голый r.Run из [урока 02](/courses/gin/lessons/02-pervyj-server/) в проде не живёт. К финальному [проекту курса](/courses/gin/) останется только собрать всё вместе.

Содержание урока · 13 разделов

деплой 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 с диска: в контейнере у вас нет ни файлов, ни прав на них.

Конфиг: окружение, дефолты, честный фейл

GO
package 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 на двадцать строк

GO
FROM 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 по вкусу. Конфигурация этих значений — из окружения: на стейджинге и проде бюджеты разные. Так сервер превращается из «запустился и молится» в честный контракт: за сколько он готов отвечать и что будет с запросом, который в этот бюджет не уложился.

Явный сервер с бюджетами

GO
srv := &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 работает прилично: процесс доедает запросы и пишет прощальную строку в лог.

Полный цикл остановки

GO
func 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-ом, а не археологией по дате сборки образа. Для съезжающих деплоев это главная страница правды.

Две пробы и версия

GO
r.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 и статус отказа.

r.GET("/readyz", func(c *gin.Context) { ctx, cancel := context.WithTimeout( c.Request.(), time.Second) defer () if err := repo.(ctx); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"}) return } c.JSON(http., gin.H{"status": "ready"}) })

Соберите жизнь одного релиза

От команды сборки до тихого ухода реплики — расставьте шаги по порядку.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Прод-чеклист: настройка и её значение#

НастройкаДефолт для ленивыхПрод-значение
Режим gindebug с дампамиGIN_MODE=release
Серверr.Run без таймаутовhttp.Server: ReadHeader/Read/Write/Idle
Остановкаумереть по SIGTERM сразуShutdown с дедлайном 25 из 30 секунд
Конфигконстанты в mainокружение, секреты вне образа
Пользователь контейнераrootUSER 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-ом и таймаутом.

Карточки: деплой

Термин

CGO_ENABLED=0

нажмите, чтобы перевернуть
Определение

Статический бинарник без libc: работает в scratch и alpine, кросс-компилируется флагами GOOS/GOARCH. С парой -ldflags "-s -w" — минус таблица символов, с -X main.version=... — вшитая версия. Обратная сторона: cgo-драйверы вроде sqlite требуют отдельного решения.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 через полминуты после старта.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Развертывание в продакшене)

Вопросы и ответы по уроку

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; откат версии обязан переживать предыдущую схему — обратная совместимость на один релиз назад.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию