---
title: "Развертывание: бинарник, Docker, graceful shutdown и reverse proxy"
description: "деплой echo: статичный бинарник go build, multi-stage Dockerfile, graceful shutdown по SIGTERM, переменные окружения, systemd и reverse proxy."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/24-deploy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Развертывание: бинарник, Docker, graceful shutdown и reverse proxy

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

деплой echo — урок, где процесс, наученный в [двадцать третьем](/courses/echo/lessons/23-monitoring/) отвечать метриками, пробами и slog-логами, наконец покидает ваш ноутбук и приезжает на сервер. Приложение из [двадцать первого урока](/courses/echo/lessons/21-struktura/) собрано в слои, прод-режим из [двадцать второго](/courses/echo/lessons/22-performance/) включается переменной APP_ENV, наблюдаемость из двадцать третьего отвечает на вопросы «жив ли, готов ли, как себя чувствует» — сегодня собираем артефакт: статичный бинарник через go build, Docker-образ в две сцены, окружение с секретом JWT, systemd-юнит для хоста и reverse proxy перед каноном на :1323. Деплой echo — это не «как поставить», а три контракта: артефакт, который собирается один раз и везде одинаково; остановка, которая не рвёт in-flight запросы; конфигурация, которая меняется без пересборки. Ключевая новая механика — graceful shutdown: сигнал SIGTERM, e.Shutdown с таймаутом и честная работа с hijacked-соединениями из [восемнадцатого урока](/courses/echo/lessons/18-websocket/). Приступаем — echo docker-история начинается с одного go build.

### деплой echo: артефакт вместо исходников

Тема «деплой echo» начинается с признания: пока приложение запускается командой go run на машине разработчика, у него нет ни версии, ни владельца, ни способа откатиться. Деплой — это переход от исходников к артефакту: куску, который собран один раз, помечен версией и запускается одинаково в любой среде. Для Go-приложения артефакт обязан радовать компилятор языка: статичный бинарник, в котором уже ничего не нужно ни доустанавливать, ни интерпретировать. Этим деплой echo отличается от многих экосистем: «сборка» — одна команда, «рантайм» — сам бинарник и ядро, а всё остальное — Docker-образ, systemd-юнит, окружение — упаковка одного и того же файла.

План урока следует за жизненным циклом выката: собрать (go build и флаги, которые делают бинарник пригодным для минимального образа), упаковать (multi-stage Dockerfile с HEALTHCHECK по /healthz из двадцать третьего урока), научить останавливаться (graceful shutdown — самая богатая механика урока), прокормить окружением (секрет и порт без пересборки) и поставить на границу сети (systemd на голом хосте или reverse proxy перед контейнером). Каждая ступень проверяется приборами, которые вы уже подключили: пробы, метрики и JSON-логи — это те глаза, без которых выкат не отличается от надежды.

### go build: статичный бинарник и версия внутри

Команда сборки канона выглядит так: CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(git describe --tags)" -o bin/server ./cmd/server. Разберём по флагам, потому что каждый решает конкретную деплой-задачу. CGO_ENABLED=0 запрещает компилятору связывать C-код: бинарник получается полностью статичным и работает в пустейшем образе (scratch, distroless/static), где нет ни glibc, ни bash; побочный бонус — кросс-компиляция в GOOS=linux GOARCH=amd64 с любой машины. Для канона на :1323, где все зависимости — чистый Go (echo, echo-jwt, echoprometheus, slog), ноль CGO ничего не отнимает. -trimpath вырезает локальные пути из бинарника: в стектрейсах падают чистые пути модуля вместо /home/z/... — меньше шума в логах и меньше информации наружу. -ldflags "-s -w" отбрасывает таблицу символов и DWARF-отладку: минус четверть веса файла; отладчик для прода не нужен — точную сборку всегда можно пересобрать.

Самое важное — -X main.version=..., которое вшивает значение в переменную пакета main при линковке. В cmd/server/main.go объявляется var version = "dev", логгер печатает её на старте, и вместе с echo-баннером, спрятанным HideBanner из двадцать второго урока, в журнале появляется честный «version=v1.4.0». Теперь на вопрос «а что вообще крутится на сервере» отвечает curl или slog-строка, а не опрос коллег. Канон собран; переезжаем в контейнер.

### Канон: сборка с версией и проверка

```go
// cmd/server/main.go — точка входа из двадцать первого урока
var version = "dev" // перезаписывается на линковке

func main() {
        // ... сборка приложения из app.New
        slog.Info("starting", "version", version, "addr", addr)
        // ...
}

// сборка и проверка артефакта
CGO_ENABLED=0 go build -trimpath \
  -ldflags "-s -w -X main.version=$(git describe --tags)" \
  -o bin/server ./cmd/server
./bin/server & curl -fsS localhost:1323/healthz
```

Переменная version объявлена с дефолтом "dev": локальная сборка без флагов не ломается, а пайплайн на линковке подставляет git-тег. Проверка артефакта перед упаковкой — /healthz из двадцать третьего урока: бинарник отвечает 200, значит слои собраны и порт слушается. file bin/server покажет «statically linked» — это сертификат, что CGO_ENABLED=0 сработал и образ может быть сколь угодно пустым.

### Docker: две сцены, нонрут-пользователь и HEALTHCHECK

Docker-образ канона — multi-stage: в первой сцене компилятор, во второй — только артефакт. Builder стартует с golang:1.25-alpine, копирует go.mod/go.sum и качает модули (этот слой кэшируется и не переживает правки кода), копирует исходники и выполняет ту же команду сборки, что и выше. Финальная сцена — alpine или distroless/static: копирует bin/server, объявляет USER nonroot и EXPOSE 1323. Почему не один stage: компилятор весит гигабайты, рантайм — десятки мегабайт; образ-финал без компилятора быстрее качается, имеет меньше CVE и, главное, честно соответствует артефактной модели — в прод уезжает бинарник, а не исходники. USER nonroot — прямое требование безопасности: процесс, слушающий :1323 (порт выше 1024, привилегий не нужно), не обязан быть root в контейнере.

Специфично echo docker-дополнение — HEALTHCHECK, и здесь заходит наблюдаемость из двадцать третьего урока: докер сам опрашивает /healthz и помечает контейнер unhealthy, если проба перестала отвечать. В alpine есть busybox wget, в distroless нет ничего — выбор базового образа определяет синтаксис проверки. Для выходящего HTTPS-трафика (HTTP-клиент из [двадцать второго урока](/courses/echo/lessons/22-performance/) зовёт внешние API) финальному образу нужен пакет ca-certificates: без корневых сертификатов TLS-рукопожатие падает, и это одна из самых частых ошибок первого выката — процесс жив, health-пробы зелёные, а исходящие запросы валятся по x509.

### Канон: Dockerfile в две сцены

```go
# стадия сборки: компилятор и модули
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download          # слой кэшируется до правки зависимостей
COPY . .
RUN CGO_ENABLED=0 go build -trimpath \
    -ldflags "-s -w -X main.version=${VERSION}" \
    -o /out/server ./cmd/server

# финальная сцена: только артефакт
FROM alpine:3.20
RUN adduser -D -u 65532 nonroot && apk add --no-cache ca-certificates
USER nonroot
COPY --from=build /out/server /server
EXPOSE 1323
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s \
  CMD wget -qO- http://127.0.0.1:1323/healthz || exit 1
ENTRYPOINT ["/server"]
```

Слои идут от реже меняющегося к чаще: зависимости → код → сборка; правка исходника не переигрывает go mod download. USER nonroot с uid 65532 — UID из образа distroless, согласован и с файловыми правками. HEALTHCHECK опрашивает /healthz каждые 15 секунд с таймаутом 3с и start-period на прогрев — параметры зеркалят пробы из двадцать третьего урока, только решает их Docker, а не Kubernetes. ENTRYPOINT в exec-форме — сигнал SIGTERM от docker stop попадает прямо процессу-серверу, без прослойки-shell, которая сигнал не пересылает.

### Проверьте себя: что произойдёт при docker stop

**Вопрос:** Контейнер с каноном запущен, вы выполняете docker stop tasksapp. Что делает Docker?

- Посылает SIGKILL немедленно: docker stop не договаривается
- Посылает SIGINT и ждёт бесконечно, пока процесс сам выйдет
- ✅ Посылает процессу SIGTERM, ждёт 10 секунд, затем SIGKILL
- Посылает SIGHUP: сервер обязан перечитать конфигурацию

Контракт docker stop: сначала SIGTERM — «пора заканчиваться», затем через таймаут (по умолчанию 10 секунд, флаг -t меняет) SIGKILL, у которого нет грусти и выбора. Эти десять секунд — бюджет для graceful shutdown из следующего блока: успеть дождаться завершения in-flight запросов и закрыть листенер. Если приложение SIGTERM игнорирует или висит — процесс убивается жёстко, и все преимущества программы на Go обнуляются.

### Graceful shutdown: контракт net/http и e.Shutdown

Внезапная смерть процесса рвёт все in-flight запросы: пользователь увидел ошибку на честном POST — потому, что вы переезжали с v1.3.0 на v1.4.0. Graceful shutdown — договорённость: при получении сигнала сервер перестаёт принимать новые соединения, дожидается завершения начатых и только потом выходит. Док-комментарий http.Server.Shutdown описывает протокол по шагам: «Shutdown works by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down. If the provided context expires before the shutdown is complete, Shutdown returns the context's error». Словарь: листенеры закрываются — новые запросы не принимаются; idle-соединения закрываются; активные — дожидают своего завершения: платформа ждёт столько, сколько даёт контекст.

Echo оборачивает это одной строкой: e.Shutdown(ctx) — по исходнику echo.go v4.15.0 это «It internally calls http.Server#Shutdown()» на внутреннем Server, а e.Shutdown без аргумента-контекста не существует — контекст задаёт бюджет ожидания. И здесь же ловушка, из-за которой половина graceful-туториалов в интернете не работает: e.Start(":1323") возвращает значение http.ErrServerClosed, когда сервер остановлен через Shutdown — это штатный возврат, а не авария. Если main написан как e.Logger.Fatal(e.Start(":1323")), то штатная остановка упадёт фатальной ошибкой и процесс выйдет с кодом 1 — системы оркестрации считают это сбоем и на ровном месте поднимают тревогу. Правильный main отделяет запуск в горутину и сравнивает ошибку через errors.Is(err, http.ErrServerClosed).

### Канон: main.go с graceful shutdown

```go
// cmd/server/main.go — полный производственный main
func main() {
        secret := os.Getenv("JWT_SECRET")
        if secret == "" {
                slog.Error("JWT_SECRET is empty") // падаем до старта листенера
                os.Exit(1)
        }
        prod := os.Getenv("APP_ENV") == "production"
        e := app.New(repository.NewMemory(), []byte(secret), prod)

        go func() {
                if err := e.Start(":1323"); err != nil && !errors.Is(err, http.ErrServerClosed) {
                        e.Logger.Fatal(err) // это НЕ штатный возврат
                }
            }()
        ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
        defer stop()
        <-ctx.Done() // SIGTERM от docker stop или systemd

        shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
        defer cancel()
        if err := e.Shutdown(shutdownCtx); err != nil {
                e.Logger.Error("shutdown timed out", "err", err)
        }
        hub.Close() // закрыть hijacked-соединения WebSocket — ниже в уроке
        slog.Info("stopped", "version", version)
}
```

Три оси этой сборки. Сигнал: signal.NotifyContext превращает SIGTERM/SIGINT в закрытие канала ctx.Done() — код ждёт не в обработчике сигнала, а в обычном select-е. Бюджет: 8 секунд внутри 10-секундного окна docker stop — запас на закрытие горутин и выгрузку буферов; контекст истёк — Shutdown вернёт ctx.Err(), но соединения не порвёт принудительно, их добьёт выход процесса. Ошибка запуска: errors.Is(err, http.ErrServerClosed) отличает штатную остановку от настоящей аварии — без этого main падает фатально каждый деплой. JWT_SECRET читается в composition root из двадцать первого урока: пустой секрет — отказ до старта, а не 500 на первом запросе.

### Проверьте себя: ErrServerClosed

**Вопрос:** После e.Shutdown(shutdownCtx) что вернёт горутина, ждущая e.Start(":1323"), и почему в каноне стоит проверка errors.Is(err, http.ErrServerClosed)?

- ✅ http.ErrServerClosed — штатный признак остановки; без проверки main упадёт Fatal с кодом 1 при каждом деплое
- nil — сервер «успешно не запустился», проверка не нужна
- context.DeadlineExceeded — Shutdown истёк, Start обязан упасть
- Она не вернётся никогда: Start блокируется навсегда

Serve (и е.Start поверх него) возвращает ErrServerClosed после Shutdown — это документированный, ожидаемый исход, а не ошибка. Проверка errors.Is отделяет его от настоящих аварий: bind-конфликт порта и сломанный листенер дают другие ошибки, и вот их — Fatal. Без этой проверки каждый успешный деплой выглядит как сбой в журнале и в статусе контейнера.

### Что произойдёт с активным WebSocket при остановке

```go
// канон: клиент держит WS-соединение из восемнадцатого урока
// (hijacked, в hub-е с register/unregister)
ctx, stop := signal.NotifyContext(...)
<-ctx.Done()
shutdownCtx, _ := context.WithTimeout(context.Background(), 8*time.Second)
err := e.Shutdown(shutdownCtx) // клиент ещё в комнате WS
// что вернёт Shutdown и что станет с WS-соединением?
```

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

- Shutdown заблокируется навсегда, пока клиент не отключится
- ✅ Shutdown вернётся, не дожидаясь WS: hijacked-соединения Shutdown не закрывает и не ждёт — их закрывает hub
- WS-соединение получит HTTP 503 и переподключится
- Shutdown упадёт с ошибкой: hijacked соединения несовместимы с остановкой

Док http.Server.Shutdown прямолинеен: «Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets». Апгрейд вырвал сокет из-под ведения сервера ещё в восемнадцатом уроке — сервер про него больше не знает. Поэтому в каноне за Shutdown стоит hub.Close(): хаб рассылает стоп и закрывает коннекты сам. HTTP-стримы SSE из девятнадцатого урока — обратный случай: они НЕ hijacked, Shutdown их честно ждёт; их застрявший цикл обязан выходить по Context.Done() или closeNotify, иначе бюджет таймаута съест бесконечный стрим.

### Упростим main и посмотрим на код выхода

```go
// вариант main из блога «короче — лучше»:
go func() {
        e.Logger.Fatal(e.Start(":1323"))
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
<-ctx.Done()
_ = e.Shutdown(context.Background())
// docker stop сработал штатно. Каков код выхода процесса?
```

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

- 0: Shutdown отработал, значит выход успешный
- 137: SIGKILL от Docker
- ✅ 1: при остановке Start вернёт http.ErrServerClosed, а Fatal завершит процесс с ошибкой
- Код выхода не определён

Fatal вызывает os.Exit(1) — независимо от того, что Shutdown был безупречен. Оркестратор (systemd, Docker, Kubernetes) увидит failure и — в зависимости от политики Restart — может поднять «упавший» контейнер, который вы только что корректно остановили. Две строки, на которые стоит потратить внимание: errors.Is(err, http.ErrServerClosed) вместо безусловного Fatal. Здравый main канона выше именно так и написан.

### Окружение: секрет и порт без пересборки

Один образ обязан работать во всех средах — иначе артефактная модель ломается: собирать образ на каждую конфигурацию — дорога к дрейфу «на тесте работало». Двадцать второй урок уже задал канон: prod := os.Getenv("APP_ENV") == "production". Деплой добавляет два обязательных параметра и правило. Параметры: JWT_SECRET (секрет подписи токенов из [пятнадцатого урока](/courses/echo/lessons/15-jwt/) — его время жить равно времени жить пода, а не образа) и PORT, если :1323 занят или прокси требует другого. Правило: конфигурация читается только в composition root — cmd/server/main.go; слои из двадцать первого урока os.Getenv не зовут: тест передаёт всё параметрами, прод — теми же параметрами, но значения из окружения.

Способы передать: docker run --env-file prod.env (файл рядом с контейнером, в git не попадает), -e JWT_SECRET=... (виден в ps — только для локалки), в systemd — EnvironmentFile=/etc/tasksapp.env (ниже), в оркестраторах — секреты платформы. Запрет один: секреты не должны попадать в образ ни через RUN, ни через --build-arg — слои образа читаются любым, кто скачал его из реестра. Знакомая ситуация: секрет уехал в docker build --build-arg JWT_SECRET=..., слой с ARG остался в истории — образ в реестре теперь «сделан с секретом» навсегда, откати — не откатишь.

### Канон: prod.env рядом с контейнером

```go
# prod.env (в деплой-репозитории, не в git приложения)
APP_ENV=production
JWT_SECRET=<64 байта из генератора, менять при компрометации>
PORT=1323

# запуск
docker run -d --name tasksapp \
  -p 1323:1323 --env-file prod.env \
  registry.example/tasksapp:v1.4.0

# main.go: порт из окружения, дефолт — канон
addr := ":1323"
if p := os.Getenv("PORT"); p != "" {
        addr = ":" + p
}
```

Образ не содержит ни среды, ни секрета: он один и тот же для теста и прода — меняется только env-файл. PORT остаётся параметром композиции: app.New и маршруты из двадцать первого урока не знают, на каком порту слушают, — это вопрос main. Смена секрета = рестарт контейнера с новым env: активные access-токены подписаны старым ключом и отвалятся — потому секреты ротируют вместе с refresh-токенами, а не между ними.

### Проверьте себя: где жить конфигурации

**Вопрос:** Ревьюер предлагает читать JWT_SECRET внутри service.TaskService через os.Getenv при каждом Create. Почему это ошибка слоёв?

- os.Getenv слишком медленный для горячего пути Create
- Секрет в service — нормально, ошибка только в main
- os.Getenv нельзя вызывать из горутин
- ✅ Слой сервиса перестаёт быть тестируемым и переносимым: зависимость от глобального окружения не объявлена в конструкторе

Конструктор NewTaskService(store TaskStore) из двадцать первого урока обещает: сервис зависит от хранилища и ничего больше. Скрытая зависимость от окружения ломает контракт: тест обязан подделывать os.Setenv (глобально, с гонками в параллельных тестах), а перенос в воркер без окружения падает. Чтение env — работа composition root: main читает, что нужно, и передаёт параметры. Правило одного места чтения окружения проверяется греп-ом: os.Getenv живёт только в cmd/.

### systemd и reverse proxy: два способа жить без оркестратора

Далеко не каждый сервер имеет Kubernetes, и деплой echo обязан уметь жить по-простому: бинарник плюс systemd. Юнит описывает контракт службы: ExecStart=/usr/local/bin/tasksapp-server (тот самый артефакт), EnvironmentFile=/etc/tasksapp.env (тот же prod.env, что у Docker), User=www-data (nonroot не только в контейнере), Restart=on-failure (падение — поднять, остановку — уважить), After=network-online.target. systemd шлёт службе тот же SIGTERM, что и docker stop: graceful shutdown из середины урока работает один в один — это и есть выгода сигнал-контракта: механизм остановки не зависит от упаковки. systemctl enable --now tasksapp включает автозапуск, journalctl -u tasksapp читает stdout со slog-JSON — тот же поток, что собирает Docker.

Перед сервером почти всегда стоит reverse proxy: nginx терминирует TLS, раздаёт статику и держит внешний порт 443. Конфигурация коротка: location / { proxy_pass http://127.0.0.1:1323; } плюс заголовки X-Forwarded-For/Host и правила для длинных соединений — WebSocket из восемнадцатого урока требует Upgrade/Connection, SSE из девятнадцатого — отключённой буферизации. Сетевые тонкости nginx — отдельная вселенная, и повторять их здесь не будем: полный разбор proxy_pass, X-Forwarded-For и hop-by-hop заголовков сделан в [уроке fiber о развертывании](/courses/fiber/lessons/23-deploy/) — механика proxy одинакова, а вашему echo-серверу важно одно: видеть прокси дружелюбно (таймауты http.Server из двадцать второго урока согласованы с proxy_read_timeout) и не доверять вслепую заголовкам клиента.

### Канон: systemd-юнит для задачника

```go
# /etc/systemd/system/tasksapp.service
[Unit]
Description=Tasksapp (Echo)
After=network-online.target
Wants=network-online.target

[Service]
User=www-data
ExecStart=/usr/local/bin/tasksapp-server
EnvironmentFile=/etc/tasksapp.env
Restart=on-failure
RestartSec=2
# шлёт SIGTERM, ждёт TimeoutStopSec, потом SIGKILL
TimeoutStopSec=10

[Install]
WantedBy=multi-user.target
```

Симметрия с Docker нарочно видимая: тот же артефакт, тот же env-файл, тот же SIGTERM и тот же бюджет (TimeoutStopSec=10 против 10 секунд docker stop). Restart=on-failure поднимает процесс при падении, но не мешает systemctl stop — в отличие от Restart=always, который конфликтует с ручной остановкой. TimeoutStopSec согласован с 8-секундным контекстом Shutdown канона: main успевает закончить ожидание раньше, чем systemd перейдёт к SIGKILL.

> **После выката проверьте приборы, а не надежды.** Выкат не заканчивается на «контейнер поднялся». Чек-лист после каждого релиза, весь инструментарий из двадцать третьего урока: /healthz отвечает 200 сразу, /readyz гасит 503 после прогрева и управляет трафиком, в /metrics появился ярлык версии или строка version=v1.4.0 в логе старта, echo_requests_total растёт, доля 5-хх не дёргается, slog-JSON читается в collector-е. И один раз за смену — учебная остановка: docker stop на стенде, посмотреть в логи, что Shutdown дождался запросов и процесс вышел с кодом 0. Графически красивый, но убегающий по SIGKILL сервис — не выкачен, а запущен.

### Соберите сигнальную связку main.go

Восстановите костяк graceful shutdown: конвертация сигналов, ожидание и мягкая остановка.

```go
ctx, stop := signal.___Context(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()

shutdownCtx, cancel := context.___Timeout(context.Background(), 8*time.Second)
defer cancel()
if err := e.___(shutdownCtx); err != nil {
        e.Logger.Error("shutdown timed out", "err", err)
}
```

Подсказка: Сигналы в канал, бюджет ожидания, метод Echo из echo.go v4.15.0.

Правильные ответы в пропусках: `Notify`, `With`, `Shutdown`

### Порядок: пайплайн выката канона

Расставьте шаги выката от тестов до проверки остановки.

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

1. `go vet ./... && go test ./...`
2. `CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(git describe)" -o bin/server ./cmd/server`
3. `docker build --build-arg VERSION=$(git describe) -t registry.example/tasksapp:v1.4.0 .`
4. `docker push registry.example/tasksapp:v1.4.0`
5. `docker run -d --name tasksapp -p 1323:1323 --env-file prod.env registry.example/tasksapp:v1.4.0`
6. `curl -fsS localhost:1323/healthz — liveness зелёный сразу`
7. `curl -fsS localhost:1323/readyz — 503 на прогреве, 200 после ready.Store(true)`
8. `docker stop tasksapp — SIGTERM, Shutdown дождался запросов, код выхода 0`
9. `проверка приборов: echo_requests_total в Prometheus, slog-версия в логе, доля 5-хх спокойна`

### Карточки: язык развертывания

- **Graceful shutdown** — Протокол net/http.Shutdown: закрыть листенеры → закрыть idle-соединения → дождаться активных до истечения контекста. Echo: e.Shutdown(ctx) = http.Server#Shutdown().
- **http.ErrServerClosed** — Что возвращает e.Start/e.Serve после Shutdown. Штатный исход, не авария: main обязан проверять errors.Is, иначе каждый деплой — Fatal и код 1.
- **Hijacked-соединения** — WebSocket из 18-го урока: Shutdown их «does not attempt to close nor wait». Закрывает hub (hub.Close() после Shutdown). SSE — наоборот: ждёт, пока цикл выйдет по Context.Done().
- **CGO_ENABLED=0** — Статичный бинарник без libc: работает в scratch/distroless, кросс-компилируется через GOOS/GOARCH. Для канона на чистом Go — бесплатный выигрыш.
- **ldflags -X** — Вшивает значение в переменную на линковке: -X main.version=$(git describe) + var version = "dev" в main.go. Версия видна в логе старта и в сверке артефакта.
- **EnvironmentFile** — Секреты и порт — вне образа: docker run --env-file или systemd EnvironmentFile=/etc/tasksapp.env. Читает окружение только composition root (cmd/server), os.Getenv в слоях — ошибка.

### Канон выкачен

Канон на :1323 теперь артефакт, а не исходники. Сборка: CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(git describe)" — статичный бинарник с версией внутри; file подтверждает «statically linked». Упаковка: multi-stage Dockerfile с кэшем модулей, nonroot, ca-certificates для исходящих HTTPS и HEALTHCHECK по /healthz из двадцать третьего урока. Остановка: signal.NotifyContext по SIGTERM, 8-секундный контекст внутри 10-секундного бюджета, e.Shutdown дожидается завершения HTTP-запросов, hub.Close() закрывает hijacked WebSocket, а errors.Is(err, http.ErrServerClosed) отличает штатный выход от аварии. Окружение: JWT_SECRET и PORT через --env-file или EnvironmentFile, os.Getenv — только в main. Хост без Docker: systemd-юнит с Restart=on-failure и journalctl; перед контейнером — reverse proxy с правилами для Upgrade и SSE.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите бинарник, поднимите образ, остановите контейнер под нагрузкой и почитайте логи: Shutdown должен дождаться in-flight запросов, а метрики — показать ноль обрывов на 5-хх.

Следующий, двадцать пятый урок курса echo — Итоговый проект: API на Echo с JWT, валидацией и тестами. Всё, что вы собрали за курс — маршруты, слои, ошибки, тесты, наблюдаемость, деплой, — склеится в один задачник, который можно отдать в портфолио. Навигация по курсу — на [странице курса Echo](/courses/echo/); полный разбор nginx-прокси и заголовков X-Forwarded-For — в [уроке fiber о развертывании](/courses/fiber/lessons/23-deploy/); пробы и метрики, проверяющие выкат, — в [уроке про мониторинг](/courses/echo/lessons/23-monitoring/); таймауты http.Server, согласованные с proxy_read_timeout, — в [уроке про производительность](/courses/echo/lessons/22-performance/); composition root, где читается окружение, — в [уроке про структуру проекта](/courses/echo/lessons/21-struktura/); hub WebSocket, закрывающий hijacked-соединения, — в [уроке про WebSocket](/courses/echo/lessons/18-websocket/).

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

- Деплой echo — артефактная модель: CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(git describe)" -o bin/server ./cmd/server даёт статичный бинарник с версией внутри (var version = "dev", перезаписывается на линковке, печатается slog-ом на старте).
- Multi-stage Dockerfile: builder на golang:1.25-alpine с кэшем go mod download, финал — alpine/distroless: USER nonroot, EXPOSE 1323, ca-certificates для исходящих HTTPS, HEALTHCHECK по /healthz из двадцать третьего урока, ENTRYPOINT в exec-форме — SIGTERM доходит процессу.
- Graceful shutdown: signal.NotifyContext(os.Interrupt, syscall.SIGTERM) → ctx.Done() → e.Shutdown(8s-контекст внутри 10-секундного docker stop). Протокол net/http: закрыть листенеры → idle → дождаться активных; истёк контекст — возвращается ctx.Err(), соединения не рвутся принудительно.
- e.Start после Shutdown возвращает http.ErrServerClosed — штатный исход: горутина проверяет errors.Is и не падает Fatal-ом, иначе каждый деплой — код выхода 1 и ложная тревога оркестратору.
- Shutdown не закрывает и не ждёт hijacked-соединения (WebSocket из восемнадцатого урока) — их закрывает hub.Close() после Shutdown; SSE-циклы не hijacked и ждутся — их выход обязан быть привязан к Context.Done()/closeNotify.
- Окружение: JWT_SECRET и PORT читает только composition root (cmd/server/main.go) — пустой секрет = отказ до старта листенера; docker run --env-file / systemd EnvironmentFile; секреты не попадают в образ (--build-arg остаётся в истории слоёв).
- Без оркестратора: systemd-юнит — ExecStart артефакта, EnvironmentFile, User=www-data, Restart=on-failure, TimeoutStopSec=10 (согласован с Shutdown), journalctl читает slog-JSON; перед контейнером reverse proxy: proxy_pass на :1323, X-Forwarded-For, отдельные правила для WS-Upgrade и SSE-буферизации — подробный разбор в fiber-курсе.

## Домашнее задание

Выкачайте канон и докажите каждый контракт. Первое: соберите артефакт CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(git describe)" -o bin/server ./cmd/server, проверьте file bin/server («statically linked») и строку version в slog-старте; объясните, что изменится в стектрейсе из-за -trimpath. Второе: напишите двухсценный Dockerfile с USER nonroot и HEALTHCHECK по /healthz; приостановите процесс SIGSTOP-ом и посмотрите, как Docker помечает контейнер unhealthy. Третье: доведите main.go до канона урока: signal.NotifyContext, горутина с errors.Is(err, http.ErrServerClosed), 8-секундный Shutdown; сначала заведите версию с e.Logger.Fatal(e.Start(...)) и задокументируйте код выхода 1 при docker stop — затем почините. Четвёртое: под нагрузкой (500 RPS локальным генератором) выполните docker stop и посчитайте в /metrics из двадцать третьего урока, сколько in-flight запросов получили честные ответы — объясните роль 8-секундного бюджета и что изменится при 11-секундном. Пятое: подключите WebSocket-клиент из восемнадцатого урока, держите комнату и выполните остановку; убедитесь по логам, что Shutdown вернулся, не дожидаясь WS, и что hub.Close() закрыл коннект; объясните, почему SSE-цикл обязан выходить по Context.Done(). Шестое: вынесите JWT_SECRET и PORT в prod.env, запустите docker run --env-file, затем docker history и найдите там --build-arg-вариант с секретом — зафиксируйте, почему так нельзя. Седьмое: напишите systemd-юнит по канону, запустите бинарник без Docker, выполните systemctl restart и прочитайте slog-JSON в journalctl -u tasksapp; сравните коды выхода при systemctl stop и при kill -9 — почему Restart=on-failure не поднимает первое.

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

### Какой таймаут выбрать для e.Shutdown и как он связан с docker stop?

Правило вложенности: бюджет Shutdown строго меньше времени, которое даёт платформа. Docker stop по умолчанию 10 секунд до SIGKILL, systemd TimeoutStopSec настраивается, в Kubernetes terminationGracePeriodSeconds задаётся явно. Канон берёт 8 секунд: два-три запаса на закрытие горутин, логов и буферов. Истёк контекст — Shutdown вернёт ctx.Err() и выйдет: соединения он не рвёт принудительно, их добьёт выход процесса, но важно, что вы не зависли дольше бюджета. Отдельная ловушка — длинные SSE/WS: либо их циклы выходят по Context.Done(), либо бюджет таймаута будет съедаться каждый рестарт.

### Нужен ли Docker вообще, если есть systemd?

Нет: это два честных способа жить на одном хосте, и канон поддерживает оба одним артефактом. systemd проще операционно: файл юнита, journalctl, Restart=on-failure — и никакого реестра образов; изоляции нет, поэтому версии языка и библиотек — вопрос хоста. Docker даёт изоляцию, неизменяемый образ с версией и одинаковый рантайм на любой машине — ценой реестра, слоёв и чуть большей площади атаки. Выбор обычно диктует инфраструктура команды: есть платформа-контейнеры — деплойте образ; есть «сервер под столом» — юнит. Сигнальный контракт SIGTERM и env-файл у обоих совпадают нарочно — перенос между ними меняет три строки.

### Как деплой echo отличается от fiber-версии того же урока?

Артефактная часть совпадает почти дословно: go build, multi-stage, nonroot, HEALTHCHECK — Go-компилятор один и тот же. Различия начинаются в деталях фреймворка: fiber построен на fasthttp и имеет свои ListenConfig и TrustProxy-механику, а echo стоит на net/http — поэтому здесь канонный graceful shutdown через e.Shutdown и signal.NotifyContext с честным ErrServerClosed, документированное поведение для hijacked-соединений и стандартные http.Server-таймауты из двадцать второго урока. Reverse proxy и nginx-заголовки — общие для обоих: транспорт один и тот же, и подробный разбор мы оставили в fiber-уроке о развертывании, чтобы не пересказывать его дважды.
