Содержание урока · 10 разделов
Первый же выкат Go-сервиса на chi за nginx ломает привычную картину: в журнале на каждый запрос один и тот же адрес — 172.17.0.1 или адрес прокси, лимит запросов режет всех пользователей одной очередью, а редирект на страницу логина уводит с https:// на http://. Ничего в chi не сломалось: сервис честно видит то, что ему принёс прокси — TCP-соединение от nginx, а не от клиента. Деплой go сервиса — это два слоя, и настраивать их надо согласованно: прокси принимает TLS, сжимает ответы, отдаёт статику и переводит запрос на chi, а chi должен узнать настоящий адрес клиента и исходную схему из заголовков. Ниже — раскладка по слоям: что настраиваем в nginx, что в chi, где живут таймауты, куда ставить multistage-образ и systemd-юнит, и живое место про подделанный X-Forwarded-For, из-за которого лимиты по IP облетают стороной.
Деплой go сервиса за nginx: слои от TLS до chi#
Обязанности раскладываются так. Прокси держит TLS-терминацию: сертификаты, redirect с 80-го порта, http2 — всё здесь, chi работает по HTTP внутри доверенного периметра. Сжатие — gzip на прокси: жать в хендлере значит тратить CPU приложения на работу, которую прокси делает дешевле. Статика — images и собранные ассеты отдаёт nginx, chi занимается только API: файловый сервер chi через Mount остаётся для dev-режима и внутренних задач. Балансировка — upstream с несколькими инстансами chi, когда один перестал справляться. Плюс мелочи, которые в приложении делать дорого: ограничение соединений, чёрные списки, access-лог. chi за этим остаётся ровно тем, чем был весь курс: роутером и хендлерами на чистом net/http. Двусторонняя связь одна и простая: заголовки от прокси к chi и health-пробы от прокси к /healthz.
Dockerfile для Go: multistage и distroless#
Go-бинарник — редкий случай, когда контейнер может быть крошечным: сборке нужен тулчейн, запуску — нет. Multistage-сборка разделяет эти жизни: первая стадия FROM golang:1.22-alpine качает модули и компилирует, вторая FROM alpine (или пустой FROM scratch, или distroless) получает один файл через COPY --from=build. CGO_ENABLED=0 выключает cgo — бинарник становится статическим и запускается даже в scratch без libc. В финальном образе нет компилятора, модулей и shell — меньше поверхность атаки и мегабайты. EXPOSE 8080 — только документация порта, проброс делает -p при запуске. Health-check: /healthz из каркаса с graceful shutdown опрашивается прокси и оркестратором; HEALTHCHECK в Dockerfile требует исполняемый файл, а в distroless нет ни shell, ни curl — проверку переносят в docker-compose или Kubernetes либо копируют рядом крошечный check-бинарник.
Multistage-образ для сервиса на chi
GOFROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/notes ./cmd/notes
FROM alpine:3.20
COPY --from=build /bin/notes /notes
EXPOSE 8080
ENTRYPOINT ["/notes"]Слои с go.mod и go.sum идут раньше кода: пока файлы модулей не менялись, docker build переиспользует кэш и не качает зависимости на каждый коммит. Финальная стадия забирает только бинарник — тулчейн, исходники и кэш модулей остаются в build-стадии. Docker stop посылает SIGTERM, который ловит каркас из урока про graceful shutdown. Smoke-тест бинарника удобно прогнать локальным Go до сборки образа: go build && ./notes и curl на /healthz.
Проверка: что попадает в финальный образ
Что именно копирует в финальную стадию строка COPY --from=build /bin/notes /notes?
X-Forwarded-For и middleware.RealIP в chi#
Адрес клиента живёт в заголовках, которые выставляет прокси. X-Real-IP — один адрес, тот, что прокси видел на своём входе. X-Forwarded-For — цепочка через запятую, где каждый прокси дописывает свой прыжок. X-Forwarded-Proto — исходная схема, http или https. Важная деталь nginx: по умолчанию он передаёт на бэкенд только Host и Connection — все три заголовка надо выставить proxy_set_header вручную. В chi адрес чинит middleware.RealIP. По исходникам v5.0.8 он проверяет заголовки в порядке True-Client-IP, X-Real-IP, X-Forwarded-For, у цепочки берёт первое значение до запятой, валидирует строку через net.ParseIP — мусор молча игнорируется — и переписывает r.RemoteAddr. Ставить слой надо рано, до Logger: адрес в строке лога читается из RemoteAddr в момент сборки, и порядок слоёв из урока про RequestID и Logger работает и здесь.
nginx перед chi: location с заголовками
GOserver {
listen 443 ssl;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}proxy_pass переводит запрос на chi, слушающий 127.0.0.1:8080 — снаружи порт не открыт, до RealIP добирается только nginx. X-Forwarded-For здесь затирается адресом клиента, а не дополняется: почему — в красной врезке ниже. X-Forwarded-Proto отдаёт исходную схему, и редиректы перестают уводить с https. Проверка руками: chi локальным Go на :8080, nginx на :8081, curl -H «X-Forwarded-For: 203.0.113.9» — хендлер увидит честный RemoteAddr.
Заголовок клиент шлёт сам: curl -H «X-Forwarded-For: 1.2.3.4» — и nginx с классическим proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for дописывает реальный адрес в конец: «1.2.3.4, 91.108.x.x». chi берёт первое значение — подделку. Rate-limit по IP становится бесполезен: новый фейковый адрес на каждый запрос, лимиты обходятся бесплатно, логи врут. Фикс — на границе: затирать клиентский заголовок значением $remote_addr, как в конфиге выше, и не открывать порт chi наружу. Слой RealIP доверяет тому, кто стоит перед ним, и ничему больше.
Проверка: что окажется в RemoteAddr
За nginx без proxy_set_header идёт запрос с клиентским заголовком «X-Forwarded-For: 1.2.3.4». На chi включён middleware.RealIP. Что в r.RemoteAddr?
Какой адрес выберет RealIP?
Прочитайте код и выберите, что он выведет:
// заголовки запроса, дошедшего до chi:
X-Real-IP: 203.0.113.7
X-Forwarded-For: 198.51.100.9, 10.0.0.5
// стек: r.Use(middleware.RealIP)
// в хендлере:
fmt.Print(r.RemoteAddr)X-Forwarded-Proto и таймауты прокси и http.Server#
Схема нужна каждому, кто строит абсолютные URL или решает, редиректить ли на https: за прокси r.TLS всегда nil — соединение к chi идёт по HTTP, и проверка «r.TLS == nil» врёт. Отсюда конвенция: прокси выставляет X-Forwarded-Proto $scheme, приложение читает заголовок — и redirect на логин остаётся на https://. Второй слой согласования — таймауты. У nginx свой бюджет: proxy_connect_timeout, proxy_send_timeout и proxy_read_timeout, по умолчанию 60 секунд каждый; в http.Server — ReadTimeout, WriteTimeout и IdleTimeout из каркаса. Правило: дедлайн chi должен срабатывать раньше бюджета прокси. Timeout-хендлер на 5 секунд при proxy_read_timeout 60s — нормально; отчёт на 90 секунд при прокси 60s — прокси отрежет соединение, клиент увидит 502, а хендлер доработает впустую. Дедлайны и троттлинг выставляются с учётом прокси-бюджета, а не в отрыве от него.
systemd без Docker: unit-файл#
Docker — не единственный способ жить на сервере, и для одиночного VPS systemd честно проще: один бинарник, unit-файл, никакого реестра образов. ExecStart указывает на собранный бинарник, User — на непривилегированного пользователя (root не нужен: chi слушает порт выше 1024, а 80/443 держит nginx), Restart=on-failure поднимает процесс после падения, EnvironmentFile подкладывает переменные окружения. systemctl enable --now notes — сервис в автозапуске; journalctl -u notes — логи. Docker stop и systemctl stop делают одно и то же: шлют SIGTERM, который ловит graceful shutdown из каркаса — долетающие запросы заканчиваются, новые не принимаются. Чего в systemd-варианте нет — изоляции и воспроизводимости: версии libc и окружение зависят от машины, и собирать бинарник нужно на совместимой системе.
Unit-файл для сервиса на chi
GO[Unit]
Description=notes-api (chi)
After=network-online.target
[Service]
User=notes
ExecStart=/opt/notes/notes
EnvironmentFile=/etc/notes/env
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.targetAfter=network-online.target откладывает старт до появления сети — слушателю нужен интерфейс. User=notes: сервис работает от непривилегированного пользователя с правами только на бинарник и env-файл. Restart=on-failure с RestartSec=2 — пауза между попытками, чтобы не крутить бесконечный рестарт-цикл при битой конфигурации. После правки юнита — systemctl daemon-reload, иначе systemd продолжит держать старую версию файла в памяти.
nginx — когда сервисов много и ops-традиции уже сложились: гибкие location, upstream, экосистема модулей; цена — многословный конфиг и сертификаты руками или через certbot. Caddy — когда нужен короткий конфиг и авто-TLS из коробки: пара строк — и сертификаты обновляются сами; цена — меньше гибкости и меньше людей, которые его знают. Чистый Go с autocert — минус целый слой: меньше точек отказа, но gzip, лимиты соединений и статику придётся держать в приложении, и любой баг хендлера виден снаружи. Расклад у меня такой: один pet-сервис — Caddy или autocert; прод с несколькими сервисами — nginx, потому что граница «сеть отдельно от кода» окупается с первого инцидента.
Слой → что настраиваем#
| Слой | Где настраиваем | Что делаем |
|---|---|---|
| TLS | nginx / Caddy | сертификаты, redirect с 80 на 443, http2 |
| Сжатие | nginx / Caddy | gzip для text/*: JSON и HTML, без уже сжатых картинок |
| Статика | nginx / Caddy | location на каталог ассетов и cache-заголовки |
| Заголовки клиента | nginx → chi | X-Real-IP, X-Forwarded-For, X-Forwarded-Proto через proxy_set_header |
| Адрес клиента | chi | middleware.RealIP в начале стека, до Logger |
| Таймауты | nginx + http.Server | proxy_read_timeout больше дедлайнов хендлеров chi |
| Перезапуск | systemd / Docker | Restart=on-failure или restart-политика контейнера |
Чек-лист выката#
- proxy_set_header четвёркой: Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto — nginx по умолчанию передаёт только Host и Connection.
- X-Forwarded-For затирается $remote_addr, а не дополняется $proxy_add_x_forwarded_for — иначе клиент подделает адрес.
- middleware.RealIP стоит в начале стека, до Logger; порт chi снаружи закрыт.
- Таймауты согласованы: proxy_read_timeout больше дедлайнов chi, иначе долгие запросы умрут 502-ми на прокси.
- /healthz отвечает и в момент graceful shutdown — прокси снимает инстанс с балансировки до остановки.
- Restart=on-failure в systemd или restart-политика в Docker — падение не оставляет сервис мёртвым до утра.
Восстановите location перед chi
Три строки nginx-конфига: порт бэкенда, адрес клиента и исходная схема.
Соберите multistage-файл
Расставьте строки Dockerfile: от базового образа сборки до переноса бинарника в финальную стадию.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: chi за прокси
Сервис за прокси перестаёт быть игрушкой на ноутбуке: TLS и сжатие съел nginx, адрес клиента доехал через X-Real-IP и RealIP до RemoteAddr, редиректы держат схему из X-Forwarded-Proto, таймауты согласованы, а падение поднимает Restart. Остаётся выключаться вежливо: graceful shutdown из отдельного урока уже ловит SIGTERM от systemd и Docker stop, а /healthz держит прокси в курсе живости инстанса. Бюджеты по времени выставляются вместе с Timeout и Throttle: сначала дедлайн chi, потом прокси-запас. Полная карта курса — на странице chi.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Схема «nginx/Caddy → chi»: TLS, сжатие и статика на прокси, chi занимается логикой на чистом net/http.
- nginx по умолчанию передаёт только Host и Connection — X-Real-IP, X-Forwarded-For и X-Forwarded-Proto выставляются proxy_set_header вручную.
- middleware.RealIP переписывает RemoteAddr по True-Client-IP → X-Real-IP → X-Forwarded-For и безопасен только за доверенным прокси.
- Подделанный X-Forwarded-For при $proxy_add_x_forwarded_for обходит лимиты по IP: заголовок затирается на границе, порт chi закрыт снаружи.
- Таймауты согласованы: дедлайны chi срабатывают раньше proxy_read_timeout — иначе прокси отрезает долгие запросы 502-ми.
- Multistage-образ: сборка в полной стадии, в финал копируется один статический бинарник; health-check — /healthz плюс оркестратор.
- Без Docker — systemd: ExecStart, User, Restart=on-failure, EnvironmentFile и SIGTERM для graceful shutdown.
Домашнее задание
Соберите multistage-образ сервиса: build-стадия с CGO_ENABLED=0, финальная на alpine, EXPOSE и ENTRYPOINT. Рядом напишите nginx-location с четвёркой proxy_set_header — X-Forwarded-For затирается $remote_addr — и unit-файл systemd с Restart=on-failure. Проверьте curl-ом с подделанным X-Forwarded-For, что в RemoteAddr оказался честный адрес клиента.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли reverse-прокси, если chi сам умеет таймауты и статику?
Умеет — но каждый слой, который вы тащите в приложение, съедает его CPU и появляется в коде. Прокси снимает TLS, сжатие и лимиты соединений до chi и даёт точку, где сеть настраивается без пересборки бинарника. Для pet-проекта чистый Go с autocert допустим; прод обычно всё равно заканчивается прокси.
Почему после middleware.RealIP в RemoteAddr нет порта?
chi v5 подставляет в RemoteAddr значение из заголовка как есть — голый IP без порта, в отличие от обычного «IP:port» от TCP-соединения. Код, который разбирает адрес через net.SplitHostPort, начнёт получать ошибку «missing port». Валидация у chi есть — net.ParseIP отсекает мусор, — но порт слой не дописывает.
X-Real-IP или X-Forwarded-For — что безопаснее?
Одиночный X-Real-IP от собственного прокси проще: в нём одно значение, и его легко затереть на границе. X-Forwarded-For рассчитан на цепочки прокси, а chi берёт из него первый элемент — тот, что ближе к клиенту. Пока за вами один nginx, безопаснее X-Real-IP или затёртый XFF; цепочке нужен доверенный каждый прыжок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter