golang

Автоматизируем генерацию gRPC стабов для Go

  • среда, 3 июня 2026 г. в 00:00:18
https://habr.com/ru/articles/1042602/

Настраиваем генерацию gRPC стабов для Go и подключаем как модуль

Держать proto-контракты в одном репозитории удобно, но подключать их целиком в каждый сервис — не очень. Разберём, как автоматически генерировать Go-стабы из proto-файлов, версионировать их как отдельные Go-модули и публиковать через GitLab CI/CD. Бонусом — swagger-документация и GitLab Pages.

Всё описанное рассчитано на приватный бесплатный GitLab. В self-hosted и платной версиях, а также для публичных репозиториев настройка будет значительно проще — там часть ограничений просто снимается.


Примечания перед стартом

Подход требует настройки dev-окружения: стандартного доступа к репозиторию по SSH и нескольких переменных окружения на каждой машине разработчика:

GOPRIVATE=gitlab.com/your-group
GOPROXY=direct
GONOSUMDB=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.proto
syntax = "proto3";
package common.v1;
option go_package = "gitlab.com/chichilaki/mgs/proto-go-stubs/common.git/v1";

Для сервисных контрактов с HTTP-аннотациями grpc-gateway и swagger-опциями структура выглядит так:

// admin.proto
syntax = "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_VERSION
fi

Это важно: без явного go get с тегом go mod tidy подтянет latest, а не ту версию, которую мы только что опубликовали.


Сервис, использующий стабы

Рассмотрим сторону потребителя — сервис, который хочет держать swagger-документацию у себя, отдавать её через HTTP и взаимодействовать с API через Swagger UI.

Встраивание swagger.json через go:embed

В корне проекта создаётся пакет docs:

package docs

import _ "embed"

//go:embed control-plane-admin.swagger.json
var AdminSwaggerJSON []byte

//go:embed control-plane-user.swagger.json
var 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.json

pages:
  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: v2
deps:
  - buf.build/googleapis/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
buf.gen.yaml
version: v2
plugins:
  - 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
  - commit

variables:
  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 update

lint:
  stage: lint
  image: alpine:latest
  before_script:
    - *setup-buf
  script:
    - buf lint
  allow_failure: false

generate:
  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 hour

commit-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