Неделя 8: observability на zap, ELK, Prometheus, Grafana, OpenTelemetry
Финальная неделя курса. До этого момента никто не знал, что происходит внутри системы под нагрузкой. Логи разбросаны, метрик нет, трейсы отсутствуют. Эта неделя превращает систему из “работает на ноуте” в “работает в проде и видно почему”.
Цели недели
- Заменить временный логгер на zap, всё через
platform/logger. - Поднять ELK-стек, собирать логи всех сервисов в Kibana.
- Подключить Prometheus, экспортировать базовые и бизнес-метрики.
- Поднять Grafana, нарисовать дашборды.
- Настроить алерт в Telegram при превышении порога заказов.
- Внедрить OpenTelemetry, видеть распределённые трейсы.
Три столпа observability
| Столп | Что | Кто отвечает на вопрос |
|---|---|---|
| Logs | события | “что именно произошло?” |
| Metrics | числа | “что изменилось со временем?” |
| Traces | путь запроса | “где именно потерялись 500 мс?” |
Хороший production-сервис имеет все три. Без логов, не дебагнуть incident. Без метрик, не понять что что-то сломалось до того как пожалуются клиенты. Без трейсов, не найти узкое место в распределённой системе.
1. Логи через zap
zap, один из самых быстрых Go-логгеров. Альтернативы, slog (stdlib, Go 1.21+) и zerolog. В курсе zap, потому что в boilerplate уже есть, но slog работает не хуже и идиоматичнее.
platform/logger на zap
// platform/logger/zap.go
type zapLogger struct {
inner *zap.Logger
}
func New(level, format string) (Logger, error) {
cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(parseLevel(level))
if format == "text" {
cfg.Encoding = "console"
}
cfg.EncoderConfig.TimeKey = "ts"
cfg.EncoderConfig.MessageKey = "msg"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
inner, err := cfg.Build()
if err != nil { return nil, err }
return &zapLogger{inner: inner}, nil
}
func (l *zapLogger) Info(msg string, fields ...zap.Field) {
l.inner.Info(msg, fields...)
}
// ... Debug, Warn, Error, With аналогично
func String(key, value string) zap.Field { return zap.String(key, value) }
func Int(key string, value int) zap.Field { return zap.Int(key, value) }
func Err(err error) zap.Field { return zap.Error(err) }
Использование
log.Info("order created",
logger.String("order_uuid", order.UUID),
logger.String("user_uuid", order.UserUUID),
logger.Int("part_count", len(order.PartUUIDs)),
)
Запрещено:
log.Info(fmt.Sprintf("order %s created for %s", order.UUID, order.UserUUID))
Это ломает structured logging, в Kibana по такому полю не отфильтруешь.
Вывод в stdout JSON
Production-конфиг zap по умолчанию выдаёт JSON. Это нужно для ELK-парсера:
{"ts":"2026-05-02T10:00:00Z","level":"info","msg":"order created","order_uuid":"...","user_uuid":"..."}
В docker-compose сервисы пишут в stdout, Docker сам пишет в /var/lib/docker/containers/.../*-json.log. Logstash оттуда забирает.
2. ELK-стек
Elastic stack: Elasticsearch (хранилище), Logstash (сборщик и трансформер), Kibana (UI). Альтернатива, Loki + Grafana (легче и дешевле). В курсе ELK.
docker-compose
services:
elasticsearch:
image: elasticsearch:8.13.4
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
ports: ["9200:9200"]
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
networks: [microservices_net]
logstash:
image: logstash:8.13.4
volumes:
- ./logstash/pipeline:/usr/share/logstash/pipeline
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on: [elasticsearch]
networks: [microservices_net]
kibana:
image: kibana:8.13.4
ports: ["5601:5601"]
environment:
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
depends_on: [elasticsearch]
networks: [microservices_net]
Logstash pipeline
# logstash/pipeline/logstash.conf
input {
gelf { port => 12201 }
}
filter {
json { source => "message" }
if [container_name] {
mutate { add_field => { "service" => "%{container_name}" } }
}
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
index => "services-%{+YYYY.MM.dd}"
}
}
В сервисах добавляется logging.driver: gelf в docker-compose, чтобы docker сам слал логи в Logstash. Альтернатива, Filebeat читающий /var/lib/docker/containers/.../*.log.
Что смотреть в Kibana
- частота error-логов по сервисам,
- top endpoint по числу запросов,
- top trace_id по latency (когда добавим OpenTelemetry, можно будет связать),
- частота определённых типов ошибок (“inventory unavailable”).
3. Метрики через Prometheus
client_golang, официальный Go-клиент.
platform/prometheus
// platform/prometheus/middleware.go
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
[]string{"service", "method", "path", "status"},
)
httpDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 14),
},
[]string{"service", "method", "path"},
)
httpInFlight = prometheus.NewGauge(
prometheus.GaugeOpts{Name: "http_requests_in_flight", Help: "In-flight HTTP requests"},
)
)
func init() {
prometheus.MustRegister(httpRequestsTotal, httpDuration, httpInFlight)
}
func HTTPMiddleware(service string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
httpInFlight.Inc()
defer httpInFlight.Dec()
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(ww, r)
dur := time.Since(start).Seconds()
httpRequestsTotal.WithLabelValues(service, r.Method, r.URL.Path, fmt.Sprint(ww.status)).Inc()
httpDuration.WithLabelValues(service, r.Method, r.URL.Path).Observe(dur)
})
}
}
/metrics endpoint регистрируется через promhttp.Handler():
mux.Handle("/metrics", promhttp.Handler())
Бизнес-метрики
В OrderService:
var (
ordersTotal = prometheus.NewCounter(
prometheus.CounterOpts{Name: "orders_total", Help: "Total created orders"},
)
ordersRevenue = prometheus.NewCounter(
prometheus.CounterOpts{Name: "orders_revenue_total", Help: "Total revenue in cents"},
)
)
// в Create:
ordersTotal.Inc()
// в Pay:
ordersRevenue.Add(float64(order.TotalCents))
В AssemblyService:
var assemblyDuration = prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "assembly_duration_seconds",
Help: "Time to assemble a ship",
Buckets: []float64{1, 5, 10, 30, 60, 120, 300},
},
)
Prometheus в compose
prometheus:
image: prom/prometheus:v2.52.0
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports: ["9090:9090"]
networks: [microservices_net]
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'order'
static_configs:
- targets: ['order:8080']
labels: { service: 'order' }
- job_name: 'auth'
static_configs:
- targets: ['auth:8080']
labels: { service: 'auth' }
# ... и т.д. для остальных
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.