Сегодня мы с вами на практике разберем что такое динамические матрицы в Github Actions и как с их помощью экономить время и ресурсы.

Я подготовил монорепозиторий с несколькими микросервисами url-shortener-demo с очень коротким флоу: feature_branch(через PR) →  main. Как понятно из названия это проект позволяющий генерировать короткие ссылки.

В makefile есть куча удобных команд для запуска всех сервисов
В makefile есть куча удобных команд для запуска всех сервисов

А для упрощения локального запуска подготовлен docker-compose.yml, состоящий из сервисов:

  1. api-gateway (Go) - API Gateway, единая точка входа

  2. shortener-service (Go + Redis) - Создание коротких URL

  3. redirect-service (Go + Redis + Kafka) - Перенаправление + события перехода для аналитики

  4. analytics-service (Go + MongoDB + Kafka) - Аналитика 

  5. frontend (HTML + Nginx) - Веб-интерфейс

Дальше надо прикрутить сборку и пуш образов наших микросервисов. В структуре репа в корне лежат одноименные сервисы + каталог pkg, в котором будут храниться общие либы. Каждый сервис внутри имеет свой Dockerfile - это важный признак того, что это конечный сервис который можно собрать.

Теперь про магию - на самом деле вы, наверняка, видели множество примеров со статичными матрицами сборки (например, когда сборка приложения делается на нескольких OS). Но что если пойти дальше и самому сгенерировать матрицу в зависимости от того что поменялось?

К счастью Github Actions позволяет нам это сделать. При создании PR мы автоматически можем определить какой сервис поменялся, собрать его и выложить. А в случае, если поменялось что-то в pkg - собрать все сервисы.

# .github/workflows/build-pr.yml

name: Build Pull Request

on:
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  packages: write
  pull-requests: write

env:
  REGISTRY: ghcr.io
  IMAGE_PREFIX: ${{ github.repository }}

jobs:
  changed-services:
    name: Detect changed services
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
      any_changed: ${{ steps.changed-files.outputs.any_changed }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Get changed services
        id: changed-files
        uses: tj-actions/changed-files@v45
        with:
          dir_names: true
          dir_names_max_depth: 1
          json: true
          files: |
            **/*
          files_ignore: |
            **/*.md
            .github/**
            scripts/**
            *.md

      - name: List all changed files
        run: |
          echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}"

      - name: Set matrix
        id: set-matrix
        run: |
          # Находим все директории с Dockerfile
          ALL_SERVICES=$(find . -maxdepth 2 -name "Dockerfile" -type f | sed 's|^\./||' | sed 's|/Dockerfile$||' | jq -R -s 'split("\n") | map(select(length > 0))' | jq -c .)
          echo "All services with Dockerfile: $ALL_SERVICES"
          
          # Получаем измененные файлы и убираем экранирование
          CHANGED_DIRS_RAW='${{ steps.changed-files.outputs.all_changed_files }}'
          CHANGED_DIRS=$(echo "$CHANGED_DIRS_RAW" | sed 's/\\"/"/g')
          echo "Changed directories: $CHANGED_DIRS"
          
          # Если изменился pkg/, пересобираем все Go сервисы (с go.mod)
          if echo "$CHANGED_DIRS" | jq -e 'index("pkg")' > /dev/null 2>&1; then
            SERVICES=$(find . -maxdepth 2 -name "go.mod" -type f | sed 's|^\./||' | sed 's|/go.mod$||' | jq -R -s 'split("\n") | map(select(length > 0))' | jq -c .)
            echo "pkg/ changed, rebuilding all Go services: $SERVICES"
          else
            # Фильтруем: оставляем только измененные директории с Dockerfile
            SERVICES=$(jq -nc --argjson all "$ALL_SERVICES" --argjson changed "$CHANGED_DIRS" \
              '$changed | map(select(. as $dir | $all | index($dir)))' | jq -c .)
            echo "Changed services: $SERVICES"
          fi
          
          # Если нет сервисов для сборки, создаем пустой массив
          if [ "$SERVICES" = "[]" ] || [ -z "$SERVICES" ]; then
            echo "No services to build"
            SERVICES="[]"
          fi
          
          echo "matrix={\"service\":$SERVICES}" >> "$GITHUB_OUTPUT"

  build:
    name: Build ${{ matrix.service }}
    runs-on: ubuntu-latest
    needs: [changed-services]
    if: ${{ needs.changed-services.outputs.any_changed == 'true' }}
    strategy:
      fail-fast: false
      matrix: ${{ fromJSON(needs.changed-services.outputs.matrix) }}
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service }}
          tags: |
            type=ref,event=pr
            type=sha,prefix=pr-${{ github.event.pull_request.number }}-
            type=raw,value=pr-${{ github.event.pull_request.number }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ${{ matrix.service }}/Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=${{ matrix.service }}
          cache-to: type=gha,mode=max,scope=${{ matrix.service }}

      - name: Add PR comment
        uses: mshick/add-pr-comment@v2
        with:
          message: |
            ✅ **${{ matrix.service }}** successfully built!
            
            **Images:**
            ```
            ${{ steps.meta.outputs.tags }}
            ```
            
            **Pull command:**
            ```bash
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:pr-${{ github.event.pull_request.number }}
            ```
          message-id: build-${{ matrix.service }}

Что мы получили по итогу создав такой workflow, который треггерится при создание/обновление Pull Request в main:

  1. Определяет измененные сервисы - анализирует какие сервисы были изменены в PR

  2. Собирает только измененные сервисы - использует динамическую матрицу. Если изменился pkg/ - пересобираются все Go сервисы. Никаких лишних сборок.

  3. Пушит образы с тегами PR - например, pr-123pr-123-sha123abc

  4. Добавляет комментарий в PR - с информацией о собранных образах

Самое главное что нам не нужно заботиться о том, чтобы поменять CI и о чем то думать -  достаточно положить в корень репозитория свой новый сервис и все автоматом заведется. 

А в качестве тренировке можете форкнуть реп https://github.com/itcaat/url-shortener-demo (все примеры workflow вы найдете там же) и сделать так, чтобы собирались не все сервисы при изменении в pkg, а только те что реально зависят от измененного пакета.


Другие авторские статьи по теме DevOps, SRE и администрирования вы можете найти в моем Telegram-канале DevOps Brain ↩

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


  1. HipsterLondon
    17.10.2025 13:42

    Интересная статья, спасибо.

    А можно ли как-то при изменении общего кода пересобирать только нужные сервисы? Строить граф зависимостей?


    1. Derfirm
      17.10.2025 13:42

      В целом это можно, к примеру построить sbom и сравнивать с текущими именами сервисов/библиотек