Структура проекта, gRPC, брокеры, Docker/K8s
Словарь окружения бэкенда: как раскладывать проект, как профилировать живой процесс, чем gRPC отличается от REST, устройство брокеров сообщений изнутри и минимум Docker/Kubernetes.
Структура проекта: cmd/, internal/, pkg/
cmd/ — точки входа: по одному main.go на каждый отдельный бинарник, который производит этот модуль (если сервис собирает и веб-сервер, и отдельную CLI-утилиту миграций — у каждого свой подкаталог внутри cmd/).
internal/ — специальная директория, поведение которой ЗАШИТО В САМ КОМПИЛЯТОР Go, а не является соглашением сообщества: импортировать пакеты из internal/ можно только из кода, лежащего в том же дереве модуля (точнее — на уровне или ниже родителя internal/). Попытка импортировать internal/-пакет из другого модуля не работает и не компилируется, а не просто "не принято" — это единственный механизм инкапсуляции на уровне пакетов, встроенный в сам язык.
pkg/ — для сравнения, просто СОГЛАШЕНИЕ, компилятор его никак не проверяет и не ограничивает: туда кладут код, реально предназначенный для переиспользования другими проектами (например, публичный SDK-клиент к своему API). В небольших сервисах, у которых нет внешних потребителей кода, pkg/ часто вообще не нужен — не стоит заводить его "для порядка", если переиспользовать код всё равно некому.
main.go держат МАКСИМАЛЬНО тонким — по сути только сборка зависимостей (создать репозиторий, сервис, хендлер, связать их вместе) и вызов http.ListenAndServe. Причина: main.go — это точка входа процесса, а не обычный пакет, поэтому написать для него нормальный unit-тест затруднительно (пришлось бы реально поднимать процесс). Вся содержательная логика выносится в internal-пакеты именно для того, чтобы её можно было протестировать независимо, без запуска всего бинарника целиком.
pprof — профилирование без остановки процесса
Импорт пакета net/http/pprof ради его побочного эффекта (import _ "net/http/pprof") автоматически регистрирует набор HTTP-хендлеров под /debug/pprof/ на уже существующем сервере — снять профиль можно у РЕАЛЬНО РАБОТАЮЩЕГО процесса в проде, не останавливая и не перезапуская его.
Три типа профиля закрывают разные вопросы:
- CPU-профиль (
/debug/pprof/profile?seconds=30) — где именно тратится процессорное время за указанный период. Снимается командойgo tool pprof http://host/debug/pprof/profile?seconds=30. - Heap-профиль (
/debug/pprof/heap) — текущее распределение выделенной памяти по функциям-аллокаторам. Способ найти утечку памяти. - Goroutine-профиль (
/debug/pprof/goroutine) — список всех живых горутин со стек-трейсами каждой. Способ найти утечку горутин (см. главу про конкурентность).
Практический ответ на вопрос "как искать утечку памяти в проде", а не абстрактное "профилировать": снять heap-профиль (или goroutine-профиль, если подозревается утечка горутин) несколько раз подряд с интервалом в несколько минут и сравнить их между собой командой go tool pprof -base=old.prof new.prof. Растущее без остановки число объектов/горутин, которое не выходит на плато между снимками, — прямой признак утечки; если число колеблется, но в среднем стабильно — это нормальная работа GC, не утечка.
gRPC и Protobuf vs REST
REST — не протокол, а СОГЛАШЕНИЕ поверх обычного HTTP: ресурсы адресуются URL, действия над ними кодируются HTTP-методами (GET/POST/PUT/DELETE), сервис не хранит состояние клиента между запросами (stateless).
Более старый предшественник — SOAP, всегда работающий через XML, где контракт сервиса формально описан в отдельном документе WSDL, а сам вызываемый метод закодирован ВНУТРИ тела запроса, а не в URL, как в REST. SOAP почти полностью вытеснен REST/gRPC в новых системах, но всё ещё встречается в интеграциях со старыми корпоративными системами (банки, госструктуры).
Транспортная разница важна для понимания, зачем вообще нужен gRPC: HTTP/1.1 обрабатывает несколько запросов в одном TCP-соединении строго по очереди — если один запрос завис, следующие за ним в том же соединении ждут (head-of-line blocking).
HTTP/2 решает это мультиплексированием — несколько независимых логических потоков внутри ОДНОГО TCP-соединения, зависание одного не тормозит остальные. gRPC построен НА HTTP/2 (не поверх HTTP/1.1), поэтому естественным образом поддерживает потоковую передачу (streaming) в обе стороны без дополнительных костылей вроде long-polling или WebSocket.
Второе, ради чего выбирают gRPC — формат данных. Protobuf сжимает передаваемые байты двумя независимыми механизмами одновременно:
- Вместо текстовых имён полей (как в JSON —
"user_id"каждый раз заново) по сети идут только ЧИСЛОВЫЕ НОМЕРА полей из заранее согласованной.proto-схемы, известной обеим сторонам ДО обмена данными. - Сами числа кодируются не фиксированным числом байт, а varint-схемой переменной длины: маленькое число занимает 1 байт, большое — несколько.
Видно на реальных числах: 1 и 127 умещаются в 1 байт, 128 и 300 требуют уже 2, миллион — 3, и это всё равно меньше 8 байт, которые фиксированный uint64 занимал бы ВСЕГДА, независимо от реального значения. Для типичного API, где большинство целочисленных полей (счётчики, ID, статусы) на практике небольшие числа, суммарная экономия по всему сообщению получается заметной.
Контракт .proto-схемы проверяется КОМПИЛЯЦИЕЙ клиента и сервера из одного и того же файла — рассинхрон между ними физически не может произойти незаметно, потому что оба генерируются из одного источника. У REST же контракт (например, OpenAPI-спецификация) — отдельный документ, который ничто не мешает разойтись с реальным кодом хендлера, если про него забыли при рефакторинге.
Важная оговорка, которую обязательно нужно проговорить: бинарный формат ≠ шифрование. Protobuf — это просто более плотная бинарная упаковка данных, а не защита от чтения содержимого: тот, кто перехватит трафик, при знании схемы легко декодирует сообщение обратно в читаемые поля. Конфиденциальность передачи даёт TLS поверх HTTP/2 (то есть по сути https, только для gRPC), а не сам факт того, что формат бинарный, а не текстовый.
Kafka vs RabbitMQ — устройство изнутри
Kafka-топик физически разбит на несколько ПАРТИЦИЙ — каждая партиция это отдельный, строго упорядоченный append-only лог (данные только дописываются в конец, никогда не переставляются и не удаляются точечно). Строгий порядок сообщений Kafka гарантирует ТОЛЬКО внутри одной партиции — между сообщениями из разных партиций порядок не гарантирован вообще.
Producer при отправке сообщения хэширует его ключ, и результат хэша определяет, в какую партицию попадёт сообщение — один и тот же ключ ВСЕГДА попадает в одну и ту же партицию (пока не меняется число партиций топика). Отсюда практическое правило, которое стоит помнить не как абстракцию, а как готовый рецепт: если важен порядок событий одной сущности (например, все статусы одного заказа должны обрабатываться по порядку), в качестве ключа сообщения берут order_id/user_id — тогда все события этой сущности физически лежат в одной партиции и гарантированно читаются по порядку.
Consumer group — механизм горизонтального масштабирования чтения: каждая партиция топика в любой момент времени вычитывается РОВНО ОДНИМ консьюмером внутри одной группы, само распределение партиций между консьюмерами Kafka берёт на себя, явная синхронизация между инстансами не нужна. При добавлении или падении консьюмера запускается rebalance — партиции перераспределяются заново между оставшимися живыми участниками группы.
RabbitMQ устроен принципиально иначе: producer публикует сообщение не напрямую в очередь, а в exchange, а exchange по своим правилам маршрутизации (routing key) решает, в какие очереди сообщение реально попадёт.
Topic-exchange матчит routing key по маске с двумя видами wildcard: * заменяет ровно один сегмент пути (orders.* совпадёт с orders.created, но не с orders.eu.created), # — любое число сегментов, включая ноль (orders.# совпадёт с обоими). Fanout-exchange routing key вообще игнорирует и рассылает сообщение во ВСЕ привязанные к нему очереди — классический broadcast.
Подтверждение обработки в RabbitMQ — ack/nack(requeue=true) на уровне отдельного сообщения: сам факт того, что consumer прочитал сообщение, ещё не значит, что оно удалено из очереди — удаление происходит только после явного ack.
offset в Kafka — это позиция сообщения ТОЛЬКО внутри его собственной партиции, не сквозной номер по всему топику. У топика с 10 партициями существует 10 независимых последовательностей offset'ов, а не одна общая — Kafka в принципе не гарантирует и не пытается поддерживать единый глобальный порядок по топику целиком, только по каждой партиции в отдельности.
Prometheus, Grafana, Swagger
Prometheus работает по модели PULL, а не push: он сам с заданным интервалом обращается к каждому отслеживаемому сервису по HTTP-эндпоинту /metrics и забирает текущие значения — сервисы ничего никуда сами не отправляют, только пассивно отдают своё текущее состояние по запросу.
Три базовых типа метрик закрывают разные вопросы:
- Counter — монотонно растущее значение (число обработанных запросов, число ошибок). Смотреть на сырое значение счётчика почти никогда не осмысленно (оно просто вечно растёт) — вместо этого строят
rate(metric[5m]), скорость роста за окно времени, то есть фактически "запросов в секунду за последние 5 минут". - Gauge — текущее значение, которое может как расти, так и падать (число открытых соединений, использование памяти прямо сейчас).
- Histogram — распределение наблюдений по заранее заданным диапазонам (бакетам), из которого можно вычислить процентили — например, p95-время ответа: "95% запросов уложились быстрее этого значения".
Grafana сама метрики никак не собирает и не хранит — это чисто визуализация: она подключается к Prometheus (или другому источнику данных) и строит дашборды из результатов запросов к нему.
Swagger/OpenAPI — машиночитаемая спецификация REST API (в YAML/JSON), из которой можно автоматически сгенерировать и человекочитаемую документацию, и клиентский код на разных языках. Главная практическая проблема такой спецификации — рассинхрон с реальным поведением кода, если её ведут вручную отдельным файлом; типичное решение — генерировать саму спецификацию ИЗ кода через аннотации в комментариях к хендлерам, а не поддерживать два независимых источника истины параллельно.
Логи ≠ метрики, это разные инструменты под разные вопросы. Метрики отвечают на вопрос "сколько и как быстро" агрегированно (сколько запросов в секунду, какой p99 latency) — по одной метрике нельзя понять, что случилось с конкретным запросом. Логи отвечают на вопрос "что именно произошло" в конкретном случае — структурированная (обычно JSON) запись, которую ищут по конкретному полю в системе вроде Loki/ELK, а не перебором grep по неструктурированному тексту.
Docker и Kubernetes
Docker. Образ — неизменяемый шаблон файловой системы и метаданных; контейнер — запущенный из этого образа процесс со своим изолированным окружением. Multi-stage build решает конкретную практическую проблему: тяжёлый toolchain для СБОРКИ (компилятор Go, кэши модулей — сотни мегабайт) не должен попадать в финальный образ, который реально едет в прод:
# Стадия 1: тяжёлый toolchain, нужен только для сборки
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download # отдельным слоем ДО копирования всего кода
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
# Стадия 2: только результат сборки, без toolchain
FROM alpine:3.20
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Порядок инструкций здесь важен не эстетически, а из-за кэширования слоёв Docker: COPY go.mod go.sum и go mod download стоят ДО COPY . . намеренно — если сначала скопировать весь код, то ЛЮБОЕ изменение хотя бы одной строчки в исходниках инвалидирует кэш этого слоя и всех следующих за ним, включая повторное скачивание всех зависимостей заново, даже если сам go.mod не менялся.
CGO_ENABLED=0 обязателен именно при сборке под alpine (который использует musl вместо glibc) — без этого флага бинарник может слинковаться динамически с glibc-зависимостями, которых на alpine физически нет, и запуск падает с обманчивой ошибкой exec: no such file or directory, хотя файл на месте и никуда не делся (ошибка на самом деле про отсутствующий динамический линковщик/библиотеку, а не про сам файл).
ARG в Dockerfile доступен только во ВРЕМЯ СБОРКИ образа; ENV — и во время сборки, и потом в рантайме уже запущенного контейнера.
Kubernetes. Pod — минимальная единица развёртывания (обёртка вокруг одного или нескольких плотно связанных контейнеров, обычно всё же одного). Deployment — декларативное описание желаемого состояния ("хочу N реплик этого пода постоянно") — контроллер Deployment сам следит и пересоздаёт под, если тот упал, без ручного вмешательства.
Service — стабильный сетевой адрес поверх набора подов, выбранных по label-селектору: IP конкретного пода меняется при каждом его пересоздании, Service эту нестабильность полностью скрывает от вызывающего кода. Ingress — маршрутизация входящего HTTP-трафика по домену/пути к нужному Service — один общий вход вместо отдельного LoadBalancer на каждый сервис в кластере.
livenessProbe и readinessProbe отвечают на РАЗНЫЕ вопросы и по-разному реагируют на провал:
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
# readinessProbe не задан вообщеlivenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /ready # отдельная ручка: "прогрелся ли кэш, готов ли принимать трафик"
initialDelaySeconds: 15
periodSeconds: 5livenessProbe отвечает на вопрос "жив ли процесс вообще" — провал означает, что Kubernetes считает под сломанным и ПЕРЕЗАПУСКАЕТ его. readinessProbe отвечает на другой вопрос — "готов ли под ПРЯМО СЕЙЧАС принимать реальный трафик" — провал НЕ вызывает рестарт, под просто временно исключается из Service (трафик на него перестаёт идти), пока проверка снова не станет успешной.
Классический баг, который стоит уметь объяснить своими словами, а не просто процитировать определение: под с долгим прогревом кэша при старте, у которого настроен только livenessProbe (левая колонка выше) — Kubernetes считает под живым сразу после старта процесса и немедленно начинает слать через Service реальный пользовательский трафик, хотя приложение ещё не прогрело кэш и будет отвечать медленно или ошибками на первые запросы. Правая колонка с отдельным readinessProbe держит под вне Service ровно до момента, когда он реально готов, — без единой строчки кода приложения, только конфигурацией.
Библиотеки Go: драйверы, брокеры, JSONP
Для Postgres есть два основных драйвера с разной философией: lib/pq реализует только стандартный интерфейс database/sql и ничего сверх этого; pgx реализует тот же стандартный интерфейс, но дополнительно даёт собственный расширенный API — например, COPY protocol для массовой быстрой вставки данных и LISTEN/NOTIFY для получения уведомлений от самой БД — то, что через голый database/sql в принципе недоступно, потому что этот стандартный интерфейс не знает про специфичные для конкретной СУБД возможности.
Для Kafka: segmentio/kafka-go — чистая Go-реализация протокола без использования C-кода; Sarama — самая старая из популярных, тоже без cgo; confluent-kafka-go — Go-обёртка вокруг существующей C-библиотеки librdkafka через механизм cgo. Она обычно самая быстрая из трёх (librdkafka — очень оптимизированная и давно существующая библиотека), но платит за это требованием CGO_ENABLED=1 при сборке, что усложняет кросс-компиляцию и multi-stage Docker-сборки на минимальные образы вроде scratch.
fasthttp — альтернатива стандартному net/http, которая переиспользует объекты запроса/ответа между вызовами вместо создания новых на каждый запрос (снижает нагрузку на аллокатор и GC под высокой нагрузкой), но за счёт этого несовместима с интерфейсом http.Handler целиком — переход на неё means переписать хендлеры под другой API, а не просто заменить импорт.
JSONP — исторический способ обойти Same-Origin Policy браузера ДО того, как появился CORS: вместо чистого JSON сервер возвращает JavaScript-код, вызывающий заранее согласованную функцию с данными в качестве аргумента, а тег <script src="..."> в HTML не подчиняется Same-Origin Policy (в отличие от fetch/XMLHttpRequest). Ограничение — работает только для GET-запросов, потому что физически это загрузка скрипта, а не произвольный HTTP-запрос с телом. Сегодня JSONP — практически исключительно legacy-механизм, вытесненный CORS-заголовками.