Эту статью я написал по следам работы над шаблоном микросервиса на Go — для коллег, которые переходят на Go после Ruby.

Такие проекты для меня - это способ немного выдохнуть и спокойно порефлексировать: чего именно не хватало в реальной работе, какие инструменты хотелось иметь под рукой, какие решения приходилось принимать в последний момент. Заодно появляется шанс изучить новые подходы и переосмыслить старые.

Цель этого шаблона была простой: дать человеку возможность сразу писать бизнес-логику, не отвлекаясь на инфраструктурную возню, и при этом быть уверенным, что сервис будет наблюдаемым, тестируемым, поддерживаемым и относительно безопасным. А если качество на старте окажется ниже ожидаемого — чтобы уже были инструменты, которые позволяют быстро это качество поднять: найти причину, измерить эффект, повторить, не сломав всё вокруг.

Большая часть того, о чём я расскажу ниже, знакома тем, кто работал с более низкоуровневыми языками. Но я регулярно вижу, что даже сильные разработчики на PHP, Ruby, Python недооценивают пласт вещей, которые на самом деле критичны для любого проекта вне зависимости от архитектуры: наблюдаемость, изолированное тестирование, feature flags, нормальное конфигурирование и документация «почему так», а не «как сделать».

Это не “методичка по Go”. Скорее попытка зафиксировать набор инженерных привычек, которые лично мне много раз экономили дни жизни, которые можно применить к любому языку. И я искренне считаю: если бы эти привычки использовались чаще, сервисы в среднем были бы качественнее, а проблемы решались бы быстрее и спокойнее.

Логи

Вывод структурных логов в консоль
Вывод структурных логов в консоль


Всё начинается с логов. У кого-то их тонна, у кого-то почти нет, но чаще всего это просто технические сообщения «на всякий случай». И проблема обычно даже не в том, что логов мало. Проблема в том, что мы думаем, как логировать, но редко думаем, где потом это читать, как агрегировать, как искать, как связывать с трассировкой, и как сделать так, чтобы логи были полезны не только автору, но и любому человеку, который будет разбирать инцидент через полгода.

Про уровни логирования спорить не хочу, это отдельная религия. Мне важнее формат. Текстовые логи нормальны, когда ты один и смотришь их в консоли. Но как только появляется команда, несколько сервисов или суровый энтерпрайз, где у разработчиков нет доступа к консоли прода, текст превращается в ад: кто-то пишет «ошибка запроса», кто-то «error happened», кто-то логирует структуру, кто-то строку, и потом попробуй это всё собери в одну картину.

Поэтому я сразу считаю “правильным дефолтом” JSON. Его удобно парсить любыми инструментами, и он остаётся читаемым человеком, особенно если держать структуру в порядке.

Дальше неизбежно встаёт вопрос: куда писать. В большинстве случаев достаточно stdout, особенно если у вас настроен сбор логов через агент и централизованное хранилище. Но мне нравится иметь возможность писать в несколько “синков” — например, stdout плюс файл, или stdout плюс OTel-экспорт. Не потому что «так надо», а потому что в разных окружениях это реально облегчает жизнь: где-то проще читать файл, где-то — смотреть поток, где-то — только централизованное хранилище.

Ещё один момент, который я стараюсь делать сразу — разделение потоков. Технические логи и аудит-логи для меня давно не одно и то же. Технические логи рассказывают, как живёт сервис: что он делает, что сломалось, что медленно. Аудит-логи отвечают на другой класс вопросов: кто что сделал, к каким данным получил доступ, на каком основании. Иногда это нужно безопасникам, иногда — вам самим, когда выясняется, что условный Вася Пупкин скачал данные пользователей, и вы хотите понять, это легитимно или это пробой в авторизации.

В Go я обычно делаю два логгера через slog: один “app”, второй “audit”, и добавляю атрибут channel, чтобы это легко фильтровалось и не путалось.

Вот минимальный пример инициализации:

package logger  
  
import (  
    "log/slog"  
    "os")  
  
var (  
    appLogger   *slog.Logger  
    auditLogger *slog.Logger  
)  
  
// Init initializes the logger with the specified environment.
func Init(env string) {  
    level := slog.LevelInfo  
    if env == "local" || env == "dev" {  
       level = slog.LevelDebug  
    }  
  
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})  
    base := slog.New(h).With("env", env)  
  
    slog.SetDefault(base.With("channel", "app"))  
    appLogger = slog.Default()  
    auditLogger = base.With("channel", "audit")  
  
    slog.Info("logger initialized", "level", level.String())  
}  
  
// App returns the application logger.func App() *slog.Logger {  
    return appLogger  
}  
  
// Audit returns the audit logger.func Audit() *slog.Logger {  
    return auditLogger  
}

И вот тут логично перейти к следующему уровню зрелости. Если логи — это “что происходило”, то трассировка — это “в какой последовательности и где именно”.

Трассировка

Пример вывод трасарировки в Grafana
Пример вывод трасарировки в Grafana

Трассировка — то, без чего микросервисы нормально не живут. Но, честно говоря, даже в монолите это невероятно полезно. Просто в микросервисах боль сильнее, поэтому и ценность ощущается быстрее.

Идея простая: у каждого запроса есть trace_id, и по нему ты видишь весь путь запроса через сервисы, очереди, базы и внешние интеграции. Внутри trace есть span — атомарные операции. Хорошая новость в том, что большая часть спанов создаётся автоматически, если ты используешь нормальные инструменты (HTTP middleware, SQL драйверы с OTel-инструментацией, клиенты Kafka/Redis и т.д.). Плохая новость в том, что бизнес-логика “между ними” сама себя не оттрассирует, и иногда надо уметь добавить span руками.

Вот пример в сервисном методе. Он максимально приземлённый: взяли контекст, создали span, добавили атрибуты, зафиксировали ошибку.

package service

import (
	"context"
	"errors"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
)

type FeedService struct{}

func (s *FeedService) BuildFeed(ctx context.Context, userID string, limit int) error {
	tr := otel.Tracer("feed-service")
	ctx, span := tr.Start(ctx, "FeedService.BuildFeed")
	defer span.End()

	span.SetAttributes(
		attribute.String("user.id", userID),
		attribute.Int("feed.limit", limit),
	)

	// тут обычно ваш код: Redis, БД, вызов других сервисов и т.д.
	if limit <= 0 {
		err := errors.New("limit must be positive")
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		return err
	}

	span.SetStatus(codes.Ok, "ok")
	return nil
}

Почему я так упираюсь в связку “трейсы + логи”? Потому что это реально магия. Ты открываешь проблемный трейс, видишь trace_id, и дальше хочешь в один клик найти все логи по этому запросу. Чтобы это работало, логгер должен вытаскивать trace_id и span_id из контекста.

Например можно сделать через middlware:

package middleware  
  
import (  
    "log/slog"  
    "net/http"    "time"  
    "go.opentelemetry.io/otel/trace"  
    "psblab.gitlab.yandexcloud.net/sodrujestvo/golang-template/pkg/httpserver")  
  
// RequestLogger logs HTTP requests with trace context.func RequestLogger() func(next http.Handler) http.Handler {  
    return func(next http.Handler) http.Handler {  
       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {  
          start := time.Now()  
  
          rr := &httpserver.ResponseRecorder{ResponseWriter: w}  
          next.ServeHTTP(rr, r)  
  
          span := trace.SpanFromContext(r.Context())  
          spanContext := span.SpanContext()  
  
          attrs := []any{  
             "method", r.Method,  
             "path", r.URL.Path,  
             "remote_addr", r.RemoteAddr,  
             "status", rr.Status(),  
             "duration", time.Since(start),  
          }  
  
          if spanContext.IsValid() {  
             if spanContext.HasTraceID() {  
                attrs = append(attrs, "trace_id", spanContext.TraceID().String())  
             }  
             if spanContext.HasSpanID() {  
                attrs = append(attrs, "span_id", spanContext.SpanID().String())  
             }  
          }  
  
          slog.Info("http request", attrs...)  
       })  
    }  
}

С такой штукой логи начинают автоматически связываться с трассировкой. И когда у тебя реально горит прод, это один из тех моментов, где ты понимаешь, что “оно всё было не зря”. Выглядит так:

Вывод логов с трейсами в Grafana
Вывод логов с трейсами в Grafana

Метрики

Стандартные метрики в Golang
Стандартные метрики в Golang

Про стандартные метрики рантайма обычно знают все: GC, память, горутины, HTTP — это полезно и нужно, и оно почти всегда подключается без боли. Но со временем ты понимаешь, что стандартные метрики отвечают на вопрос “здоров ли сервис”, а тебе ещё нужно отвечать на вопрос “здоров ли продукт и бизнес-логика”.

Я люблю метрики, которые привязаны к смыслу. Если это сервис постов, то тебе важно понимать, сколько постов создают, как часто добавляют картинки или видео, как меняется поведение пользователей. Это не «хочу поиграться в аналитика», это реально помогает принимать решения и продукту, и инженерам.

Плюс есть технические метрики, которые не про сервис в целом, а про конкретную бизнес-механику: сколько сообщений улетело в DLQ, сколько промахов у кэша, как часто включается fallback. Такие числа позволяют развивать сервис на цифрах, а не на ощущениях.

Вот пример добавления метрик через prometheus client:

package metrics

import "github.com/prometheus/client_golang/prometheus"

var (
	PostsCreated = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "posts_created_total",
			Help: "Total number of created posts",
		},
		[]string{"source"},
	)

	DLQMessages = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "dlq_messages_total",
			Help: "Total number of messages sent to DLQ",
		},
		[]string{"topic"},
	)
)

func Register(r prometheus.Registerer) {
	r.MustRegister(PostsCreated, DLQMessages)
}

Дальше в коде это выглядит максимально просто: в месте, где реально создаётся пост, инкрементируешь PostsCreated.WithLabelValues("api").Inc(). Нагрузки это почти не добавляет, зато через неделю ты уже можешь ответить на вопросы, на которые раньше отвечал бы “ну мне кажется…”.

Конфигурирование

С наблюдаемостью разобрались, теперь поговорим про конфигурацию.

Самый простой способ — использовать env и парсить значения в коде. Это нормально, но со временем env разрастается: там оказываются и инфраструктурные параметры, и бизнес-настройки, и названия топиков, и флаги, и таймауты. После этого проект становится неудобным: новые люди боятся что-то трогать, а старые периодически ломают окружения “случайно”.

Я предпочитаю разделять конфигурацию на два слоя. Первый — это конфиг-файл с тем, что меняется редко и обычно вместе с деплоем, например названия топиков или дефолтные параметры. Второй — переменные окружения для чувствительных или окруженческих вещей: строки подключения, адреса Redis, токены и т.д. При этом возможность переопределения через env остаётся: это удобный механизм для CI/CD.

В Go я чаще всего беру Viper. Не потому что “других нет”, а потому что он закрывает потребности без велосипеда: файл, env, дефолты, переопределения. Когда его не хватит, можно будет написать свое решение.

Пример конфигурации:

package config

import (
	"fmt"
	"time"

	"github.com/spf13/viper"
)

type HTTPConfig struct {
	Addr              string        `mapstructure:"addr"`
	ReadTimeout       time.Duration `mapstructure:"read_timeout"`
	WriteTimeout      time.Duration `mapstructure:"write_timeout"`
	IdleTimeout       time.Duration `mapstructure:"idle_timeout"`
	ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout"`
	ShutdownTimeout   time.Duration `mapstructure:"shutdown_timeout"`
}

type Config struct {
	Env         string     `mapstructure:"env"`
	HTTP        HTTPConfig `mapstructure:"http"`
	PostgresDSN string     `mapstructure:"postgres_dsn"`
	RedisAddr   string     `mapstructure:"redis_addr"`
	PostsTopic  string     `mapstructure:"posts_topic"`
}

func Load(path string) (*Config, error) {
	v := viper.New()

	v.SetConfigFile(path)
	v.SetConfigType("yaml")

	v.SetEnvPrefix("APP")
	v.AutomaticEnv()

	v.SetDefault("env", "local")
	v.SetDefault("http.addr", ":8080")
	v.SetDefault("http.read_timeout", "10s")
	v.SetDefault("http.write_timeout", "10s")
	v.SetDefault("http.idle_timeout", "120s")
	v.SetDefault("http.read_header_timeout", "5s")
	v.SetDefault("http.shutdown_timeout", "10s")
	v.SetDefault("posts_topic", "posts.events")

	_ = v.ReadInConfig() // в local можно позволить жить без файла

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("unmarshal config: %w", err)
	}

	return &cfg, nil
}

И да, ремарка, которую лучше проговорить вслух. В проде секреты не должны лежать “как есть” в переменных окружения. Либо шифрование на уровне CI/CD, либо секрет-менеджер. В Kubernetes это нормально решается, и лучше сразу двигаться в эту сторону, чем потом разгребать. Рекомендую пару статей на эту тему (не моих):

Feature flags

Это практический инструмент, который спасает, когда ты хочешь выкатить функциональность постепенно, включить только для части пользователей, сделать A/B, провести регресс или просто не взрывать прод изменением поведения.

Можно, конечно, обойтись условием if cfg.FeatureX { ... }. Но как только тебе нужно управлять этим динамически, ты упираешься в необходимость нормального решения и нормального интерфейса управления.

Мне здесь нравится OpenFeature. Это SDK-абстракция, через которую можно подключить Unleash, Flipt, flagd и что угодно ещё. Ты один раз пишешь код под интерфейс, а дальше выбираешь инструмент, который удобен команде.

package main  
  
import (  
"fmt"  
"context"  
"github.com/open-feature/go-sdk/openfeature"  
)  
  
func main() {  
	// Register your feature flag provider  
	openfeature.SetProviderAndWait(openfeature.NoopProvider{})  
	// Create a new client  
	client := openfeature.NewClient("app")  
	// Evaluate your feature flag  
	v2Enabled := client.Boolean(  
		context.TODO(), "v2_enabled", true, openfeature.EvaluationContext{},  
	)  
	// Use the returned flag value  
	if v2Enabled {  
		fmt.Println("v2 is enabled")  
	}  
}

Самый жизненный кейс у меня — авторизация. Во время разработки ты хочешь просто дергать ручки, а значит либо отключать middleware локально, либо поднимать рядом контур авторизации. Для разработчика это ещё терпимо. Для тестировщика — боль. Для команды — постоянные “а как мне сейчас это проверить”.

Флаг решает это аккуратно: включил проверку JWT — сервис работает “как на проде”, выключил — тестируешь бизнес-логику без лишних зависимостей. При этом я всегда добавляю дополнительную защиту: на проде выключение авторизации должно быть запрещено, даже если кто-то случайно ткнул кнопку в UI.

Пример middleware:

package middleware

import (
	"net/http"

	of "github.com/open-feature/go-sdk/openfeature"
)

type JWTAuthenticator interface {
	Authenticate(r *http.Request) error
}

func AuthMiddleware(client *of.Client, env string, auth JWTAuthenticator) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			enabled, err := client.BooleanValue(r.Context(), "auth.jwt.enabled", true, of.EvaluationContext{})
			if err != nil {
				// если флаг-система недоступна — безопаснее считать, что включено
				enabled = true
			}

			// доп. защита: на проде авторизацию не отключаем
			if env == "prod" {
				enabled = true
			}

			if enabled {
				if err := auth.Authenticate(r); err != nil {
					http.Error(w, "unauthorized", http.StatusUnauthorized)
					return
				}
			}

			next.ServeHTTP(w, r)
		})
	}
}

Тестирование

Здесь я не буду рассказывать “надо писать юнит-тесты”. Это очевидно. Мне важнее другое: чем меньше вы завязаны на реальный внешний контур для проверки своей логики, тем быстрее вы развиваетесь. Интеграционные тесты с реальными внешними сервисами дорогие: они медленные, нестабильные, требуют окружений и людей, которые их поддерживают.

Поэтому я люблю подход, когда сервис можно поднять “почти один”, а все зависимости либо мокируются, либо поднимаются локально быстро, либо стартуют прямо из теста.

И вот здесь у меня случилось личное открытие: testcontainers. Я долго думал, что это что-то вроде docker-compose и не понимал, зачем. Потом попробовал — и стал использовать регулярно. Это очень быстрый путь получить настоящую базу/брокер/кэш в тестах без плясок вокруг окружений.

Вот пример теста, который поднимает PostgreSQL и проверяет реальную работу с SQL:

package integration_test

import (
	"context"
	"database/sql"
	"testing"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
	"github.com/stretchr/testify/require"

	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestPostgresWithTestcontainers(t *testing.T) {
	ctx := context.Background()

	pg, err := postgresContainer.Run(ctx,  
	    "postgres:18",  
	    postgresContainer.WithDatabase("testdb"),  
	    postgresContainer.WithUsername("testuser"),  
	    postgresContainer.WithPassword("testpass"),  
	    testcontainers.WithWaitStrategy(  
	       wait.ForLog("database system is ready to accept connections").  
	          WithOccurrence(2).  
	          WithStartupTimeout(30*time.Second),  
	    ),  
	)  
	require.NoError(t, err)
	t.Cleanup(func() { _ = pg.Terminate(ctx) })

	dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
	require.NoError(t, err)

	db, err := sql.Open("pgx", dsn)
	require.NoError(t, err)
	t.Cleanup(func() { _ = db.Close() })

	_, err = db.ExecContext(ctx, `create table if not exists items(id serial primary key, name text not null)`)
	require.NoError(t, err)

	_, err = db.ExecContext(ctx, `insert into items(name) values ($1)`, "hello")
	require.NoError(t, err)

	var got string
	err = db.QueryRowContext(ctx, `select name from items where name = $1`, "hello").Scan(&got)
	require.NoError(t, err)
	require.Equal(t, "hello", got)
}

Такие тесты обычно выглядят “тяжелее”, чем моки, но они проверяют реальную интеграцию и при этом остаются быстрыми. И это тот случай, когда качество реально растёт, а время на поддержку тестов — нет. У меня с Redis, PostgreSQL, Kafka тесты для простого сервиса прогоняются за 30 секунд. В целом максимум думаю можно брать 5 минут, на прогретых контейнерах.

Вывод запуска testcontainers
Вывод запуска testcontainers

Команды и Makefile

Отдельная тема, которую я раньше недооценивал, — команды для работы с проектом. Makefile написать несложно, но польза огромная. Когда человек заходит в репозиторий, ему не нужно гадать, как запустить сервис, как поднять зависимости, как прогнать тесты и линтеры. Он просто читает make help и начинает работать.

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

Пример Makefile
Пример Makefile

Документация

Документацию разработчики не любят писать, а иногда даже не любят читать. Часто повторяют, что “код — это документация”. За свои годы я практически не видел, чтобы это работало в проектах, которые живут больше пары лет и развиваются командой.

Код меняется, приходят новые люди, появляются новые требования, потом ещё, потом ещё. В какой-то момент ты смотришь на кусок, который написан красиво, и думаешь: “Окей, а почему мы вообще сделали так, а не иначе?”. И вот это “почему” код почти никогда не объясняет.

Я обычно делаю папку docs/ и пишу там короткие тексты о том, как устроен сервис на уровне смысла. Я не дублирую контракты, я даю контекст: что это за ручка, зачем она нужна, какую роль играет, какие есть цепочки вызовов. Мне важно, чтобы новый человек мог прочитать это и через несколько часов начать писать код, а не неделю раскручивать клубок в голове.

Отдельно я люблю adr/ — архитектурные решения. И здесь есть мысль, которую я понял не сразу. ADR полезны не только тем, кто будет читать их потом. Они полезны тем, кто их пишет. Когда ты начинаешь фиксировать альтернативы и аргументы, очень быстро становится заметно, где ты действительно уверен, а где просто выбрал на ощущениях. И если объяснение выбора звучит слабо даже для самого себя — это отличный сигнал, что решение стоит пересмотреть, пока оно не стало историческим наследием.

Заключение

Этот список можно было бы продолжить, но мне кажется, что именно эти вещи дают сервису шанс быть не просто “написанным”, а реально живым: наблюдаемым, тестируемым, управляемым и понятным людям, которые придут после вас.

Когда это всё внедрено, у тебя перестаёт быть ощущение, что ты постоянно тушишь пожары. Вместо этого ты спокойно разбираешь проблему, видишь причину, проверяешь гипотезу, выкатываешь изменение и измеряешь эффект. И это, честно, очень приятный способ заниматься инженерией.

Если у вас есть что добавить или вы с чем-то не согласны — приходите в комментарии. Мне будет интересно сравнить опыт.

Сильно чаще чем статьи я пишу в своем канале в ТГ, подписывайтесь.

Комментарии (7)


  1. SolidSnack
    14.12.2025 08:47

    Трассировка — то, без чего микросервисы нормально не живут. Но, честно говоря, даже в монолите это невероятно полезно.

    Очередная микросервесная чушь, погуглите понятие точка остановы. Учите мат часть, потом учите других.

    Мде, руководители нынче один лучше другого))


    1. Apokalepsis Автор
      14.12.2025 08:47

      Пользователь, на проде, говорит - у меня не работает личный кабинет, ошибка 404 после перехода ( ну или просто говорит не работает). У всех остальных при этом работает.

      Вы пойдете на прод, запустите в режиме отладки и попросите пользователя попробовать еще раз? Или снимите дамп и будете его разматывать?

      Это в целом к любой архитектуре относится. А теперь берем микросервисную или сервисную и у вас цепочка вызовов между сервиса, их например три. В одновременно откроете три IDEA и тоже самое будете делать ручками?


      1. SolidSnack
        14.12.2025 08:47

        Причём тут вообще что монолиту это ТОЖЕ будет полезно? Это используется уже сто лет до ваших микроштучек. Все пытаетесь микросервисам приписать несуществующие плюсы своими микрорешениям


  1. unabl4
    14.12.2025 08:47

    Годно. Только непонятна связь с PHP и Ruby


    1. Apokalepsis Автор
      14.12.2025 08:47

      Спасибо за оценку. Согласен не очевидно. Личный опыт и большая насмотреность -то о чем я написал очень актуально в первую очередь для людей переходящих с этих языков (Python еще можно добавить). Наверное можно сказать что так исторически сложилось.

      Поправил, что бы не вводить в заблуждение.


  1. sotland
    14.12.2025 08:47

    Вопрос, а что если поднять тестовый контур, с базой, кафкой прям весь? И всё тестировать через него? Всегда.


    1. Apokalepsis Автор
      14.12.2025 08:47

      Сильно зависит от размера команды и проекта. Потому что кажется что самый простой путь, но он приводит к тому что окружение постоянно надо чинить и приводить в порядок. Если проект не большой, то это не сложно делать (иногда даже может быть полезно, если у вас там данные боевые).

      По моему опыту - после появляется одного теста, появляется еще несколько тестовых площадок. Потом еще и еще, а потом ты даже не можешь нормально ручками что-то протестить, не то что автотест прогнать - тут один функционал, не трогай, тут другой ждет фиксов и следующего релиза. И в итоге команда скидывает большое количество времени на поддержание окружений. Это я еще кстати не говорю о том что часто бывает для како-то тестирования специально подготовить площадку, а после тестов удалить.

      Может быть еще компромисс, когда мы на ветку автоматически поднимаем площадку только с нашим сервисом (в Gitlab есть такая штука, называется Environments) и тестируем его изолированно, после тестов удаляем. Зависимости не поднимаем (имею ввиду зависимые сервисы). Но это все равно будет дольше чем прогнать 100 тестов в testcontainers.

      Если же у нас просто проект, зависимостей практически нет, работает микро-команда, то вполне себе быстрое решение.