Содержание урока · 10 разделов
- HTTP метрики go: семейство InstrumentHandler
- Порядок слоёв и что видит каждый
- Инцидент: пятисотые, которых не было
- Когда готовых слоёв мало
- Семейство InstrumentHandler целиком
- In-flight растёт: трафик или зависшие хендлеры?
- Спорное мнение: готовые слои переоценены
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Метрика HTTP-запроса — самая ходовая в любом дашборде, и заводят её всегда одной пачкой. Трафик: сколько запросов в секунду и по каким маршрутам. Ошибки: доля пятисотых и четвёртых ответов. Длительность: распределение времени ответа и p99 на графике. Плюс пара приправ: число запросов в обработке прямо сейчас и размеры тел запросов-ответов. Триплет «трафик, ошибки, длительность» в мониторинге зовут RED-методом, и его хватает, чтобы заметить и деградацию, и падение. Писать замер времени руками в каждом хендлере — путь к рассинхрону: в одном хендлере defer, в другом забыли, третий меряет время до записи ответа, а четвёртый называет метрику иначе. Middleware решает проблему структурно: одна обёртка вокруг роутера гарантирует замер всем маршрутам, а типы и имена метрик живут в одном месте. Именно так устроены готовые слои promhttp — и именно поэтому собственная обёртка повторяет их форму.
HTTP метрики go: семейство InstrumentHandler#
Пакет promhttp умеет оборачивать http.Handler готовыми слоями, и у всех один знаменатель сигнатуры: InstrumentHandlerX(метрика, next http.Handler) http.Handler. Метрика — не любая, а подходящего типа: счётчику запросов нужен CounterVec с лейблами code и method, гистограмме длительности — HistogramVec с теми же лейблами, in-flight — одиночный Gauge без лейблов. Обёртки проставляют значения сами: код ответа они достают из перехваченного WriteHeader, метод — из запроса, поэтому в хендлерах не появляется ни одной строки про метрики. Слои гнездятся: внешний замеряет длительность, внутренний считает запросы. Объявите в векторе ровно те лейблы, которые слой умеет заполнять: лишний или недостающий — и первый же запрос упадёт паникой inconsistent label cardinality. Поэтому новые связки обкатывают на стенде, а не в проде, и первым тестом после добавления слоя делают обычный прогон хендлера с чтением /metrics.
Два слоя над роутером
GOpackage main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var httpRequests = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Запросы по методу и статусу",
},
[]string{"method", "code"},
)
var httpDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Длительность обработки",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "code"},
)
func main() {
prometheus.MustRegister(httpRequests, httpDuration)
mux := http.NewServeMux()
mux.HandleFunc("/api", apiHandler)
mux.Handle("/metrics", promhttp.Handler())
wrapped := promhttp.InstrumentHandlerDuration(httpDuration,
promhttp.InstrumentHandlerCounter(httpRequests, mux),
)
http.ListenAndServe(":8080", wrapped)
}Два вектора с одинаковыми лейблами method и code: счётчик запросов и гистограмма длительности. Обёртки гнездятся: внешний InstrumentHandlerDuration меряет всё время обработки, внутренний InstrumentHandlerCounter инкрементит по итоговому статусу — код оба берут из перехваченного WriteHeader. /metrics добавлен в mux до оборачивания, чтобы скрейп не попадал в собственные метрики.
Порядок слоёв и что видит каждый#
Слои можно менять местами, и от порядка зависит семантика. Внешний замер длительности включает всё, что внутри: работу счётчика, логи, сам хендлер — так и надо, потому что латентность для пользователя одна. Если поставить замер внутрь, между счётчиком и хендлером, цифра почти не изменится: накладные расходы обёрток ничтожны по сравнению с сетью, но принципиальная схема остаётся — чем ближе слой к пользователю, тем полнее он видит время. С готовыми слоями порядок роли почти не играет; со своими — играет: middleware, который снимает лейбл маршрута, должен стоять после роутера, иначе маршрут ещё не сопоставлен и в лейбл уйдёт пустая строка. Композиция — сильная сторона net/http: recovery сверху, метрики под ним, свой лейбл маршрута ещё ниже — и каждый слой делает ровно одно дело, не зная о соседях.
Свой middleware: statusRecorder и Observe
GOtype statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rec, r)
elapsed := time.Since(start).Seconds()
code := strconv.Itoa(rec.status)
httpDuration.WithLabelValues(r.Method, code).Observe(elapsed)
})
}statusRecorder встраивает http.ResponseWriter и перехватывает WriteHeader: статус запоминается и передаётся оригинальному писателю, остальная поверхность ответа не меняется. Дефолт 200 повторяет соглашение net/http, где ответ без явного WriteHeader считается успешным. Для паникующих хендлеров замер дублируют в defer или ставят слой под recovery — иначе 500 от recovery не попадёт в распределение.
Инцидент: пятисотые, которых не было#
Инцидент, из которого выросло правило этого урока. Сервис под нагрузкой начал паниковать в одном хендлере: recovery перехватывал панику, писал 500, и всё выглядело штатно — доля ошибок в метриках почти не менялась, а поддержка была завалена жалобами. Причина: замер в middleware стоял после next.ServeHTTP, до которого паника не доживала, — Observe не выполнялся, и пятисотые ответы в гистограмме отсутствовали. Ошибка двойная: замер без defer и отсутствие отдельного счётчика ошибок. Чинится двумя строками: Observe в замыкании defer и инкремент при коде пятой сотни. Мораль шире кода: слой, который обещает метрики всем маршрутам, обязан метить и аварийные ответы — иначе самый интересный трафик, ради которого и ставят мониторинг, проходит мимо распределения молча. Проверьте это на стенде: хендлер с паникой — и посмотрите, какая серия родилась. Счётчик http_errors_total рядом с гистограммой — минимальный страховочный полис: когда два числа расходятся, виноват слой, который врёт.
Когда готовых слоёв мало#
Готовые слои знают про code и method, и всё. Как только появляется собственное измерение — шаблон маршрута, идентификатор тенанта, флаг эксперимента, версия API в заголовке, — наступает время собственной обёртки по образцу выше. Скелет всегда один: подменить ResponseWriter обёрткой, запоминающей статус; замерить время вокруг next.ServeHTTP; вычислить лейблы; отдать наблюдение нужному Observer. Три тонкости, на которых спотыкаются. Статус по умолчанию — 200: если хендлер не вызвал WriteHeader явно, в метрике должен оказаться 200, как и в реальном ответе. Паника в хендлере: если замер стоит после next.ServeHTTP без defer, 500-е от recovery-слоя никогда не попадут в гистограмму — а это самые интересные значения. Лейбл маршрута берите из роутера после сопоставления: в chi это chi.RoutePattern(r), в gin — c.FullPath(), и оба вернут пустую строку, если слой стоит выше маршрутизации. Шаблон, а не сырой путь: о кардинальности путей мы подробно говорили в уроке про лейблы.
Семейство InstrumentHandler целиком#
- InstrumentHandlerCounter — счётчик запросов по лейблам code и method.
- InstrumentHandlerDuration — длительность обработки в ObserverVec: гистограмме или summary.
- InstrumentHandlerInFlight — одиночный Gauge с числом запросов, находящихся в обработке прямо сейчас.
- InstrumentHandlerRequestSize и InstrumentHandlerResponseSize — размеры тела запроса и ответа в байтах, тоже через ObserverVec.
- InstrumentHandlerTimeToWriteHeader — время до записи первых заголовков: TTFB и зависшие хендлеры.
- Общий контракт: метрика с лейблами code и method (или их подмножеством), next-хендлер, на выходе http.HandlerFunc.
In-flight растёт: трафик или зависшие хендлеры?#
График in-flight обманывает всех, кто видит его впервые: рост линии читают как рост трафика. Не всегда. In-flight — число запросов в обработке прямо сейчас, и его рост означает произведение двух величин: пришедшего трафика и времени обработки. Трафик ровный, а линия ползёт вверх — значит, запросы обрабатываются дольше: заблокировалась база, завис внешний вызов, кончился пул соединений. Это самый ранний сигнал деградации — раньше пятисотых и роста p99: пул ещё держится, но свободных слотов всё меньше, и метрики горутин вскоре покажут то же самое. Практика: на панели in-flight рисуйте рядом rate запросов — если линии расходятся, виновата латентность, а не наплыв. Вторая крайность — паника на всякий рост: пик-час законно поднимает линию втрое; сравнивайте с прошлой неделей, а не с прошлым часом. Сама метрика почти бесплатна: пара атомарных операций на запрос.
Две грабли одного middleware-слоя. Первая: набор лейблов в векторе обязан совпасть с тем, что проставляет обёртка, — паника приходит на первом запросе, когда тестов нет, и валит хендлер, а не метрики. Вторая: /metrics тоже HTTP-маршрут. Роутер, обёрнутый целиком, метит и свой же скрейп: метод GET, код 200, каждые несколько секунд — это шум в сериях. Лечится порядком: /metrics регистрируется вне оборачиваемого mux или исключается из слоя инструментов.
Спорное мнение: готовые слои переоценены#
Спорное мнение, с которым согласны не все: в сервисе со своим роутером и логикой я чаще пишу один собственный middleware, чем собираю три готовых слоя. Аргумент: своя обёртка сразу даёт то, ради чего всё затевалось, — шаблон маршрута в лейбле, единые имена метрик, замер в defer, — а готовые слои дают code и method, после чего их всё равно приходится дополнять. Две обёртки поверх одной — двойной перехват WriteHeader и два места, где можно ошибиться. Граница, где мнение меняется: новый сервис без собственных измерений и прототип — берите InstrumentHandlerCounter и InstrumentHandlerDuration, это двадцать секунд работы и меньше собственного кода в ревью. Правило разветвления простое: как только появился первый собственный лейбл, весь HTTP-слой собирается в одном месте, а не в трёх разных. И один свой middleware проще объяснить новому человеку в команде, чем композицию из трёх обёрток.
Прежде чем выкатывать HTTP-слой, посчитайте серии на салфетке: маршрутов двадцать, методов четыре, статусов десять — счётчик даст до восьмисот серий, гистограмма с одиннадцатью бакетами — восемь тысяч. Для одного сервиса нормально, для двадцати — уже разговор о реестре. Лейбл сырого пути с идентификаторами — не «ещё один срез», а конец реестра: границам кардинальности посвящён семнадцатый урок.
Проверка: лейблы обёрток
Какие лейблы обёртки promhttp проставляют сами?
Проверка: in-flight
Какая метрика нужна InstrumentHandlerInFlight?
Какой код ответа попадёт в метрику?
Прочитайте код и выберите, что он выведет:
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
// внутри middleware:
rec := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rec, r)
code := strconv.Itoa(rec.status)Соберите слой из готовых блоков
Допишите вектор длительности и обёртку InstrumentHandlerDuration над роутером.
Ядро собственного middleware
Расставьте строки замера внутри metricsMiddleware: от старта таймера до наблюдения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: HTTP-метрики
Слой HTTP-метрик — костяк мониторинга сервиса, и собирать его теперь можно двумя способами: готовыми обёртками или собственной обёрткой с своими лейблами. Дальше по курсу метрики перестают быть техническими: в следующем уроке считаем бизнес-события — заказы, выручку и очереди платежей теми же Counter и Gauge. Для предыдущей части темы полезен урок про Summary — там объяснено, когда ObserverVec стоит заменить summary-версией. Роутер в примерах этого курса — чистый net/http; те же слои ложатся на chi без изменений, потому что chi.Router — обычный http.Handler, знакомство с ним в отдельном курсе. Полная программа — на странице Prometheus. И как всегда: весь код урока исполняется локальным Go — песочница сайта библиотечные пакеты не запускает; соберите сервис, постучите в хендлер curl-ом и посмотрите, какие серии родили обёртки.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Базовый набор HTTP-метрик: счётчик по code и method, гистограмма длительности, Gauge in-flight — RED-метод.
- Обёртки promhttp имеют сигнатуру InstrumentHandlerX(метрика, next) и сами проставляют code и method.
- Набор лейблов в векторе обязан совпасть с тем, что проставляет слой: ошибка — паника на первом запросе.
- Свой middleware — это statusRecorder, замер вокруг next.ServeHTTP и Observe в нужный Observer.
- Статус по умолчанию 200; замер и Observe ставят так, чтобы 500 от recovery тоже попадали в распределение.
- /metrics держат вне слоя инструментов, чтобы скрейп не метил сам себя.
Домашнее задание
Возьмите учебный сервис и соберите два варианта: первый — InstrumentHandlerCounter и InstrumentHandlerDuration над mux, второй — собственный middleware со statusRecorder и лейблом шаблона маршрута. Сравните /metrics после пары запросов: какие серии родились, какие лейблы заполнились. Затем уберите лейбл method из вектора и посмотрите на панику на стенде — этот сбой лучше увидеть до прода. Нужен локальный Go.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем InstrumentHandlerDuration отличается от своего middleware?
Только гибкостью: обёртка promhttp знает про code и method и ничего больше. Свой middleware нужен для собственных лейблов — шаблона маршрута, тенанта, версии API — и для нестандартных правил замера.
Почему метрика in-flight — Gauge, а не счётчик?
Это моментальная величина: количество запросов в обработке сейчас. Оно растёт и убывает, поэтому Inc и Dec на входе и выходе. Счётчик умеет только расти и показал бы суммарное число запросов, а не текущую нагрузку.
Нужно ли метрить /metrics?
Обычно нет: скрейп идёт каждые несколько секунд и генерирует стабильный шум в сериях. Регистрируйте /metrics вне оборачиваемого mux; если всё же нужен контроль доступа к экспозиции, заведите отдельные метрики скрейпа, а не общий слой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
WebSocket в Go: real-time чаты и уведомления
Курс WebSocket на Go: gorilla/websocket, эхо-сервер, hub-паттерн, комнаты, авторизация соединений, ping/pong, reconnect и масштабирование. 25 уроков.
- Поднимать WS-сервер и апгрейдить HTTP-соединения
- Писать readPump/writePump без гонок на каждое соединение
- Строить hub: регистрацию, рассылку и комнаты
- Авторизовывать соединения и ограничивать сообщения