Перейти к содержанию

Как завести новый сервис

Новый сервис = новый git-репозиторий. Этот рецепт — от «есть идея» до первого зелёного CI и работающего make bootstrap.

Правила структуры, слоёв, Dockerfile, Makefile — в ../conventions/project-layout.md. Здесь — последовательность шагов.

Содержание

1. Согласовать границы

До кода обсуди с lead-инженером:

  • Bounded context. Какая доменная область у сервиса? Сервис = один bounded context по DDD. Два контекста в одном сервисе — запрет.
  • Какие сущности он владеет (таблицы, инварианты).
  • Какие события публикует (<entity>.created, <entity>.updated).
  • Какие события потребляет (из каких других сервисов).
  • Downstream HTTP. Какие /internal/* endpoint'ы ему придётся дёргать у соседей.

Записывай результат в 1-страничный spec в PR-описании первого коммита.

2. Создать репозиторий

  • Имя репозитория: <service>-service (например, order-service, search-service).
  • Тип: private по умолчанию.
  • Настрой branch protection на main: minimum 1 approve, require status check ci.

3. Клонировать service-template или эталон

Когда появится kazmaps-service-template — клонируй его. Пока шаблона нет — возьми за основу эталонный сервис:

  • самый чистый — репозиторий сервиса user, файлы cmd/server/main.go, internal/handler/router.go, Makefile, Dockerfile.
  • если сервис событийный (много Kafka-handler'ов) — репозиторий сервиса notification.

Скопируй структуру папок и набор файлов в свой новый репозиторий. Бизнес-код эталона удали, оставь только каркас:

<new-service>/
├── cmd/server/main.go
├── internal/{config,handler,middleware,service,repository/postgres,event,domain,db}/
├── pkg/db/
├── migrations/
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── go.mod
├── .env.example
└── README.md

4. Rename

Переименуй всё, что завязано на имя эталона:

  • go.mod: module github.com/example/<service>-service.
  • Имя сервиса в логах (service=userservice=<new>).
  • Env-префикс если был (например, NOTIFICATION_*<NEW>_*).
  • Порт — свободный из таблицы занятых:
Сервис Порт
user 8001
review 8007
media 8008
notification 8013

Выбирай следующий неиспользуемый номер. Обнови в .env.example, docker-compose.yml, Dockerfile (EXPOSE), internal/config/.

5. Базовые файлы

README.md

Одна страница: зачем сервис, как запустить локально, где документация на контракты (OpenAPI).

.env.example

Все переменные из internal/config/ с dev-значениями:

SERVICE_PORT=8020
SERVICE_LOG=info

DB_HOST=localhost
DB_PORT=5432
DB_USER=<svc>
DB_PASSWORD=<svc>_dev_password
DB_NAME=kazmaps

REDIS_ADDR=localhost:6379

KAFKA_BROKERS=localhost:9092
KAFKA_CLIENT_ID=<svc>

INTERNAL_API_TOKEN=change_me_internal_token
GATEWAY_HMAC_KEY=change_me_hmac_key

Makefile

Минимум целей:

.PHONY: help bootstrap deps build run test lint up down logs psql migrate-up migrate-down

help:
    @grep -E '^[a-zA-Z_-]+:.*## ' Makefile | awk 'BEGIN{FS=":.*## "};{printf "%-15s %s\n", $$1, $$2}'

bootstrap: ## .env + deps + docker up + migrations
    cp -n .env.example .env || true
    $(MAKE) deps
    $(MAKE) up

deps: ## download go modules
    go mod download && go mod tidy

build: ## build binary to bin/
    CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/server ./cmd/server

run: ## run binary (expects infra up)
    ./bin/server

test: ## run tests with race detector
    go test -race -count=1 ./...

lint: ## run golangci-lint
    golangci-lint run

up: ## docker compose up -d
    docker compose up -d

down: ## docker compose down (keeps volumes)
    docker compose down

clean: ## docker compose down -v (drops volumes)
    docker compose down -v

logs: ## follow service logs
    docker compose logs -f <service>

psql: ## psql in postgres container
    docker compose exec postgres psql -U $${DB_USER:-<service>} -d $${DB_NAME:-kazmaps}

migrate-up: ## apply all migrations
    migrate -path ./migrations -database "$${DB_DSN}" up

migrate-down: ## rollback one migration
    migrate -path ./migrations -database "$${DB_DSN}" down 1

Dockerfile

FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/svc ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
 && adduser -D -u 10001 app
COPY --from=builder /out/svc /bin/svc
USER app
EXPOSE 8020
ENTRYPOINT ["/bin/svc"]
  • CGO_ENABLED=0 — статическая сборка, работает в alpine.
  • USER app (uid 10001) — non-root runtime.
  • -ldflags="-s -w" — убирает отладочные символы, бинарь меньше.

docker-compose.yml

postgres + redis + kafka + сам сервис. Образец — из docker-compose.yml эталонного сервиса. Не забудь healthcheck'и и depends_on: condition: service_healthy, чтобы сервис не стартовал раньше БД.

6. Первая миграция

migrations/001_init.up.sql:

BEGIN;
SELECT pg_advisory_xact_lock(hashtext('<svc>_migrations'));

CREATE SCHEMA IF NOT EXISTS <svc>;

-- доменные таблицы
CREATE TABLE <svc>.<entity>s (
    id         BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- outbox сразу
CREATE TABLE <svc>.outbox (
    "offset"      BIGSERIAL   PRIMARY KEY,
    uuid          UUID        NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    payload       BYTEA,
    metadata      JSONB,
    transaction_id XID8       NOT NULL DEFAULT pg_current_xact_id()
);
-- Схема конкретных колонок outbox определяется watermill-sql
-- DefaultPostgreSQLSchema; сверься с версией модуля при создании.

COMMIT;

001_init.down.sqlDROP SCHEMA <svc> CASCADE; (только если ты действительно готов терять данные при rollback dev-среды).

Подробности — ../conventions/db-pgx.md и add-migration.md.

7. Wiring в main.go

func run() error {
    cfg, err := config.Load()
    if err != nil { return err }

    log := newLogger(cfg.Service.Log)
    log.Info("starting service", "service", cfg.Service.Name, "port", cfg.Service.Port)

    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

    database, err := pkgdb.New(ctx, pkgdb.Config{DSN: cfg.DB.DSN(), PoolMax: 20, PoolMin: 2})
    if err != nil { return fmt.Errorf("db: %w", err) }
    defer database.Close()

    if err := migrations.Run(ctx, database, log); err != nil {
        return fmt.Errorf("migrate: %w", err)
    }

    rdb := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr})
    if err := rdb.Ping(ctx).Err(); err != nil { return fmt.Errorf("redis: %w", err) }
    defer rdb.Close()

    kafkaPub, kafkaSub, err := event.NewKafka(cfg.Kafka)
    if err != nil { return fmt.Errorf("kafka: %w", err) }
    defer kafkaPub.Close()
    defer kafkaSub.Close()

    // собрать repositories / services / handlers ...

    router := handler.NewRouter(handler.Deps{ /* ... */ })
    srv := &http.Server{
        Addr:              cfg.Service.Addr(),
        Handler:           router,
        ReadHeaderTimeout: 10 * time.Second,
    }

    errCh := make(chan error, 1)
    go func() { errCh <- srv.ListenAndServe() }()
    go func() { errCh <- forwarder.Run(ctx) }()

    select {
    case <-ctx.Done():
        shutdownCtx, c := context.WithTimeout(context.Background(), 30*time.Second)
        defer c()
        return srv.Shutdown(shutdownCtx)
    case err := <-errCh:
        return err
    }
}

Правила:

  • main.goтолько wiring. Никакой бизнес-логики.
  • Graceful shutdown на SIGINT/SIGTERM: ловим сигнал, делаем srv.Shutdown(ctx) с таймаутом, выходим.
  • Fail-fast на старте: если pool.Ping / redis.Ping упали — сервис не стартует.

8. Health / readyz

Hard-обязательно:

r.Get("/healthz", d.Health.Live)   // liveness: просто 200
r.Get("/readyz",  d.Health.Ready)  // readiness: pool.Ping + redis.Ping + kafka.Ping

См. ../conventions/http-api.md.

9. Metrics

r.Handle("/metrics", promhttp.Handler())

Подключи Prometheus client с первого дня, даже если метрик пока 0. Добавление стандартных коллекторов:

prometheus.MustRegister(collectors.NewGoCollector())
prometheus.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))

10. CI

Скопируй .github/workflows/ci.yml из эталонного сервиса. Минимум job'ов:

  • lintgolangci-lint run.
  • testgo test -race -count=1 -shuffle=on ./....
  • buildgo build ./....
  • docker-builddocker build ..
  • vulncheckgovulncheck ./....

(Когда появится reusable workflow kazmaps-ci — замени на uses: ...@v1.)

11. Local setup

После всех шагов должна работать одна команда:

make bootstrap

Что она делает: 1. Копирует .env.example в .env, если .env нет. 2. go mod download && go mod tidy. 3. docker compose up -d — поднимает postgres/redis/kafka/сервис. 4. Ждёт healthcheck'и. 5. Применяет миграции (если migrate не встроен в сервис).

Целевое состояние: коллега клонирует репо, делает make bootstrap, через 30 секунд сервис отвечает 200 на /healthz.

12. Первые тесты

Минимум для зелёного CI:

// internal/handler/health_test.go
func TestHealthLive(t *testing.T) {
    h := handler.NewHealthHandler(nil, nil)
    req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
    rec := httptest.NewRecorder()
    h.Live(rec, req)
    if rec.Code != http.StatusOK { t.Fatalf("status: %d", rec.Code) }
}

Дальше добавляй тесты на service/handler по мере появления бизнес-кода. Race detector — сразу в make test.

13. OpenAPI

Если сервис держит публичный API — создай api/openapi.yaml пустым, но валидным:

openapi: 3.1.0
info:
  title: <service> API
  version: 0.1.0
paths: {}
components: {}

Пополняется при добавлении первого endpoint'а.

14. Registry и ownership

  • Добавь сервис в ../onboarding/04-who-owns-what.md: имя, owner, репозиторий, порт, краткое описание.
  • Если есть файл services-catalog.md в handbook — добавь туда тоже.

15. Production-ready check

Перед первым включением прод-трафика пройди ../checklists/production-ready.md: runbook, alert'ы, graceful shutdown, SBOM, SLO.

Чеклист

  • Границы сервиса согласованы с lead-инженером.
  • Репозиторий создан, branch protection настроен.
  • Структура папок совпадает с project-layout.md.
  • main.go — только wiring, есть graceful shutdown.
  • migrations/001_init.up.sql создаёт схему + outbox.
  • /healthz и /readyz работают.
  • /metrics отвечает.
  • Dockerfile multistage + non-root (uid 10001).
  • make bootstrap поднимает всё за одну команду.
  • CI зелёный: lint + test + build + vulncheck.
  • Сервис добавлен в who-owns-what.

Связанные разделы