pprof оказался доступен снаружи: разделяем маршруты Go-сервиса
Для расследования роста памяти в Go-сервис добавили импорт net/http/pprof. Профили появились по /debug/pprof, но вместе с ними диагностический маршрут открылся на публичном порту. Причина проста: приложение запускало HTTP-сервер с nil Handler, а этот режим использует общий DefaultServeMux, куда pprof регистрирует свои обработчики.
Сначала разделите маршрутизацию
Профили могут раскрывать устройство процесса и создавать нагрузку при сборе. Поэтому диагностический интерфейс должен иметь отдельную границу доступа. Удобная исходная схема — явный ServeMux для бизнес-API и отдельный ServeMux для диагностики. Сервер API получает только первый. Диагностический сервер слушает loopback или выделенный административный адрес с сетевыми ограничениями.
При импорте net/http/pprof происходит регистрация в DefaultServeMux. Сам импорт не заставляет ваш собственный mux обслуживать эти пути. Явное назначение Handler позволяет сохранить контроль: публичный сервер не должен случайно перейти на DefaultServeMux после рефакторинга. На диагностический mux регистрируют только действительно нужные профили и команды.
Проверьте два mux без открытия порта
Пример добавляет бизнес-маршрут в public и goroutine-профиль в debug. Один и тот же запрос проходит через оба обработчика. Public отвечает 404, debug возвращает профиль. httptest позволяет проверить разделение маршрутов без запуска серверов и без сбора длительного CPU-профиля. Код использует поддержку method patterns в ServeMux, доступную с Go 1.22.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/pprof"
)
func main() {
public := http.NewServeMux()
public.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })
debug := http.NewServeMux()
debug.Handle("GET /debug/pprof/goroutine", pprof.Handler("goroutine"))
request := func(h http.Handler) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/debug/pprof/goroutine", nil))
return w
}
hidden := request(public)
visible := request(debug)
fmt.Println(hidden.Code)
fmt.Println(visible.Code)
fmt.Println(visible.Body.Len() > 0)
}
Результат 404/200/true показывает, что профиль доступен только на нужном mux и содержит байты профиля. Программа не печатает весь профиль в консоль. В рабочем сервисе debug назначается Handler отдельного http.Server. Для локального сбора можно слушать 127.0.0.1 и подключаться через разрешённый туннель или административный доступ.
Проверьте сеть, а не только код
Отдельный порт сам по себе не делает интерфейс закрытым. Привязка к :6060 слушает все интерфейсы, а публикация контейнерного порта может сделать его доступным вне узла. Проверьте bind address, правила ingress, Docker ports и сетевые политики. Если нужен удалённый доступ нескольких операторов, используйте согласованную аутентификацию и административный прокси, сохранив отдельный набор маршрутов.
Если зарегистрировать только goroutine, ссылка с корневого индекса pprof на CPU profile не создаст такой маршрут автоматически в вашем mux. Для CPU и trace используются отдельные обработчики pprof.Profile и pprof.Trace; cmdline и symbol также подключаются отдельно. Это полезное свойство явной регистрации: вы видите доступную поверхность. Не выдавайте загруженный индекс за проверку каждого диагностического endpoint.
Длительность сбора CPU-профиля и таймаут сервера должны быть согласованы. Слишком короткий WriteTimeout оборвёт сбор раньше выбранного seconds. Одновременно не стоит предоставлять произвольному пользователю возможность запускать долгую диагностику снова и снова. Параллельные диагностические запросы и ограничения сборщика требуют отдельной операционной процедуры.
При расследовании начните с вопроса: растёт число goroutine, удерживаемая память или CPU? Выберите профиль под этот симптом и снимите сопоставимые состояния до и после нагрузки. Отдельный диагностический интерфейс нужен именно для управляемого сбора, а не для постоянной выгрузки всех внутренних данных в публичный API. Сохраняйте профили как диагностические материалы с ограниченным доступом.
Добавьте регрессионный тест полного публичного mux на /debug/pprof/, /debug/pprof/goroutine и /debug/pprof/profile. Затем проверьте сетевой путь из окружения, похожего на пользовательское. Внутренний unit-тест подтверждает регистрацию маршрутов; проверка публикации порта подтверждает инфраструктурную границу. Для остановки обоих HTTP-серверов пригодится управляемое завершение сервиса.
Сохраните диагностический путь при выпуске
Проверьте, как оператор получит профиль после обновления контейнера: имя порта, способ подключения и место сохранения результата должны оставаться известными. Автоматическая проверка публичного пути пригодится на стенде после изменения ingress. Для самого debug-сервера задайте контролируемое завершение вместе с основным процессом и обрабатывайте ошибку запуска: занятый порт не должен оставаться незамеченным. Метрика готовности бизнес-API при этом отражает его способность принимать полезную работу. Отсутствующий административный порт можно сигнализировать отдельно, чтобы способ диагностики не подменял критерий доступности пользовательской операции.
Продолжите до взаимодействия сервисов
Профиль помогает исследовать один процесс; поведение всей операции видно через обращения к базе и соседним приложениям. В платном курсе микросервисов на Go вы будете читать логи, метрики и трассировки, проверять сценарии отказа и запускать примеры через Docker Compose. Это продолжает диагностику до согласованной работы нескольких сервисов. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
