RE:NODE

Деплой веб-сервиса на Go: net/http, chi и gin в продакшене

Автор Команда RE:NODE
18 мин чтения


Веб-сервис на Go - самое простое, что можно запустить в этой категории, и у него меньше всего оправданий, если он ведёт себя плохо. Стандартная библиотека уже содержит HTTP-сервер production-уровня, результатом сборки служит один статически слинкованный бинарник, а простаивающий сервис занимает несколько десятков мегабайт. Проблемы почти всегда сводятся к одной из пяти мелочей: сервис слушает 127.0.0.1, порт прописан в коде, SIGTERM игнорируется, нет таймаутов или сборщику мусора позволено расти выше лимита памяти контейнера.

В этой статье разобраны все пять, а также то, что относится именно к деплою на app-сервер, который сам компилирует ваш код: что должен сказать go.mod, что происходит при каждом старте, как вписывается деплой из GitHub, что прокси передаёт вашим обработчикам, на какие базы данных стоит смотреть и что читать, когда в консоли появилась ошибка.

Что нужно Go-сервису во время работы#

Почти ничего, в этом и прелесть. go build линкует ваш код, зависимости и рантайм Go в один исполняемый файл. На сервере нет ни интерпретатора, ни виртуального окружения, ни node_modules. Рантайм приносит с собой планировщик и сборщик мусора, так что Go-процесс - это один бинарник и набор переменных окружения.

Две детали стоит знать до того, как они вас удивят:

  • Cgo меняет картину. При CGO_ENABLED=1 (значение по умолчанию, если есть компилятор C) бинарник может динамически линковаться с glibc, а пакетам вроде mattn/go-sqlite3 на этапе сборки нужен тулчейн C. При CGO_ENABLED=0 бинарник полностью статический и такой зависимости не имеет. Если у используемой библиотеки есть вариант на чистом Go - modernc.org/sqlite вместо go-sqlite3, pgx вместо всего, что построено на libpq, - предпочитайте его для сервера, который вы не контролируете.
  • Часовые пояса и сертификаты. В минимальном окружении может не оказаться данных о часовых поясах и набора CA. Если программа вызывает time.LoadLocation("Europe/Berlin") или делает исходящие HTTPS-запросы, импортируйте time/tzdata, чтобы встроить базу часовых поясов. Исходящему TLS нужны корневые сертификаты системы, поэтому проверьте исходящий вызов при первом деплое, а не предполагайте, что всё работает.

Память - это число, вокруг которого стоит планировать. Небольшой сервис на net/http с пулом соединений к базе обычно простаивает на 10-30 МБ резидентной памяти, а растёт он вместе с числом одновременных запросов и буферами, а не с размером кода. Поэтому самый маленький тариф для приложений - разумный дом для большинства Go-сервисов, и поэтому запас иногда нужен именно сборке, а не работе.

net/http, chi или gin#

Начните со стандартной библиотеки. Начиная с Go 1.22 стандартный ServeMux понимает методы и подстановки в путях, и это убрало главную причину тянуть роутер:

main.go
mux := http.NewServeMux()mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {    w.Write([]byte("ok"))})mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {    id := r.PathValue("id")    fmt.Fprintf(w, "user %s\n", id)})

Берите chi, когда нужны композиция middleware и группы маршрутов с сохранением стандартного интерфейса http.Handler. Всё, что написано для net/http, работает под chi без изменений, так что, перейдя на него позже, вы ничего не потеряете:

go
r := chi.NewRouter()r.Use(middleware.RequestID, middleware.Recoverer)r.Route("/api", func(r chi.Router) {    r.Get("/health", health)    r.Get("/users/{id}", getUser)})

Берите gin, когда нужны его помощники для биндинга и валидации и экосистема готовых middleware. У него собственный тип контекста, так что обработчики привязаны к gin. В продакшене задайте в окружении GIN_MODE=release; режим debug по умолчанию печатает все маршруты при старте и пишет в лог больше, чем вам нужно:

env
GIN_MODE=release

Ни один из трёх не быстрее другого настолько, чтобы это имело значение на небольшом тарифе. Разница между ними меньше, чем один обмен с базой данных. Выбирайте по тому, как вам удобнее писать обработчики и middleware, а сэкономленные силы потратьте на таймауты и остановку - этого за вас не делает ни один роутер.

go.mod, go.sum и сборка при старте#

На линейке Go у RE:NODE сервер собирается командой go build при каждом старте. У этой фразы есть последствия для структуры проекта, так что отнеситесь к ней осознанно.

Во-первых, go.mod - не украшение. Он называет модуль, задаёт минимальную версию Go и перечисляет все зависимости с версиями:

go.mod
module github.com/yourname/apigo 1.23require (    github.com/go-chi/chi/v5 v5.1.0    github.com/jackc/pgx/v5 v5.7.1)

Коммитьте и go.mod, и go.sum. Во втором лежат контрольные суммы каждой версии каждого модуля, и без него сборке приходится разрешать и проверять всё с нуля. Запускайте go mod tidy перед каждым коммитом, чтобы убрать неиспользуемые зависимости и добавить недостающие; go.mod, расходящийся с импортами, - самая частая причина того, что сборка, которая работает на вашем ноутбуке, падает на сервере.

Во-вторых, важна строка go. Если в ней указана версия новее, чем тулчейн на сервере, инструмент Go может попытаться скачать более новый тулчейн или откажется собирать - в зависимости от директивы toolchain и настройки GOTOOLCHAIN. Не пишите go 1.25 только потому, что у вас установлена именно она; укажите самую низкую версию, которая действительно нужна вашему коду. Прежде чем опираться на свежую возможность языка, проверьте, какую версию Go предлагает линейка.

В-третьих, каждый старт означает компиляцию. Это достоинство - бинарник всегда соответствует исходникам на сервере, - и цена, потому что перезапуск не мгновенный. Небольшой сервис собирается за секунды при прогретом кэше сборки и за минуту и больше при холодном. Относитесь к перезапуску как к деплою: он длится столько, сколько занимает сборка, плюс время, которое сервису нужно, чтобы открыть свои соединения.

В-четвёртых, ошибки компиляции превращаются в ошибки запуска. Если код не собирается, сервер не стартует, а сообщение компилятора попадает в консоль. Это лучше, чем наполовину работающий сервис, но означает, что сломанный коммит в ветке, за которой следит сервер, кладёт сервис. Запускайте это локально перед пушем:

bash
$ go mod tidy$ go vet ./...$ go test ./...$ go build -o /tmp/api .

Держите пакет main там, где его ждёт линейка. Проще всего положить main.go в корень репозитория; если вы предпочитаете cmd/api/main.go, посмотрите на вкладке Startup, что именно собирает линейка Go, и убедитесь, что она указывает на ваш пакет main, до первого деплоя.

Порты, привязка адреса и переменные окружения#

Здесь две настройки, и у обеих значения по умолчанию внутри контейнера неверны.

Первая - адрес привязки. localhost:8080 и 127.0.0.1:8080 слушают только интерфейс loopback, до которого снаружи контейнера ничто не достанет. Используйте 0.0.0.0 или оставьте хост пустым (":8080") - тогда сервис слушает на всех интерфейсах.

Вторая - порт. Не прописывайте его в коде. Выделенный порт приходит в переменной окружения, а вкладка Startup в панели показывает переменные вашего сервера. Читайте имя, которое там указано; в примере ниже сначала пробуется PORT, затем SERVER_PORT, затем значение для разработки, так что один и тот же бинарник работает и на ноутбуке, и на сервере:

main.go
func port() string {    for _, name := range []string{"PORT", "SERVER_PORT"} {        if v := os.Getenv(name); v != "" {            return v        }    }    return "8080"}func requireEnv(name string) string {    v := os.Getenv(name)    if v == "" {        log.Fatalf("missing required environment variable %s", name)    }    return v}

Читайте каждую переменную один раз, при старте, и сразу падайте, если обязательной нет. Сервис, который обнаруживает отсутствие DATABASE_URL в три часа ночи на редкой ветке кода, отлаживать сложнее, чем тот, что отказывается запускаться. На вкладке Startup вы также добавляете свои переменные - URL базы, секрет сессий, ключи API, - чтобы они не попадали в репозиторий. В статье Environment variables and secrets описано, что туда относится, а что нет.

Небольшая структура конфигурации наводит порядок и даёт одно место, где при старте можно залогировать действующие настройки (без секретов):

go
type Config struct {    Addr        string    DatabaseURL string    Env         string}func load() Config {    return Config{        Addr:        ":" + port(),        DatabaseURL: requireEnv("DATABASE_URL"),        Env:         os.Getenv("APP_ENV"),    }}

Таймауты, логи и graceful shutdown#

У http.Server с нулевыми значениями вообще нет таймаутов. На ноутбуке это нормально, а в продакшене - проблема: клиент, который открыл соединение и ничего не отправляет, бесконечно удерживает горутину и файловый дескриптор. Используйте сервер с явными настройками:

main.go
srv := &http.Server{    Addr:              cfg.Addr,    Handler:           mux,    ReadHeaderTimeout: 5 * time.Second,    ReadTimeout:       15 * time.Second,    WriteTimeout:      30 * time.Second,    IdleTimeout:       60 * time.Second,    MaxHeaderBytes:    1 << 20,}
ПолеЧто ограничиваетТипичное значение
ReadHeaderTimeoutВремя на чтение заголовков запроса5 s
ReadTimeoutВесь запрос, включая тело15 s
WriteTimeoutВремя на запись ответа30 s
IdleTimeoutKeep-alive соединения между запросами60 s

Больше всего неприятностей приносит WriteTimeout. Он охватывает весь ответ, поэтому стриминговый эндпоинт, большая загрузка или поток server-sent events будут оборваны, когда он сработает. Для таких маршрутов сбрасывайте дедлайн на каждый запрос через http.NewResponseController(w).SetWriteDeadline(time.Time{}) или запускайте их на отдельном сервере с другими лимитами.

Пишите логи в стандартный вывод, чтобы они попадали в консоль панели. Начиная с Go 1.21 в стандартной библиотеке есть структурированное логирование в log/slog:

go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))slog.SetDefault(logger)slog.Info("listening", "addr", cfg.Addr)

JSON-строки удобно искать и неудобно читать вживую, поэтому переключайтесь на slog.NewTextHandler, когда APP_ENV не равен production.

Теперь остановка. При остановке или перезапуске сервера отправляется SIGTERM. Если программа его не обрабатывает, процесс убивается по истечении льготного периода, а всё, что было в работе, теряется - недописанные ответы, буферизованные записи, задания посреди выполнения. Go позволяет обработать сигнал коротко:

main.go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()go func() {    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {        log.Fatal(err)    }}()<-ctx.Done()slog.Info("shutting down")shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()if err := srv.Shutdown(shutdownCtx); err != nil {    slog.Error("shutdown", "err", err)}db.Close()

Shutdown перестаёт принимать новые соединения, ждёт завершения активных запросов и возвращается, когда они завершились или истёк контекст. Две ловушки: ListenAndServe сразу возвращает http.ErrServerClosed, когда вы вызываете Shutdown, - это не ошибка, и логировать её как ошибку не нужно; и Shutdown не ждёт перехваченные (hijacked) соединения, например websocket, - закрывайте их сами. Делайте таймаут остановки короче времени, которое даёт платформа; десять секунд - разумный потолок. Про сторону health-эндпоинта рассказано в статье Graceful shutdown and health checks; для Go это обработчик, возвращающий константу.

Деплой из GitHub#

На линейках для приложений деплой идёт только из GitHub, через GitHub App с короткоживущими токенами, поэтому приватные репозитории работают, и персональный токен доступа нигде вставлять не нужно. Есть два переключателя, и они независимы:

  • Pull при каждом старте. При каждом запуске сервер забирает последний коммит ветки, за которой следит, и затем собирает его.
  • Deploy on push. Пуш в ветку перезапускает работающий сервер и запускает остановленный. Каждый деплой записывается отдельной записью.

Вместе со сборкой при старте цепочка деплоя получается короткой:

pushHTTPSHTTPРепозиторий GitHubgo.mod, go.sum, исходникиКлиентыPull при стартепоследний коммитСлот проксиTLS, X-Forwarded-Forgo buildпри каждом стартеПроцесс Goслушает порт
От пуша до работающего Go-сервиса

Две привычки делают это безопасным. Деплойте из ветки, в которую попадает только код, уже прошедший тесты, потому что deploy on push перезапустит продакшен на том, что туда упало. И не держите секреты в репозитории, ведь сборка читает то же дерево, которое вы запушили. Если пуш не собрался, вывод компилятора лежит в консоли, а исправление - это ещё один пуш.

При первом пуше на пустой сервер помните: каждая линейка, кроме Node.js, пока вы не задеплоите код, показывает на своём адресе небольшую рабочую страницу, и эта страница никогда не перезаписывает ваши файлы. Если вы видите её после деплоя, значит, деплой не вступил в силу: загляните в запись о деплое и в консоль, прежде чем винить прокси.

Обратный прокси, домен и X-Forwarded-For#

Терминировать TLS внутри Go-программы можно через ListenAndServeTLS или autocert, но на хостинговом тарифе это обычно неверный выбор: на вас ложатся продление сертификата, редирект с HTTP на HTTPS и перезагрузка при смене сертификата. Отдавайте обычный HTTP на выделенном порту, а остальное оставьте прокси.

В тарифы для приложений входит слот обратного прокси. Направьте A-запись на адрес, показанный для слота, и сертификат будет выпущен и продлён автоматически, в пределах 21-дневного окна. Дайте записи время распространиться, прежде чем проверять; сертификат нельзя выпустить для имени, которое ещё не указывает на этот адрес. Сама запись описана в статье Connecting a domain to a game server, и те же шаги подходят для приложения. Цепочку заголовков подробнее объясняет статья What a reverse proxy does.

Что это значит для ваших обработчиков:

  • `r.RemoteAddr` - это прокси, а не посетитель. Настоящий адрес клиента приходит в заголовке X-Forwarded-For. Если вы логируете r.RemoteAddr или ограничиваете частоту запросов по нему, вы логируете и ограничиваете прокси, и все посетители делят одну корзину.
  • Берите правильный элемент. X-Forwarded-For - список через запятую. Всё, что клиент отправил сам, стоит слева; адрес, добавленный вашим прокси, - справа. Если доверять крайнему левому значению, любой клиент сможет назваться кем угодно. Поскольку здесь прокси - единственный путь внутрь, доверять заголовку вообще разумно, но берите последнюю запись, добавленную прокси, а не первую.
  • Подключайте middleware один раз и рано. В chi есть middleware.RealIP, который переписывает r.RemoteAddr по пересылающим заголовкам. Ставьте его только тогда, когда прокси гарантированно стоит перед сервисом, потому что он поверит любому значению заголовков.
  • Стройте абсолютные URL по `X-Forwarded-Proto`. Редиректам, ссылкам в письмах и OAuth-колбэкам нужна публичная схема. Проверка r.TLS == nil за прокси всегда говорит «обычный HTTP», и результатом становится бесконечный редирект на http://.
go
func clientIP(r *http.Request) string {    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {        parts := strings.Split(xff, ",")        return strings.TrimSpace(parts[len(parts)-1])    }    host, _, _ := net.SplitHostPort(r.RemoteAddr)    return host}

Websocket работают через прокси, но им нужны heartbeat короче любого таймаута простоя; подробности есть в статье websockets behind a reverse proxy, и она без изменений применима к gorilla/websocket и nhooyr.io/websocket.

Базы данных: Postgres, MySQL и Valkey#

Go-сервис сам по себе не хранит состояние, и это то, что нужно. Состояние уходит в базу данных, а получить её можно двумя способами.

В каждый тариф для приложений входят слоты баз данных, которые создаются в панели со сгенерированными хостом, пользователем и паролем. Для чего-то более крупного или отдельного есть тарифы database hosting для PostgreSQL, MySQL 8.4 LTS, MongoDB, SQL Server Express и Valkey. Они доступны на собственном хосте и порту тарифа, с учётными данными, сгенерированными для каждого сервера. Готовую строку подключения помещайте в переменную окружения на вкладке Startup, но никогда не в код.

Для PostgreSQL драйвер - pgx, напрямую или через database/sql. Его пул называется pgxpool:

go
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))if err != nil {    log.Fatal(err)}defer pool.Close()

Для MySQL стандартный выбор - go-sql-driver/mysql под database/sql. Добавьте в DSN parseTime=true, иначе вместо значений time.Time вы получите срезы байтов, и задайте кодировку явно:

code
user:pass@tcp(host:3306)/appdb?parseTime=true&charset=utf8mb4&loc=UTC

database/sql по умолчанию использует пул и без ограничений, и именно это нужно исправить. Если оставить как есть, он откроет столько соединений, сколько требуют одновременные запросы, и всплеск трафика превратится в ошибку превышения лимита соединений на стороне базы:

go
db.SetMaxOpenConns(10)db.SetMaxIdleConns(5)db.SetConnMaxLifetime(30 * time.Minute)db.SetConnMaxIdleTime(5 * time.Minute)

В Go-процессе один пул, а не по одному на воркер, так что десять - с запасом для сервиса на одном-двух ядрах. Арифметику со стороны базы данных вы найдёте в статье Connection pools and limits.

Для кэша, сессий и очередей используйте Valkey. Это открытый форк Redis, он говорит по протоколу Redis, поэтому go-redis или rueidis подключаются к нему без изменений. Экземпляр защищён паролем и сохраняет данные на диск, используя и AOF, и снапшоты. О подключении рассказано в статье Valkey getting started, а job queues with Valkey - естественный следующий шаг.

Миграции запускайте осознанно. Применять их при каждом старте из main удобно и опасно, потому что цикл перезапусков будет повторять наполовину применённую миграцию. Отдельная команда или инструмент вроде golang-migrate или goose, запускаемый вручную, безопаснее для всего, что серьёзнее игрушки.

Память: GOMEMLIMIT, GOGC и память для сборки#

При достижении лимита памяти ядро останавливает контейнер, и он чисто перезапускается, а не уходит в своп. Для Go-сервиса это главный эксплуатационный риск, потому что сборщик мусора Go по умолчанию ничего не знает о вашем лимите. При GOGC=100 он позволяет куче вырасти примерно вдвое относительно живых данных, прежде чем начнёт сборку, так что сервис с 300 МБ живых данных может дойти до 600 МБ, и это не будет означать никакой неисправности.

Это исправляют две настройки, и обе - переменные окружения на вкладке Startup:

ПеременнаяПо умолчаниюЧто делает
GOGC100Рост кучи до сборки, в процентах
GOMEMLIMITнетМягкий потолок общей памяти рантайма Go
GOMAXPROCSлогические CPUПотоки, одновременно выполняющие код Go

GOMEMLIMIT принимает размер с суффиксом единицы, например GOMEMLIMIT=800MiB. Это мягкий лимит: по мере приближения к нему сборщик работает усерднее, чтобы остаться под ним, обменивая CPU на память. Задавайте его ниже лимита контейнера, а не вровень с ним, потому что память расходует не только куча Go - считаются также стеки, сам рантайм и аллокации cgo. Хорошее начало - примерно на 10-20 процентов ниже памяти тарифа. На тарифе с 1 ГБ это GOMEMLIMIT=800MiB.

Мягкий лимит не лечит утечку. Если живые данные действительно превышают лимит, сборщик работает почти непрерывно, CPU растёт, задержки увеличиваются, и вместо чистого сбоя вы получаете смертельную спираль. Если график в консоли показывает устойчивый рост памяти после того, как трафик выровнялся, ищите утечку (неограниченная map, горутина на каждый запрос, которая никогда не завершается, не закрытое тело ответа), а не повышайте лимит.

GOGC работает в паре с ним. Если снизить его до 50, сборка идёт чаще, памяти уходит меньше, но больше тратится CPU. Если поднять до 200, всё наоборот. При заданном GOMEMLIMIT можно даже поставить GOGC=off и пусть сборкой управляет один лимит, но это для сервисов, у которых живая куча мала и хорошо изучена. Оставляйте GOGC по умолчанию, пока график не даст вам повод его менять.

CPU жёстко ограничен долей, которую вы купили: тариф с 0.5 vCPU гоняет ваши потоки с этой скоростью, что бы ни думал рантайм. До Go 1.25 значением GOMAXPROCS по умолчанию было число логических CPU машины, а не квота, что может означать больше потоков планировщика, чем контейнер способен использовать, и зависания при троттлинге; более поздние версии учитывают квоту контейнера. Если вы собираете более старой версией Go, задайте GOMAXPROCS равным целому числу ядер, которое даёт ваш тариф, или импортируйте go.uber.org/automaxprocs.

Память для сборки - отдельный вопрос. Компилятор Go намного легче релизной сборки Rust, но не бесплатен, а сборка происходит в том же контейнере при каждом старте. Цифры из опыта, а не обещания:

ПроектХолодная сборкаПик памяти при сборке
Небольшой API, десяток зависимостей20-60 секунд300-500 МБ
Типичный сервис, несколько десятков модулей1-3 минуты500-900 МБ
Большое дерево, сгенерированный код, крупные protobuf3-8 минут1 ГБ и больше

На самом маленьком тарифе типичный сервис собирается, но большое дерево зависимостей может упереться в лимит, и сборка будет остановлена посреди компиляции, что выглядит как перезапуск, а не как медленная сборка. Если так произошло, go build -p 1 (или GOFLAGS=-p=1) компилирует по одному пакету за раз и снижает пик памяти ценой времени. Кроме того, сократите то, что импортируете: зависимость, которая тянет облачный SDK ради одной функции, - обычный виновник. Для релизных сборок -ldflags="-s -w" убирает символы и отладочную информацию, уменьшая бинарник на четверть и более и не меняя потребление памяти во время работы.

Что может пойти не так#

Страница открывается на ноутбуке и зависает по таймауту на сервере. Привязка к localhost. Слушайте :PORT или 0.0.0.0:PORT.

`listen tcp :8080: bind: address already in use`. Либо запущена вторая копия вашей программы, либо вы прописали в коде порт, который не совпадает с выделенным. Читайте переменную окружения.

Сервер не проходит первый старт, а в консоли ошибка компилятора. Сборка не удалась. Воспроизведите локально командой go build ./... на чистой копии репозитория; обычные причины - отсутствующая запись в go.sum, неиспользуемый импорт или незакоммиченный файл.

`go: updates to go.mod needed; to update it: go mod tidy`. Ваши импорты и go.mod расходятся. Выполните go mod tidy локально и закоммитьте оба файла.

`go.mod requires go >= 1.24 (running go 1.23)`. Строка go новее, чем тулчейн. Понизьте её до версии, которая вам действительно нужна, или переключите линейку на версию Go, которая ей удовлетворяет.

Сборка убита без ошибки Go. Компилятору не хватило памяти. Попробуйте go build -p 1, уберите тяжёлые зависимости или перейдите на тариф выше для плана, на котором вы собираете.

Перезапуски каждые несколько минут, график памяти растёт. Упираетесь в лимит. Задайте GOMEMLIMIT, а затем ищите утечку. Помните, что три перезапуска за час вызывают предупреждение на странице сервера, а шесть ведут к приостановке.

`dial tcp: lookup db: no such host`. Хост базы данных неверен для этого окружения. Используйте хост и порт, которые дали панель или тариф базы данных, а не имя из вашего локального compose-файла.

У всех посетителей в логах один и тот же IP. Вы читаете r.RemoteAddr. Читайте X-Forwarded-For, как описано выше.

`x509: certificate signed by unknown authority` при исходящих вызовах. В окружении нет пригодного набора CA, либо вызываемый вами сервер использует частный CA. Проверьте исходящий запрос уже при первом деплое, чтобы столкнуться с этим пораньше.

Запросы зависают, а потом падают после долгого ожидания. Обработчики делают внешние вызовы без дедлайна. Используйте http.Client{Timeout: ...} и передавайте r.Context() в вызовы базы данных и HTTP, чтобы отменённый запрос прекращал свою работу.

FAQ#

Какую версию Go выбрать?

Самую низкую из тех, что действительно нужны вашему коду, записанную в строке go файла go.mod. Всё, что вы пишете на 1.22 и новее, получает ServeMux с поддержкой методов, а в 1.21 появился log/slog. Проверьте версию, которую предоставляет линейка Go, и держите директиву go на её уровне или ниже.

Нужны ли мне chi или gin?

Нет. Стандартной библиотеки хватает для большинства API, а обработчики, написанные под неё, переносятся на chi без изменений. Добавляйте роутер, когда нужны группы маршрутов и цепочки middleware, а gin - когда нужны его биндинг и валидация запросов.

Как получить настоящий IP клиента?

Читайте X-Forwarded-For, который выставляет прокси, и берите последнюю добавленную им запись, а не первую. r.RemoteAddr - это адрес прокси, и он одинаков для всех посетителей.

Почему перезапуск идёт дольше, чем у приложения на Node или Python?

Сервер выполняет go build при каждом старте, поэтому перезапуск включает компиляцию. Она быстрая, когда изменилось немногое, и медленнее при холодном старте. Скромный набор зависимостей сохраняет её короткой.

Можно ли запускать миграции при старте?

Можно, и для игрушечного проекта это нормально. Для чего-то серьёзного запускайте миграции отдельным шагом, потому что сервис, застрявший в цикле перезапусков, будет снова и снова пытаться применить то же наполовину выполненное изменение.

Нужно ли задавать GOMEMLIMIT?

Да, если ваш сервис держит заметный объём кучи. Задайте его на 10-20 процентов ниже памяти тарифа, чтобы сборщик начал работать плотнее до того, как контейнер будет остановлен. Это не даёт сборщику мусора считать всю машину своей.