---
title: "Развертывание в продакшене"
description: "Деплой gin в продакшен: статический бинарник и multi-stage Dockerfile, конфигурация окружением, health-чеки liveness и readiness, graceful shutdown и таймауты."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/24-deploy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «Gin: самый популярный веб-фреймворк Go», урок 24 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/24-deploy/*

Сервис, который живёт только под 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/) останется только собрать всё вместе.

### деплой gin: что меняется между ноутбуком и продом

Go — редкий язык, где деплой может быть тривиальным: компилятор выдаёт единственный статический бинарник без рантайма, интерпретатора и зависимостей, и этот файл копируется на любую Linux-машину с той же архитектурой. Но тривиальность обманчива: вокруг бинарника живёт целая дисциплина. Конфигурация должна приходить из окружения, а не из файлов в репозитории — иначе один образ на стейджинг и прод не собрать. Процесс должен сообщать о здоровье: балансировщик и оркестратор не телепаты, им нужны эндпоинты /healthz и /readyz. Остановка должна быть graceful: SIGTERM приходит всегда, и обрыв 500 активных запросов — это ваши 500 ошибок в метриках и разъярённые клиенты. Логи идут в stdout структурными строками — их собирает платформа, а не ваши ротоции на диске. Всё это называют двенадцатью факторами: конфиг из окружения, одинаковые релизы, логи как поток событий, порт из переменной. Хороший gin-проект соответствует им ещё на этапе кода: [конфиг-пакет из урока про слои](/courses/gin/lessons/22-struktura/) читает окружение, 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-библиотек?

- Иначе бинарник не соберётся: компилятор требует явного отключения cgo в проде
- ✅ Чтобы бинарник стал статическим: без зависимости от libc он запускается в scratch и на любой машине, а кросс-компиляция проходит без шаманства
- Это ускоряет работу: без cgo HTTP-роутер работает в два раза быстрее
- Чтобы уменьшить потребление памяти процессом вдвое

По умолчанию на Linux net-пакет может использовать cgo-резолвер, и тогда бинарник линкуется с libc динамически — и требует её в целевом окружении. CGO_ENABLED=0 даёт чисто статический файл: копируете в пустой scratch-контейнер — работает. Скорость и память не меняются; зато меняется деплой gin: образ без ОС-зависимостей и предсказуемый DNS. Исключение — драйверы с cgo вроде sqlite: для них решение принимается осознанно.

### Конфигурация окружением: секреты не в образе

Правило двенадцати факторов: конфиг — это окружение, а не код. Пароль базы, отличный на стейджинге и проде, порт, который назначает платформа, адрес редиса — всё это переменные, а не константы и не файлы в репозитории. В 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. В каком порядке должны идти инструкции, чтобы кэш работал?

- COPY . . первым, затем go mod download — так код точно окажется в образе
- ✅ COPY go.mod go.sum, go mod download, и только потом COPY . . — слой зависимостей инвалидируется только при смене манифестов
- Порядок не важен: докер кэширует каждую инструкцию независимо от предыдущих слоёв
- go mod download выполняют на финальной стадии — там кэш свежее

Кэш слоёв инвалидируется каскадом: сменился любой файл в COPY . . — пересобираются все слои после него. Манифесты меняются раз в неделю, код — каждый коммит, поэтому зависимостям нужен собственный слой до копирования исходников. Инвалидация по любому .go-файлу — это и есть «виноват go mod download». На финальной стадии зависимостей нет вообще — там один бинарник.

### Таймауты http.Server: голый Run не для прода

r.Run(":8080") — обёртка над http.Server без единого таймаута: соединение может висеть вечно, медленный клиент — держать горутину и буферы сколько угодно. Прод требует явного Server с настроенными пределами. ReadHeaderTimeout — время на заголовки: главный щит от slowloris-атак, которые держат соединения недописанными заголовками. ReadTimeout — на чтение всего тела: ставьте с запасом к самому большому [загружаемому файлу из урока 13](/courses/gin/lessons/13-fajly/). WriteTimeout — на запись ответа: но помните урок про [стриминг и SSE](/courses/gin/lessons/19-proksi-streaming/) — он убивает любые долгие потоки, поэтому для сервисов с потоками его либо обнуляют, либо выносят потоковые маршруты на отдельный сервер. 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

```go
shutCtx, cancel := context.WithTimeout(
        context.Background(), 5*time.Second)
defer cancel()

err := srv.Shutdown(shutCtx)
log.Printf("shutdown: %v", err)
```

**Что выведет программа?**

- shutdown: nil — Shutdown дожидается всех активных запросов, сколько бы они ни длились
- ✅ shutdown: context deadline exceeded — 5 секунд прошли, оставшиеся соединения закрыты принудительно
- shutdown: http: Server closed — Shutdown считает себя убитым и падает
- Лог не появится: Shutdown заблокируется навсегда, пока long-poll не завершится сам

Контракт Shutdown: перестать принимать новое, дождаться активных запросов, но не дольше дедлайна контекста. Обычные хендлеры успели, а long-poll на десять минут — нет: по истечении дедлайна возвращается context.DeadlineExceeded, и оставшиеся соединения обрываются принудительно. Поэтому дедлайн выбирают по бизнес-метрике p99, а бесконечные потоки из урока 19 останавливают через Context.Done, а не через shutdown.

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

```go
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"})
})
```

Подсказка: Контекст берётся из запроса — c.Request.Context(); defer cancel() освобождает ресурсы таймера; у репозитория метод Ping; успешный ответ — StatusOK.

Правильные ответы в пропусках: `Context`, `cancel`, `Ping`, `StatusOK`

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

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

Правильный порядок строк:

1. `CI собирает: CGO_ENABLED=0, -ldflags с версией, статический бинарник`
2. `Multi-stage Dockerfile копирует бинарник в alpine и ставит USER 10001`
3. `Платформа передаёт PORT и DATABASE_URL из секретов на запуске`
4. `config.Load() валидирует окружение: пустой DSN — падение до трафика`
5. `gin.SetMode(release): дамп маршрутов и debug-подсказки остаются на стейджинге`
6. `http.Server стартует с таймаутами, ListenAndServe живёт в горутине`
7. `Пробы /healthz и /readyz зеленеют — балансировщик пускает трафик`
8. `Новый релиз: SIGTERM, NotifyContext ловит сигнал, Shutdown с дедлайном дожидается запросов`
9. `Пул базы закрывается, процесс выходит с кодом 0 — kubectl rollout без единой 5xx`

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

| Настройка | Дефолт для ленивых | Прод-значение |
| --- | --- | --- |
| Режим 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-ом и таймаутом.

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

- **CGO_ENABLED=0** — Статический бинарник без libc: работает в scratch и alpine, кросс-компилируется флагами GOOS/GOARCH. С парой -ldflags "-s -w" — минус таблица символов, с -X main.version=... — вшитая версия. Обратная сторона: cgo-драйверы вроде sqlite требуют отдельного решения.
- **Multi-stage Dockerfile** — Стадия build на golang:1.22-alpine собирает бинарник; финальная стадия на alpine/distroless копирует один файл. Слой go mod download до COPY . . — кэш зависимостей. USER 10001 вместо root, HEALTHCHECK по /healthz, ENTRYPOINT в exec-форме для доставки сигналов.
- **Liveness против readiness** — /healthz (liveness): процесс жив — 200, никаких зависимостей, иначе падение базы вызывает рестарт-бурю. /readyz (readiness): готов принимать трафик — ping базы с таймаутом, 503 снимает реплику с балансировки. Первый управляет рестартами, второй — потоком трафика.
- **Graceful shutdown** — signal.NotifyContext ловит SIGTERM/SIGINT; main ждёт <-ctx.Done(), зовёт srv.Shutdown с дедлайном (25 c при grace-period 30), затем закрывает пул базы и продюсеры. ListenAndServe возвращает http.ErrServerClosed — фильтровать через errors.Is. Балансировщику помогает снятие readiness.
- **Таймауты http.Server** — ReadHeaderTimeout 5 c — щит от slowloris; ReadTimeout — на тело с запасом к загрузкам; WriteTimeout — на ответ, но осторожно с потоками из урока стриминга; IdleTimeout 60-120 c для keep-alive. r.Run — это тот же сервер без единого бюджета.
- **Shell-форма против exec-формы** — ENTRYPOINT server в shell-форме превращается в /bin/sh -c server: PID 1 — шелл, SIGTERM доходит до него, а не до сервера, и docker stop заканчивается SIGKILL. Exec-форма ENTRYPOINT ["server"] запускает бинарник как PID 1 — сигналы приходят напрямую, graceful shutdown работает.

> **Проверьте остановку своего сервиса руками.** Соберите образ по 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](/courses/gin/). Дальше — финальный проект курса: REST API с CRUD, JWT, валидацией и тестами, где вся маршрутизация курса соберётся в один сервис по канону слоёв из урока 22.

## Коротко: что запомнить

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