Настраиваем генерацию gRPC стабов для Go и подключаем как модуль
Держать proto-контракты в одном репозитории удобно, но подключать их целиком в каждый сервис — не очень. Разберём, как автоматически генерировать Go-стабы из proto-файлов, версионировать их как отдельные Go-модули и публиковать через GitLab CI/CD. Бонусом — swagger-документация и GitLab Pages.
Всё описанное рассчитано на приватный бесплатный GitLab. В self-hosted и платной версиях, а также для публичных репозиториев настройка будет значительно проще — там часть ограничений просто снимается.
Примечания перед стартом
Подход требует настройки dev-окружения: стандартного доступа к репозиторию по SSH и нескольких переменных окружения на каждой машине разработчика:
GOPRIVATE=gitlab.com/your-groupGOPROXY=directGONOSUMDB=gitlab.com/your-group
Для сервисов, использующих стабы, придётся собирать vendor-папку — как при локальном запуске, так и в пайплайнах при линтинге, тестах и сборке. Это обусловлено тем, что Go не умеет скачивать приватные модули из GitLab в обход прокси без дополнительной настройки аутентификации.
Доступ в CI-окружении осуществляется через CI_JOB_TOKEN. Типовой before_script для сервисов-потребителей стабов выглядит так:
default: image: golang:${GO_VERSION} cache: key: go-mod paths: - go/pkg/mod - .cache/go-build before_script: - go version - git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/".insteadOf "https://gitlab.com/" - go env -w GOPRIVATE=gitlab.com/chichilaki - go env -w GONOSUMDB=gitlab.com/chichilaki - go mod vendor
Структура репозиториев
Создаём группы и репозитории:
-
`group/proto` — proto-контракты, единственный репозиторий, который мы будем редактировать вручную.
-
`group/proto-stubs` — группа для сгенерированных стабов, репозитории внутри создаём заранее пустыми.
-
`group/proto-stubs/common`, `group/proto-stubs/user`, `group/proto-stubs/admin` и т.д. — по одному репозиторию на каждый модуль.
-
`group/service` — любой сервис, использующий стабы.
Репозитории стабов
Для каждого стаба создаётся отдельный репозиторий Go-модуля. В моём случае есть контракт для общих сущностей (common) и контракты для сервисов: user, admin, runner.
Настройка доступа для CI_JOB_TOKEN
Поскольку каждый сервис в своём пайплайне использует CI_JOB_TOKEN для обращения к репозиториям со стабами, нужно явно разрешить такой доступ.
Для каждого репозитория со стабами открываем Settings -> CI/CD -> Job token permissions и указываем репозиторий с proto-контрактами и группу с сервисами, использующими стабы.
Важный момент: если одни стабы зависят от других — например, admin импортирует common — то в настройках доступа репозитория admin.git нужно дополнительно добавить группу со стаб-репозиториями. Иначе go get в CI упадёт с ошибкой аутентификации, и будет неочевидно почему.
В платной и self-hosted версиях GitLab можно давать доступ сразу на группу репозиториев — там это настраивается в одном месте.
Почему .git в имени модуля
Имя модуля содержит суффикс .git:
gitlab.com/chichilaki/mgs/proto-go-stubs/common.git
Это не случайность. Go при резолве пути модуля из GitLab не может самостоятельно определить, где заканчивается путь к репозиторию и начинается путь к пакету внутри него. Суффикс .git даёт ему явную подсказку. Без него go get будет пытаться найти метаданные модуля по неправильному пути и падать.
Репозиторий с proto-контрактами
Именно этот репозиторий запускает пайплайн с генерацией и публикацией стабов.
Версионирование
Джоба публикации запускается только для веток с именем вида vX.X.X. При публикации создаётся одноимённый тег в репозитории стабов. Таким образом, имя ветки в proto-репо напрямую становится версией Go-модуля. Хочешь выпустить v1.2.3 — создаёшь ветку v1.2.3 и запускаешь пайплайн.
Настройка Personal Access Token
С недавних пор в GitLab Next появилась возможность настроить доступ
CI_JOB_TOKENна пуш в репозитории — стоит проверить, возможно этот шаг скоро станет лишним.
В бесплатной версии GitLab невозможно выдать access token на группу репозиториев, поэтому используем Personal Access Token.
Profile Settings -> Access Tokens — создаём токен с правами api, read_api, read_repository, write_repository. Токен показывается один раз — сохраните его сразу.
Далее в репозитории с proto-контрактами: Settings -> CI/CD -> Variables. Создаём переменную:
-
type: `Variable`
-
visibility: `Masked and hidden`
-
flag: `Protected`
Если в команде несколько разработчиков — каждый создаёт свой токен и свою переменную, например CI_PUSH_TOKEN_USERNAME. В пайплайне выбор нужного токена делается через rules с условием на GITLAB_USER_LOGIN — об этом ниже.
Настройка Branch Rules
Чтобы protected-переменные были доступны не только в основной ветке: Settings -> Repository -> Branch Rules -> Add rule. Маска v* даст доступ любой ветке, начинающейся с v, к защищённым переменным и токенам. Без этого шага пайплайн на ветке v1.0.0 просто не увидит токен.
Конфигурация buf
buf.yaml описывает зависимости и правила линтинга, buf.gen.yaml — плагины генерации.
В buf.gen.yaml используем local с go run вместо глобальной установки protoc и плагинов — Go сам скачает нужные версии при первом запуске. Это избавляет от проблем с расхождением версий между машинами и CI.
Оба файла полностью приведены в приложении.
Пример proto-файлов
В go_package обязательно указывать суффикс .git — именно так Go будет разрешать импорт модуля:
// common.protosyntax = "proto3";package common.v1;option go_package = "gitlab.com/chichilaki/mgs/proto-go-stubs/common.git/v1";
Для сервисных контрактов с HTTP-аннотациями grpc-gateway и swagger-опциями структура выглядит так:
// admin.protosyntax = "proto3";package admin.v1;option go_package = "gitlab.com/chichilaki/mgs/proto-go-stubs/admin.git/v1";import "common/v1/common.proto";import "google/api/annotations.proto";import "protoc-gen-openapiv2/options/annotations.proto";option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { title: "Control Plane ADMIN API"; version: "1.0"; }; security_definitions: { security: { key: "Bearer"; value: { type: TYPE_API_KEY; in: IN_HEADER; name: "Authorization"; description: "Admin Bearer token"; }; }; }; security: { security_requirement: { key: "Bearer"; } };};service AdminService { rpc ListRunners(ListRunnersRequest) returns (ListRunnersResponse) { option (google.api.http) = { get: "/v1/runners" }; } rpc GetRunnerStatus(GetRunnerStatusRequest) returns (GetRunnerStatusResponse) { option (google.api.http) = { get: "/v1/runners/{runner_id.runner_id}/status" }; } // ...}
CI/CD пайплайн
Пайплайн proto-репозитория состоит из трёх стейджей: lint, generate, commit. Полная версия — в приложении.
Джоба generate
Запускает buf generate, после чего для каждого модуля создаёт go.mod с правильным именем модуля, включая .git. Артефакты передаются в следующую джобу через artifacts.paths.
Ключевой момент: go mod init вызывается только если go.mod ещё не существует, чтобы не перезатирать файл при повторных запусках. Иначе при каждом прогоне модуль будет инициализироваться заново и терять зависимости.
Джоба commit-generated
Клонирует каждый целевой репозиторий, заменяет содержимое сгенерированными файлами, копирует swagger.json, обновляет зависимости через go mod tidy, коммитит и создаёт тег версии.
Выбор токена под конкретного пользователя делается через rules с условием на GITLAB_USER_LOGIN — это позволяет каждому разработчику использовать свой Personal Access Token, не передавая его коллегам:
rules: - if: $CI_COMMIT_BRANCH =~ /^v\d+\.\d+\.\d+$/ variables: PROTO_VERSION: $CI_COMMIT_BRANCH - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: never - if: $GITLAB_USER_LOGIN == "End1essRage" variables: PUSH_TOKEN: $CI_PUSH_TOKEN - if: $GITLAB_USER_LOGIN == "stivvhuys" variables: PUSH_TOKEN: $CI_PUSH_TOKEN_GENGAVSOV - when: never
Если PUSH_TOKEN в итоге пустой — джоба падает в before_script с явной ошибкой, а не с непонятным 403 где-то в середине скрипта. Это намеренная проверка.
Для модулей, зависящих от common, в скрипте явно добавляется require с нужной версией:
if [ "$module" != "common" ]; then go get gitlab.com/chichilaki/mgs/proto-go-stubs/common.git@$PROTO_VERSIONfi
Это важно: без явного go get с тегом go mod tidy подтянет latest, а не ту версию, которую мы только что опубликовали.
Сервис, использующий стабы
Рассмотрим сторону потребителя — сервис, который хочет держать swagger-документацию у себя, отдавать её через HTTP и взаимодействовать с API через Swagger UI.
Встраивание swagger.json через go:embed
В корне проекта создаётся пакет docs:
package docsimport _ "embed"//go:embed control-plane-admin.swagger.jsonvar AdminSwaggerJSON []byte//go:embed control-plane-user.swagger.jsonvar UserSwaggerJSON []byte
Импортируем его в main.go через сайд-эффект:
import _ "gitlab.com/chichilaki/mgs/control-plane/docs"
Маршруты для раздачи документации:
mux.HandleFunc("/swagger/admin/doc.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(docs.AdminSwaggerJSON)})mux.HandleFunc("/swagger/user/doc.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(docs.UserSwaggerJSON)})
go:embed компилирует файлы прямо в бинарник — не нужна отдельная раздача статики и нет риска рассинхронизации версий документации с кодом.
Синхронизация swagger.json
Документацию синхронизируем в трёх местах: pre-commit хук, Taskfile и CI-пайплайн сервиса. Во всех трёх случаях логика одна: клонируем стаб-репозиторий во временную директорию, забираем v1/<service>.swagger.json и кладём в docs/.
Локально используем SSH, в CI — CI_JOB_TOKEN. Соответствующие конфиги для Taskfile и CI приведены в приложении.
Локальный стек: Traefik + Swagger UI
Поднимаем через compose. Обратите внимание: BASE_URL в конфиге Swagger UI и PathPrefix в правиле Traefik должны совпадать — иначе UI загрузится, но не сможет резолвить свои же ассеты.
swagger-ui: image: swaggerapi/swagger-ui:v5.9.0 environment: BASE_URL: /swagger URLS: > [ { "url": "/cp-swagger/swagger/admin/doc.json", "name": "ADMIN control-plane" }, { "url": "/cp-swagger/swagger/user/doc.json", "name": "USER control-plane" } ] labels: - "traefik.http.routers.swagger.rule=Host(`localhost`) && PathPrefix(`/swagger`)" - "traefik.http.routers.swagger.entrypoints=web" - "traefik.http.services.swagger.loadbalancer.server.port=8080"
Маршрут для самого сервиса со стрипингом префикса /cp:
control-plane: labels: # ===== API ===== - "traefik.http.routers.cp-api.rule=Host(`localhost`) && PathPrefix(`/cp`)" - "traefik.http.routers.cp-api.entrypoints=web" - "traefik.http.routers.cp-api.service=cp" - "traefik.http.middlewares.cp-strip.stripprefix.prefixes=/cp" - "traefik.http.routers.cp-api.middlewares=cp-strip" - "traefik.http.services.cp.loadbalancer.server.port=9100" # Активируем проверку здоровья для балансировщика - "traefik.http.services.cp.loadbalancer.healthcheck.path=/health" - "traefik.http.services.cp.loadbalancer.healthcheck.interval=10s" - "traefik.http.services.cp.loadbalancer.healthcheck.timeout=3s" - "traefik.http.services.cp.loadbalancer.healthcheck.scheme=http" # Прокси для Swagger через Traefik # ===== SWAGGER ===== - "traefik.http.routers.cp-swagger.rule=Host(`localhost`) && PathPrefix(`/cp-swagger`)" - "traefik.http.routers.cp-swagger.entrypoints=web" - "traefik.http.routers.cp-swagger.service=cp-swagger" - "traefik.http.middlewares.cp-swagger-strip.stripprefix.prefixes=/cp-swagger" - "traefik.http.routers.cp-swagger.middlewares=cp-swagger-strip" - "traefik.http.services.cp-swagger.loadbalancer.server.port=9100"
Бонус: GitLab Pages со Swagger UI
Удобно иметь документацию не только локально, но и доступной по постоянной ссылке. GitLab Pages решает это без отдельного хостинга — достаточно положить артефакты в папку public.
Джоба pages создаёт папку public, кладёт туда swagger-файлы и генерирует index.html с CDN-версией Swagger UI. Запускается на ветках master и dev. Полная версия джобы — в приложении.
CI: fetch-swagger + pages
fetch-swagger: stage: prepare image: alpine:latest before_script: - apk add --no-cache git script: - mkdir -p docs - | for REPO in admin user; do git clone --depth 1 \ "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/chichilaki/mgs/proto-go-stubs/${REPO}.git" \ "/tmp/${REPO}" cp "/tmp/${REPO}/v1/${REPO}.swagger.json" "docs/control-plane-${REPO}.swagger.json" rm -rf "/tmp/${REPO}" done artifacts: paths: - docs/*.swagger.jsonpages: stage: pages image: alpine:latest script: - mkdir -p public/docs - cp docs/*.swagger.json public/docs/ || echo "No swagger files" - | cat > public/index.html << 'EOF' <!DOCTYPE html> <html> <head> <title>API Documentation</title> <meta charset="UTF-8"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"> </head> <body> <div id="swagger-ui"></div> <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script> <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script> <script> window.onload = function() { window.ui = SwaggerUIBundle({ urls: [ { url: "./docs/control-plane-admin.swagger.json", name: "Admin API" }, { url: "./docs/control-plane-user.swagger.json", name: "User API" } ], dom_id: '#swagger-ui', deepLinking: true, presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], layout: "StandaloneLayout" }); } </script> </body> </html> EOF artifacts: paths: - public rules: - if: $CI_COMMIT_BRANCH == "master" - if: $CI_COMMIT_BRANCH == "dev"
Заключение
Итого что получаем: proto-контракты в одном репозитории, автоматическая генерация и публикация стабов при создании ветки вида v1.0.0, раздельные Go-модули для каждого сервиса, swagger-документация как часть того же пайплайна и Pages с UI для просмотра.
Основная сложность в бесплатном GitLab — управление токенами. В платной версии или self-hosted большая часть этих танцев снимается: можно выдать доступ на группу и использовать единый токен. Но и в описанном виде всё работает стабильно.
Приложение: конфигурационные файлы
buf.yaml
version: v2deps: - buf.build/googleapis/googleapis - buf.build/grpc-ecosystem/grpc-gatewaylint: use: - STANDARDbreaking: use: - FILE
buf.gen.yaml
version: v2plugins: - local: ["go", "run", "google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"] out: gen/go - local: ["go", "run", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1"] out: gen/go - local: ["go", "run", "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.29.0"] out: gen/go - local: ["go", "run", "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.29.0"] out: gen/openapiv2 opt: - output_format=json - allow_merge=false
Полный пайплайн proto-репозитория
stages: - lint - generate - commitvariables: GIT_STRATEGY: fetch GIT_DEPTH: 0 GOPRIVATE: "gitlab.com" GOPROXY: "direct" GONOSUMDB: "gitlab.com" PROTO_MODULE_BASE: "chichilaki/mgs/proto-go-stubs".setup-buf: &setup-buf - apk add --no-cache git curl - curl -sSL "https://github.com/bufbuild/buf/releases/download/v1.68.4/buf-$(uname -s)-$(uname -m)" -o /usr/local/bin/buf - chmod +x /usr/local/bin/buf - buf --version - buf dep updatelint: stage: lint image: alpine:latest before_script: - *setup-buf script: - buf lint allow_failure: falsegenerate: stage: generate image: golang:1.26-alpine needs: ["lint"] before_script: - *setup-buf script: - buf generate --template buf.gen.yaml --timeout 5m - | for module in common user runner admin; do mkdir -p gen/go/${module} cd gen/go/${module} if [ ! -f go.mod ]; then go mod init gitlab.com/${PROTO_MODULE_BASE}/${module}.git fi go mod tidy cd - > /dev/null done artifacts: paths: - gen/go/ - gen/openapiv2/ expire_in: 1 hourcommit-generated: stage: commit image: golang:1.26-alpine needs: ["generate"] variables: PUSH_TOKEN: "" rules: - if: $CI_COMMIT_BRANCH =~ /^v\d+\.\d+\.\d+$/ variables: PROTO_VERSION: $CI_COMMIT_BRANCH - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: never - if: $GITLAB_USER_LOGIN == "End1essRage" variables: PUSH_TOKEN: $CI_PUSH_TOKEN - if: $GITLAB_USER_LOGIN == "stivvhuys" variables: PUSH_TOKEN: $CI_PUSH_TOKEN_GENGAVSOV - when: never before_script: - apk add --no-cache git - | if [ -z "$PUSH_TOKEN" ]; then echo "ERROR: no push token for user '$GITLAB_USER_LOGIN'" exit 1 fi - git config --global user.email "ci@gitlab.com" - git config --global user.name "GitLab CI" - git config --global url."https://gitlab-ci-token:${PUSH_TOKEN}@gitlab.com/".insteadOf "https://gitlab.com/" - echo "machine gitlab.com login gitlab-ci-token password ${PUSH_TOKEN}" > ~/.netrc - chmod 600 ~/.netrc - go env -w GOPRIVATE=gitlab.com/chichilaki/mgs - go env -w GONOSUMDB=gitlab.com/chichilaki/mgs script: - | for module in common user runner admin; do SOURCE_PATH="$CI_PROJECT_DIR/gen/go/gitlab.com/chichilaki/mgs/proto-go-stubs/$module.git" if [ ! -d "$SOURCE_PATH" ]; then echo "Source path $SOURCE_PATH not found, skipping $module" continue fi git clone https://gitlab-ci-token:${PUSH_TOKEN}@gitlab.com/chichilaki/mgs/proto-go-stubs/$module.git /tmp/repo-$module cd /tmp/repo-$module find . -mindepth 1 -not -path './.git*' -delete cp -r $SOURCE_PATH/* . SWAGGER_SRC="$CI_PROJECT_DIR/gen/openapiv2/$module/v1/$module.swagger.json" if [ -f "$SWAGGER_SRC" ]; then mkdir -p v1 cp "$SWAGGER_SRC" v1/$module.swagger.json else echo "WARN: $SWAGGER_SRC not found" fi if [ ! -f go.mod ]; then go mod init gitlab.com/chichilaki/mgs/proto-go-stubs/$module.git else go mod edit -module gitlab.com/chichilaki/mgs/proto-go-stubs/$module.git fi if [ "$module" != "common" ]; then go get gitlab.com/chichilaki/mgs/proto-go-stubs/common.git@$PROTO_VERSION fi go mod tidy if git diff --quiet && git diff --cached --quiet; then echo "No changes for $module" if ! git rev-parse "$PROTO_VERSION" >/dev/null 2>&1; then git tag "$PROTO_VERSION" git push origin "$PROTO_VERSION" fi else git add . git commit -m "Update stubs from proto repo ${CI_COMMIT_SHA} for version ${PROTO_VERSION} [skip ci]" git push origin master git tag "$PROTO_VERSION" git push origin "$PROTO_VERSION" fi cd $CI_PROJECT_DIR rm -rf /tmp/repo-$module done
Taskfile: swagger-pull
swagger-pull: desc: "Fetch swagger.json from stubs repos" cmds: - | mkdir -p docs for REPO in admin user; do TMP_DIR=$(mktemp -d) git clone --depth 1 "git@gitlab.com:chichilaki/mgs/proto-go-stubs/$REPO.git" "$TMP_DIR" cp "$TMP_DIR/v1/${REPO}.swagger.json" "docs/control-plane-${REPO}.swagger.json" rm -rf "$TMP_DIR" done
Полный compose.yaml
services: traefik: image: traefik:v3.6 restart: unless-stopped command: - "--api.dashboard=true" - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--providers.docker.network=app-network" - "--entrypoints.web.address=:80" - "--log.level=DEBUG" - "--accesslog=true" ports: - "80:80" - "8080:8080" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./../proxy/traefik:/etc/traefik networks: - app-network labels: - "traefik.enable=true" - "traefik.http.routers.dashboard.rule=Host(`localhost`) && PathPrefix(`/traefik`)" - "traefik.http.routers.dashboard.entrypoints=web" - "traefik.http.routers.dashboard.service=api@internal" swagger-ui: image: swaggerapi/swagger-ui:v5.9.0 environment: BASE_URL: /swagger WITH_CREDENTIALS: "true" URLS_PRIMARY_NAME: cp URLS: > [ { "url": "/cp/swagger/admin/doc.json", "name": "ADMIN control-plane" }, { "url": "/cp/swagger/user/doc.json", "name": "USER control-plane" } ] networks: - app-network labels: - "traefik.enable=true" - "traefik.http.routers.swagger.rule=Host(`localhost`) && PathPrefix(`/swagger`)" - "traefik.http.routers.swagger.entrypoints=web" - "traefik.http.routers.swagger.service=swagger" - "traefik.http.services.swagger.loadbalancer.server.port=8080" control-plane: build: context: ../control-plane/ dockerfile: ../control-plane/Dockerfile networks: - app-network labels: - "traefik.enable=true" - "traefik.http.routers.cp-api.rule=Host(`localhost`) && PathPrefix(`/cp`)" - "traefik.http.routers.cp-api.entrypoints=web" - "traefik.http.routers.cp-api.service=cp" - "traefik.http.middlewares.cp-strip.stripprefix.prefixes=/cp" - "traefik.http.routers.cp-api.middlewares=cp-strip" - "traefik.http.services.cp.loadbalancer.server.port=9100" - "traefik.http.services.cp.loadbalancer.healthcheck.path=/health" - "traefik.http.services.cp.loadbalancer.healthcheck.interval=10s" - "traefik.http.services.cp.loadbalancer.healthcheck.timeout=3s" - "traefik.http.services.cp.loadbalancer.healthcheck.scheme=http" - "traefik.http.routers.cp-swagger.rule=Host(`localhost`) && PathPrefix(`/cp-swagger`)" - "traefik.http.routers.cp-swagger.entrypoints=web" - "traefik.http.routers.cp-swagger.service=cp-swagger" - "traefik.http.middlewares.cp-swagger-strip.stripprefix.prefixes=/cp-swagger" - "traefik.http.routers.cp-swagger.middlewares=cp-swagger-strip" - "traefik.http.services.cp-swagger.loadbalancer.server.port=9100"networks: app-network: driver: bridge
ссылка на оригинал статьи https://habr.com/ru/articles/1042602/