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

от автора

Настраиваем генерацию 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/