Мы все знаем, что тестирование на локальной машине или в изолированном CI-окружении — это не совсем то же самое, что тестирование в реальном кластере Kubernetes. Контекстно-зависимые ошибки, проблемы с сетевыми политиками, особенности работы с секретами и конфигурациями — все это может вылезти уже после деплоя в продакшн, если не протестировать заранее. В последние пару лет я перепробовал несколько подходов к тестированию PR в кластерах Kubernetes и пришол к определённым выводам. Поделюсь, как настроить тестирование каждого Pull Request прямо в Google Kubernetes Engine с использованием GitHub Actions — так, чтобы каждый PR получал свое собственное тестовое окружение, полностью идентичное продакшену.
В этой статье я расскажу, как создать и настроить кластер GKE, подготовить манифесты приложения с Kustomize для кастомизации, интегрировать GitHub Actions с GKE, автоматизировать сборку и хранение Docker-образов, устанавливать зависимости вроде PostgreSQL через Helm-чарты и, наконец, запускать тесты против развернутого приложения. Я не буду ходить вокруг да около — это не статья о том, как использовать Kubernetes в целом или что такое GitHub Actions. Предполагаю, что вы уже имеете базовое представление об этих технологиях. Вместо этого я сконцентрируюсь на конкретных практических аспектах интеграции этих инструментов для решения задачи тестирования PR.
Архитектура решения для тестирования PR
Давайте разберемся с общей архитектурой решения. Когда я впервые столкнулся с задачей тестирования PR в Kubernetes, я расчертил для себя схему всего процеса, чтобы понимать, что именно нужно сделать и какие компоненты взаимодействуют между собой. Итак, наша архитектура должна решать следующие задачи:
1. Создание изолированной среды для каждого PR.
2. Сборка и хранение Docker-образов для каждой версии приложения.
3. Деплой приложения и его зависимостей в кластер.
4. Запуск тестов против развернутого приложения.
5. Предоставление обратной связи в GitHub PR.
Поскольку мы используем GitHub Actions и GKE, центральным компонентом нашей архитектуры будет рабочий процесс GitHub, который взаимодействует с кластером GKE. Фактически, у нас есть два основных подхода к реализации:
Динамический подход: создавать новый кластер GKE для каждого PR.
Статический подход: использовать один предварительно настроенный кластер и развертывать приложения в разных пространствах имен.
У обоих подходов есть свои плюсы и минусы. Создание нового кластера для каждого PR обеспечивает максимальную изоляцию, но требует времени (5-7 минут на создание кластера GKE) и дополнительных затрат. Использование общего кластера быстрее и дешевле, но может привести к конфликтам ресурсов и меньшей изоляции. В моем случае я выбрал второй подход — использование одного кластера GKE для всех PR. Это компромисное решение, которое обеспечивает достаточную изоляцию при разумных затратах.
Предварительная настройка кластера GKE
Первый важный шаг в нашей архитектуре — создание и настройка кластера GKE. Для тестовой среды нам не нужен огромный кластер с множеством узлов, но и слишком маленький делать не стоит. Рекомендую создать кластер хотя бы с 2-3 узлами, чтобы иметь запас ресурсов для нескольких параллельных PR. Вот пример команды для создания минимального кластера GKE:
| Bash | 1
2
3
4
5
6
7
| gcloud container clusters create "test-pr-cluster" \
--project "ваш-проект" \
--zone "europe-west3" \
--num-nodes "2" \
--machine-type "e2-standard-4" \
--enable-ip-alias \
--no-enable-basic-auth |
|
Обратите внимание на флаг --machine-type. В моих экспериментах я начинал с e2-standard-2, но быстро понял, что при паралельном тестировании нескольких PR ресурсов не хватает. Пришлось обновить до e2-standard-4, что дало гораздо лучшие результаты. Если у вас приложение ресурсоемкое или вы ожидаете много одновременных PR, возможно, стоит выбрать еще более производительные машины.
Аутентификация в Google Cloud из GitHub Actions
Следующий ключевой компонент архитектуры — настройка аутентификации между GitHub Actions и Google Cloud. Это критически важный момент с точки зрения безопасности и функциональности. В Google Cloud есть несколько способов аутентификации, я расмотрю самый практичный и безопасный. Для интеграции GitHub Actions с GKE мы используем рабочую идентификацию через сервисный аккаунт (Workload Identity Federation through a Service Account). Этот метод позволяет GitHub Actions получать временные токены для доступа к GKE без необходимости хранения постоянных ключей доступа. Настройка такой аутентификации включает несколько шагов:
1. Создание сервисного аккаунта в Google Cloud.
2. Настройка разрешений для сервисного аккаунта.
3. Создание пула рабочей идентификации (Workload Identity Pool).
4. Настройка провайдера OIDC для GitHub Actions.
5. Связывание сервисного аккаунта с пулом идентификации.
Например, для создания сервисного аккаунта можно использовать:
| Bash | 1
2
| gcloud iam service-accounts create github-actions \
--display-name "GitHub Actions Service Account" |
|
Затем необходимо предоставить этому сервисному аккаунту необходимые права для работы с GKE:
| Bash | 1
2
3
| gcloud projects add-iam-policy-binding ваш-проект \
--member "serviceAccount:github-actions@ваш-проект.iam.gserviceaccount.com" \
--role "roles/container.developer" |
|
Далее создаем пул рабочей идентификации и настраиваем его для работы с GitHub Actions:
| Bash | 1
2
3
4
5
6
7
8
9
10
| gcloud iam workload-identity-pools create "github-actions-pool" \
--project="ваш-проект" \
--display-name="GitHub Actions Pool"
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
--project="ваш-проект" \
--workload-identity-pool="github-actions-pool" \
--display-name="GitHub Provider" \
--attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor" \
--issuer-uri="https://token.actions.githubusercontent.com" |
|
И, наконец, связываем сервисный аккаунт с пулом идентификации:
| Bash | 1
2
3
4
5
| gcloud iam service-accounts add-iam-policy-binding \
"github-actions@ваш-проект.iam.gserviceaccount.com" \
--project="ваш-проект" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/номер-проекта/locations/global/workloadIdentityPools/github-actions-pool/*" |
|
Эти шаги могут показаться сложными, но они выполняются один раз при настройке инфраструктуры. После этого GitHub Actions сможет аутентифицироваться в Google Cloud без хранения долгоживущих секретов.
Управление Docker-образами
Важной частью нашей архитектуры является построение и хранение Docker-образов для каждого PR. Для этого я использую GitHub Container Registry (GHCR), который интегрирован с GitHub и позволяет хранить образы приватно, с доступом через GitHub-аутентификацию. В рамках рабочего процесса GitHub Actions мы будем:
1. Собирать Docker-образ из кода в PR,
2. Тегировать его уникальным идентификатором (например, ID рабочего процесса GitHub),
3. Отправлять образ в GHCR,
4. Использовать этот образ при деплое в GKE.
Эта часть архитектуры гарантирует, что каждый PR тестируется с соответствующей версией кода, без влияния других PR или основной ветки.
Манифесты Kubernetes и Kustomize
Для деплоя приложения в GKE нам нужны манифесты Kubernetes. Но у нас есть проблема: мы не знаем заранее, какой тег будет у Docker-образа, поскольку он генерируется во время выполнения рабочего процесса. Кроме того, разные PR должны развертываться изолированно друг от друга. Для решения этой проблемы я использую Kustomize — инструмент для кастомизации манифестов Kubernetes без использования шаблонов. С помощью Kustomize мы можем:
1. Определить базовые манифесты приложения.
2. Динамически изменять образ и теги во время выполнения рабочего процесса.
3. Создавать уникальные имена ресурсов для каждого PR.
В базовом манифесте мы указываем плейсхолдер для тега образа, который затем заменяется реальным значением в процессе деплоя.
Итак, общая архитектура решения состоит из:- Предварительно настроенного кластера GKE,
- Сервисного аккаунта Google Cloud с необходимыми разрешениями,
- Рабочего процесса GitHub Actions, который аутентифицируется в Google Cloud,
- Docker-образов, хранящихся в GitHub Container Registry,
- Манифестов Kubernetes с Kustomize для динамической кастомизации.
Детали манифестов и шаблонизация
Когда я впервые столкнулся с проблемой настройки тестирования PR в Kubernetes, одним из самых сложных аспектов оказалась разработка манифестов, которые бы одновременно сохраняли все характеристики продакшн-среды и позволяли гибко менять некоторые параметры для каждого PR. В моем случае, базовый манифест приложения выглядит примерно так:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| apiVersion: apps/v1
kind: Deployment
metadata:
name: vcluster-pipeline
labels:
type: app
app: vcluster-pipeline
spec:
replicas: 1
selector:
matchLabels:
app: vcluster-pipeline
template:
metadata:
labels:
type: app
app: vcluster-pipeline
spec:
containers:
- name: vcluster-pipeline
image: ghcr.io/моя-организация/моё-приложение:latest
envFrom:
- configMapRef:
name: postgres-config
imagePullSecrets:
- name: github-docker-registry
---
apiVersion: v1
kind: Service
metadata:
name: vcluster-pipeline
spec:
type: LoadBalancer
ports:
- port: 8080
targetPort: 8080
selector:
app: vcluster-pipeline |
|
В этом манифесте есть несколько ключевых моментов, которые нуждаются в кастомизации для каждого PR:
1. Тег Docker-образа — он должен быть уникальным для каждого PR.
2. Настройки подключения к базе данных — мы получаем их из ConfigMap.
3. Доступ к приватному реестру — используем секрет для аутентификации.
4. Имя сервиса — оно должно быть уникальным для каждого PR.
5. IP-адрес LoadBalancer — будет назначен автоматически GKE.
Kustomize позволяет решить все эти проблемы элегантным способом. В том же каталоге, что и манифест, я создаю файл kustomization.yaml:
| YAML | 1
2
3
4
5
6
7
| apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
vcluster-pipeline.yaml
images:
name: ghcr.io/моя-организация/моё-приложение
newTag: DYNAMIC_TAG |
|
Здесь DYNAMIC_TAG — это плейсхолдер, который будет заменен во время выполнения рабочего процесса GitHub Actions на реальный тег образа.
Проблема доступа к приватному реестру
Еще одной проблемой, которую я выявил на ранних этапах, был доступ к приватному реестру Docker-образов из кластера GKE. По умолчанию Kubernetes не может скачать образ из приватного реестра GitHub без аутентификации. Решение — создать секрет Kubernetes типа docker-registry, который содержит учетные данные для доступа к реестру. В рабочем процессе GitHub Actions это выглядит так:
| YAML | 1
2
3
4
5
6
7
8
| name: Create Docker Registry Secret
run: |
kubectl create secret docker-registry github-docker-registry \
--docker-server=${{ env.REGISTRY }} \
--docker-email="noreply@github.com" \
--docker-username="${{ github.actor }}" \
--docker-password="${{ secrets.GITHUB_TOKEN }}" \
--dry-run=client -o yaml | kubectl apply -f - |
|
Эта команда создает секрет с учетными данными GitHub, который затем используется в поле imagePullSecrets в манифесте Deployment.
Настройка зависимостей: база данных PostgreSQL
Для полноценного тестирования обычно требуется не только само приложение, но и его зависимости, например, база данных. В моем случае это PostgreSQL. Для настройки базы данных я использую Helm — менеджер пакетов для Kubernetes.
Вот пример файла values.yaml для Helm-чарта PostgreSQL:
| YAML | 1
2
3
4
5
6
7
8
| fullnameOverride: postgres
auth:
user: postgres
password: root
postgresPassword: roottoo
primary:
persistence:
enabled: false |
|
Обратите внимание на параметр persistence.enabled: false — это означает, что данные не будут сохраняться на диске. Для тестирования PR это обычно приемлемо, поскольку нам не нужно сохранять данные между запусками.
После установки Helm-чарта PostgreSQL я создаю ConfigMap с параметрами подключения к базе данных:
| YAML | 1
2
3
4
5
6
7
| name: Set config map from values.yaml
run: |
kubectl create configmap postgres-config \
--from-literal="SPRING_FLYWAY_URL=jdbc:postgresql://$(yq .fullnameOverride kubernetes/values.yaml):5432/" \
--from-literal="SPRING_R2DBC_URL=r2dbc:postgresql://$(yq .fullnameOverride kubernetes/values.yaml):5432/" \
--from-literal="SPRING_R2DBC_USERNAME=$(yq .auth.user kubernetes/values.yaml)" \
--from-literal="SPRING_R2DBC_PASSWORD=$(yq .auth.password kubernetes/values.yaml)" |
|
[Github] Почему мне следует комитить файл лицензии в отдельную ветку и делать pull request? https://help.github.com/articles/adding-a-license-to-a-repository/
В 8 пункте сказано, что если... Могу ли я удалить свой форк репозитория GitHub после принятия pull request Я создал форк публичного репозитория на github с целью - исправения ошибок в основном репозитории.... GitHub и Pull Request. Как скинуть ссылку? Вопрос такой, мне преподаватель сказал скинуть ссылку на Pull Request в GitHub. Дескопный GitHub... New pull request на Github Не знаю, что нажать, чтобы добавленный через браузер новый файл появился локально. Кнопка что-то не...
Практическая реализация CI/CD пайплайна
Теперь, когда я обрисовал общую архитектуру решения, погрузимся в практическую реализацию CI/CD пайплайна на GitHub Actions. Это, пожалуй, самая интересная часть всего процесса, где теоретические концепции превращаются в рабочие скрипты и автоматизацию. Наш пайплайн должен выполнять несколько критических задач: собирать и публиковать Docker-образы, аутентифицироваться в Google Cloud, развертывать приложение и его зависимости, а затем запускать тесты. И все это должно происходить автоматически при создании или обновлении Pull Request.
Сборка и публикация Docker-образа
Первый шаг нашего пайплайна — сборка Docker-образа приложения и его публикация в GitHub Container Registry. Для этого используем действия из экосистемы Docker:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKER_BUILD_RECORD_RETENTION_DAYS: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{github.run_id}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
cache-from: type=gha
cache-to: type=gha,mode=max |
|
Тут несколько интересных моментов:
1. permissions определяет, какие права нужны для работы с GitHub Container Registry.
2. В теге образа я использую github.run_id — уникальный идентификатор запуска GitHub Actions, что позволяет каждому PR иметь свой уникальный образ.
3. Включено кеширование через параметры cache-from и cache-to, что ускоряет сборку при повторных запусках.
Обратите внимание на DOCKER_BUILD_RECORD_RETENTION_DAYS: 1 — это ограничивает время хранения образов всего одним днем. В контексте тестирования PR это вполне разумно, поскольку нам не нужны старые образы, а также это позволяет сэкономить на хранении.
Аутентификация в Google Cloud и GKE
Следующий критический шаг — аутентификация в Google Cloud и получение доступа к кластеру GKE:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
| name: Authenticate on Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-provider
service_account: [email]github-actions@ваш-проект.iam.gserviceaccount.com[/email]
name: Set GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: test-pr-cluster
location: europe-west3 |
|
Здесь я использую действие google-github-actions/auth для аутентификации через Workload Identity Federation, как обсуждалось ранее. Затем действие get-gke-credentials настраивает kubectl для работы с нашим кластером GKE. Стоит отметить, что в реальных проектах вы, вероятно, захотите вынести идентификатор пула Workload Identity и имя сервисного аккаунта в секреты репозитория, чтобы не хардкодить их в рабочем процессе.
Создание уникального пространства имен для PR
Чтобы изолировать ресурсы каждого PR, я создаю отдельное пространство имен в Kubernetes:
| YAML | 1
2
3
4
5
6
| name: Create namespace for PR
run: |
NAMESPACE="pr-${{ github.event.pull_request.number }}"
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
kubectl config set-context --current --namespace=$NAMESPACE
echo "NAMESPACE=$NAMESPACE" >> $GITHUB_ENV |
|
Этот шаг создает пространство имен на основе номера PR и устанавливает его как текущий контекст для последующих команд kubectl. Также я сохраняю имя пространства имен в переменной среды для использования в дальнейших шагах.
Установка зависимостей: PostgreSQL
Теперь устанавливаем PostgreSQL с помощью Helm:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
| name: Install PostgreSQL
run: |
helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql \
--values kubernetes/values.yaml \
--namespace ${{ env.NAMESPACE }}
name: Wait for PostgreSQL to be ready
run: |
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=postgresql \
--timeout=120s \
--namespace ${{ env.NAMESPACE }} |
|
Я добавил дополнительный шаг ожидания готовности PostgreSQL, потому что часто встречался с ситуацией, когда следующие шаги запускались до того, как база данных была полностью инициализирована, что приводило к ошибкам.
Создание ConfigMap и Secrets
Следующий шаг — создание ConfigMap с параметрами подключения к базе данных:
| YAML | 1
2
3
4
5
6
7
8
9
| name: Create ConfigMap for application
run: |
kubectl create configmap postgres-config \
--from-literal="SPRING_FLYWAY_URL=jdbc:postgresql://postgresql:5432/" \
--from-literal="SPRING_R2DBC_URL=r2dbc:postgresql://postgresql:5432/" \
--from-literal="SPRING_R2DBC_USERNAME=postgres" \
--from-literal="SPRING_R2DBC_PASSWORD=root" \
--namespace ${{ env.NAMESPACE }} \
--dry-run=client -o yaml | kubectl apply -f - |
|
Также создаем секрет для доступа к приватному реестру Docker-образов:
| YAML | 1
2
3
4
5
6
7
8
9
| name: Create Docker Registry Secret
run: |
kubectl create secret docker-registry github-docker-registry \
--docker-server=${{ env.REGISTRY }} \
--docker-email="noreply@github.com" \
--docker-username="${{ github.actor }}" \
--docker-password="${{ secrets.GITHUB_TOKEN }}" \
--namespace ${{ env.NAMESPACE }} \
--dry-run=client -o yaml | kubectl apply -f - |
|
Деплой приложения
Теперь самое интересное — деплой нашего приложения с помощью Kustomize:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| name: Update image tag in Kustomization
run: |
cd kubernetes
kustomize edit set image ghcr.io/моя-организация/моё-приложение=ghcr.io/моя-организация/моё-приложение:${{ github.run_id }}
name: Deploy application
run: |
kubectl apply -k kubernetes --namespace ${{ env.NAMESPACE }}
name: Wait for application to be ready
run: |
kubectl wait --for=condition=ready pod -l app=vcluster-pipeline \
--timeout=120s \
--namespace ${{ env.NAMESPACE }} |
|
Первый шаг обновляет тег образа в файле kustomization.yaml, заменяя плейсхолдер на реальный тег с идентификатором запуска GitHub Actions. Затем мы применяем манифесты с помощью kubectl apply -k и ждем, пока приложение будет готово.
Заметил, что эти шаги ожидания часто игнорируются в туториалах, но в реальной жизни они критически важны для надежности пайплайна. Без них я постоянно сталкивался с ситуациями, когда тесты запускались на ещё не готовом к работе приложении.
Получение IP-адреса приложения и запуск тестов
После деплоя приложения нужно получить его внешний IP-адрес для запуска тестов. Поскольку я использую сервис типа LoadBalancer, GKE автоматически назначит внешний IP-адрес. Однако тут есть важный нюанс — этот процесс не мгновенный, и нужно подождать, пока IP будет назначен. Вот решение, которое я применил:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| name: Retrieve LoadBalancer external IP
run: |
for i in {1..10}; do
EXTERNAL_IP=$(kubectl get service vcluster-pipeline -o jsonpath='{.status.loadBalancer.ingress[0].ip}' --namespace ${{ env.NAMESPACE }})
if [ -n "$EXTERNAL_IP" ]; then
break
fi
echo "Waiting for external IP... Attempt $i of 10"
sleep 10
done
if [ -z "$EXTERNAL_IP" ]; then
echo "Error: External IP not assigned to the service" >&2
exit 1
fi
APP_BASE_URL="http://${EXTERNAL_IP}:8080"
echo "APP_BASE_URL=$APP_BASE_URL" >> $GITHUB_ENV
echo "External IP is $APP_BASE_URL" |
|
Этот скрипт делает до 10 попыток получить IP-адрес, с интервалом в 10 секунд между попытками. Если после всех попыток IP не назначен, пайплайн завершится с ошибкой. В противном случае URL приложения сохраняется в переменной среды APP_BASE_URL для использования в тестах. Я раньше сталкивался с тем, что в некоторых инструкциях просто предлагают сразу запросить IP без проверки, что приводило к ошибкам. Или еще хуже — просто ставили фиксированную задержку в 30-60 секунд, что либо замедляло пайплайн, либо все равно иногда не работало, если провайдер облака был перегружен и назначал IP дольше обычного.
Запуск интеграционных тестов
Теперь, когда приложение развернуто и у нас есть его URL, можно запустить тесты. В моем случае это интеграционные тесты, которые проверяют работу приложения в реальной среде:
| YAML | 1
2
3
4
5
6
| name: Run integration tests
run: |
export APP_BASE_URL
./mvnw -B verify -Dtest=SkipAll -Dit.test=ApplicationIT -Dsurefire.failIfNoSpecifiedTests=false
env:
APP_BASE_URL: ${{ env.APP_BASE_URL }} |
|
Обратите внимание на параметр -Dtest=SkipAll. Это небольшая хитрость, которая позволяет пропустить выполнение всех модульных тестов и запустить только интеграционные. В Maven это можно сделать, указав шаблон, который не соответствует ни одному классу модульных тестов. Тут мы акцентируем внимание только на тестах, которые взаимодействуют с реальным развернутым приложением.
Обработка результатов тестов и обновление PR
После запуска тестов важно предоставить обратную связь в PR. Я делаю это с помощью действия github/script:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| name: Update PR with test results
if: always()
uses: actions/github-script@v6
with:
script: |
const outcome = '${{ job.status }}';
const url = '${{ env.APP_BASE_URL }}';
let message = '';
if (outcome === 'success') {
message = `Интеграционные тесты успешно пройдены\n\nПриложение доступно по адресу: ${url}\n\nЭто окружение будет автоматически удалено через 24 часа.`;
} else {
message = `Интеграционные тесты завершились с ошибкой\n\nПриложение доступно по адресу: ${url} для отладки проблемы.\n\nЭто окружение будет автоматически удалено через 24 часа.`;
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: message
}); |
|
Директива if: always() гарантирует, что этот шаг выполнится независимо от результата тестов. Это важно, потому что мы хотим сообщить результаты даже в случае неудачи.
В сообщении я указываю URL приложения, что очень удобно для отладки проблем — разработчик может сразу перейти по ссылке и проверить, что не так с его PR.
Очистка ресурсов
В идеальном мире мы бы сразу удаляли все ресурсы после завершения тестов, но на практике я предпочитаю оставлять их на некоторое время для возможности отладки:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| name: Schedule cleanup
if: always()
run: |
cat <<EOF | kubectl apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
name: cleanup-pr-${{ github.event.pull_request.number }}
namespace: default
spec:
schedule: "0 0 * * *" # Полночь каждый день
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
serviceAccountName: cleanup-sa
containers:
- name: kubectl
image: bitnami/kubectl
command:
- /bin/sh
- -c
- kubectl delete namespace ${{ env.NAMESPACE }} || true
restartPolicy: OnFailure
EOF |
|
Этот шаг создает CronJob, который удалит пространство имен PR через день. Конечно, для этого необходимо предварительно создать сервисный аккаунт cleanup-sa с соответствующими правами.
Дополнительные соображения
В процессе работы с этим пайплайном я столкнулся с несколькими проблемами, которые стоит упомянуть:
1. Параллельное выполнение. Если в вашем репозитории может быть много одновременных PR, убедитесь, что кластер GKE имеет достаточно ресурсов. Я однажды столкнулся с ситуацией, когда 10 одновременных PR полностью исчерпали ресурсы кластера.
2. Время выполнения. Весь процесс от коммита до результатов тестов занимает около 5-7 минут, что вполне приемлемо для CI/CD пайплайна. Большую часть времени занимает сборка и публикация Docker-образа.
3. Стоимость. Даже небольшой кластер GKE стоит денег. Если у вас низкая активность PR, возможно, стоит рассмотреть вариант с созданием кластера только при необходимости, несмотря на дополнительное время ожидания.
4. Безопасность. Помните, что сервисный аккаунт, используемый в GitHub Actions, имеет доступ к вашему проекту Google Cloud. Ограничивайте его права минимально необходимыми и регулярно проверяйте настройки.
5. Отладка. Иногда что-то идет не так, и может быть сложно понять причину. Я добавил в пайплайн дополнительные шаги для вывода отладочной информации:
| YAML | 1
2
3
4
5
6
7
8
| name: Debug information
if: always()
run: |
echo "Namespace: ${{ env.NAMESPACE }}"
echo "App URL: ${{ env.APP_BASE_URL }}"
kubectl get all --namespace ${{ env.NAMESPACE }}
kubectl describe pods --namespace ${{ env.NAMESPACE }}
kubectl logs -l app=vcluster-pipeline --namespace ${{ env.NAMESPACE }} --tail=100 |
|
Это помогает быстро идентифицировать проблемы, не переключаясь в консоль Google Cloud.
Инкрементальное тестирование и blue-green деплой в контексте PR
В процессе работы с тестированием PR на Kubernetes я понял, что не все тесты нужно запускать сразу. Иногда разумнее использовать инкрементальный подход. Я разделил тесты на несколько категорий:
1. Базовые тесты (проверка подключения, пинг эндпоинтов).
2. Функциональные тесты (CRUD-операции).
3. Нагрузочные тесты (только для критических PR).
Это позволяет получать быструю обратную связь и не тратить ресурсы на полное тестирование заведомо проблемных PR. Вот как можно реализовать такой подход:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| name: Run basic connectivity tests
run: |
curl -f ${{ env.APP_BASE_URL }}/health || exit 1
curl -f ${{ env.APP_BASE_URL }}/api/v1/status || exit 1
echo "Basic connectivity tests passed"
name: Run functional tests
if: success()
run: |
./mvnw -B verify -Dtest=SkipAll -Dit.test=FunctionalIT -Dsurefire.failIfNoSpecifiedTests=false
env:
APP_BASE_URL: ${{ env.APP_BASE_URL }}
name: Run load tests
if: success() && contains(github.event.pull_request.labels.*.name, 'full-test')
run: |
./mvnw -B verify -Dtest=SkipAll -Dit.test=LoadIT -Dsurefire.failIfNoSpecifiedTests=false
env:
APP_BASE_URL: ${{ env.APP_BASE_URL }} |
|
Другое интересное решение, которое я внедрил - стратегия blue-green деплоя прямо в PR-тестировании. Это особенно полезно для проверки миграций баз данных или других сложных изменений. Суть в том, что мы сначала деплоим текущую версию из main, запускаем на ней тесты, а потом заменяем на версию из PR и снова запускаем тесты.
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| name: Deploy current main version
run: |
kubectl apply -k kubernetes/stable --namespace ${{ env.NAMESPACE }}
kubectl wait --for=condition=ready pod -l app=vcluster-pipeline,version=stable --timeout=120s --namespace ${{ env.NAMESPACE }}
name: Run baseline tests
run: |
./mvnw -B verify -Dtest=SkipAll -Dit.test=BaselineIT -Dsurefire.failIfNoSpecifiedTests=false
env:
APP_BASE_URL: ${{ env.STABLE_APP_URL }}
name: Deploy PR version
run: |
kubectl apply -k kubernetes/pr --namespace ${{ env.NAMESPACE }}
kubectl wait --for=condition=ready pod -l app=vcluster-pipeline,version=pr --timeout=120s --namespace ${{ env.NAMESPACE }}
name: Run migration tests
run: |
./mvnw -B verify -Dtest=SkipAll -Dit.test=MigrationIT -Dsurefire.failIfNoSpecifiedTests=false
env:
STABLE_APP_URL: ${{ env.STABLE_APP_URL }}
PR_APP_URL: ${{ env.PR_APP_URL }} |
|
Rollback-стратегии в контексте PR-тестирования
В PR-тестировании стратегия отката немного отличается от продакшена. Нам не нужно откатывать изменения, так как неудачный PR просто не мержится. Однако иногда полезно предусмотреть автоматический откат в рамках самого тестирования - например, если деплой приложения прошол успешно, но оно не запускается или не отвечает на запросы.
Я реализовал простую, но эффективную стратегию:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
| name: Check application health
run: |
for i in {1..12}; do
if curl -s -f ${{ env.APP_BASE_URL }}/health > /dev/null; then
echo "Application is healthy"
exit 0
fi
echo "Waiting for application to become healthy... Attempt $i of 12"
sleep 10
done
echo "Application failed health check, performing rollback"
kubectl rollout undo deployment/vcluster-pipeline --namespace ${{ env.NAMESPACE }}
exit 1 |
|
Этот скрипт проверяет здоровье приложения в течение 2 минут. Если приложение не становится доступным, выполняется откат деплоймента и пайплайн завершается с ошибкой.
Работа с базами данных и состоянием
Отдельная головная боль - это миграции схемы базы данных. Тут у меня два подхода:
1. Для небольших проектов я использую встроенные механизмы миграции (Flyway, Liquibase) и позволяю приложению самостоятельно мигрировать схему при запуске.
2. Для крупных проектов предпочитаю отдельный шаг миграции в пайплайне:
| YAML | 1
2
3
4
| name: Run database migrations
run: |
kubectl create job --from=cronjob/db-migrate db-migrate-${{ github.run_id }} --namespace ${{ env.NAMESPACE }}
kubectl wait --for=condition=complete job/db-migrate-${{ github.run_id }} --timeout=180s --namespace ${{ env.NAMESPACE }} |
|
Эта строатегия имеет ряд преимуществ:- Миграции выполняются до запуска приложения.
- Легко отследить ошибки миграции.
- Можно выполнить откат миграции в случае проблем.
В моем конкретном случае с PR-тестированием я использую упрощенный подход с Flyway, поскольку каждый PR получает свежую базу данных. Но в реальных проэктах такая стратегия может привести к проблемам, если разные PR изменяют схему базы по-разному. Еще один аспект, о котором стоит упомянуть - это использование заранее подготовленных данных для тестирования. В моей реализации я добавил дополнительный шаг для инициализации базы данных тестовыми данными:
| YAML | 1
2
3
| name: Seed database with test data
run: |
kubectl exec -i $(kubectl get pod -l app.kubernetes.io/name=postgresql -o jsonpath='{.items[0].metadata.name}' --namespace ${{ env.NAMESPACE }}) -- psql -U postgres -d postgres < ./testdata/seed.sql |
|
Это позволяет создать необходимый набор данных для тестирования и делает тесты более предсказуемыми.
Стратегии изоляции тестовых сред
Когда я начал внедрять тестирование PR в Kubernetes, одной из ключевых проблем оказалась необходимость надежной изоляции между разными тестовыми средами. Без правильной изоляции PR могут мешать друг другу, что сильно подрывает доверие к результатам тестов.
Использование пространств имен для разделения PR
Самый очевидный способ разделения ресурсов в Kubernetes - это использование отдельных пространств имен (namespaces). Именно такой подход я показал ранее, где для каждого PR создается уникальное пространство имен:
| YAML | 1
2
3
4
5
6
| name: Create namespace for PR
run: |
NAMESPACE="pr-${{ github.event.pull_request.number }}"
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
kubectl config set-context --current --namespace=$NAMESPACE
echo "NAMESPACE=$NAMESPACE" >> $GITHUB_ENV |
|
Это дает нам неплохую базовую изоляцию, но у нее есть и ограничения:- Разные PR все еще используют общие ресурсы кластера (CPU, память).
- Они могут видеть пространства имен друг друга.
- Сетевая изоляция по умолчанию отсутствует.
Для усиления изоляции я обычно добавляю сетевые политики (Network Policies):
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| name: Create network policy
run: |
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-only-internal
namespace: ${{ env.NAMESPACE }}
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ${{ env.NAMESPACE }}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ${{ env.NAMESPACE }}
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
EOF |
|
Эта политика запрещает подам из разных PR коммуницировать между собой, что дополнительно повышает изоляцию.
Квоты ресурсов для предотвращения конфликтов
Еще одна распостраненная проблема, которая у меня возникала - это истощение ресурсов кластера из-за "жадного" PR. Я помню ситуацию, когда один PR с нагрузочным тестированием полностью "забрал" все ресурсы кластера, из-за чего остальные тесты начали падать. Решение - установка ResourceQuota для каждого пространства имен:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| name: Create resource quota
run: |
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ResourceQuota
metadata:
name: pr-quota
namespace: ${{ env.NAMESPACE }}
spec:
hard:
requests.cpu: "2"
requests.memory: 2Gi
limits.cpu: "4"
limits.memory: 4Gi
EOF |
|
Это гарантирует, что один PR не сможет захватить больше ресурсов, чем ему положено.
Динамическое создание и очистка тестовых окружений
Для эффективной работы системы очень важно не просто создавать тестовые среды, но и своевременно их уничтожать. Тут есть несколько стратегий:
1. Очистка по времени жизни - создаем CronJob, который удаляет пространства имен старше определенного возраста:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| name: Create cleanup job
run: |
cat <<EOF | kubectl apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
name: cleanup-old-prs
namespace: default
spec:
schedule: "0 */6 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: kubectl
image: bitnami/kubectl
command:
- /bin/sh
- -c
- |
for ns in \$(kubectl get ns -l created-by=pr-test --output=jsonpath={.items[*].metadata.name}); do
age=\$(kubectl get ns \$ns -o go-template="{{.metadata.creationTimestamp}}")
now=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
age_seconds=\$(( \$(date -d "\$now" +%s) - \$(date -d "\$age" +%s) ))
if [ \$age_seconds -gt 86400 ]; then
kubectl delete ns \$ns
fi
done
restartPolicy: OnFailure
EOF |
|
2. Очистка по статусу PR - настройка вебхука, который удаляет среду, когда PR закрывается или мержится:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| name: Cleanup PR Environment
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Authenticate with GKE
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: [email]github-actions@project.iam.gserviceaccount.com[/email]
- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@v2
with:
cluster_name: test-pr-cluster
location: europe-west3
- name: Delete namespace
run: kubectl delete namespace pr-${{ github.event.pull_request.number }} --ignore-not-found |
|
Грамотная стратегия очистки тестовых сред не менее важна, чем их создание, особенно когда у вас активный проект с множеством PR каждый день.
Мониторинг и отладка процесса тестирования
Даже самый продуманный пайплайн для тестирования PR будет бесполезен, если вы не знаете, что происходит внутри. Когда вы запускаете тесты в Kubernetes, вы сталкиваетесь с дополнительным уровнем сложности по сравнению с локальным тестированием. Логи разбросаны по разным подам, метрики не собраны в одном месте, а отладка становится настоящим квестом. Поэтому правильная настройка мониторинга и отладки - ключевой фактор для успешного PR-тестирования.
Сбор логов из тестовой среды
Первое, что я реализовал в своем пайплайне - автоматический сбор логов со всех компонентов системы. Когда тест падает, я хочу иметь все логи под рукой, не переключаясь между разными системами. Для этого я добавил в пайплайн следующие шаги:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| name: Collect application logs
if: always()
run: |
mkdir -p logs
kubectl logs -l app=vcluster-pipeline --namespace ${{ env.NAMESPACE }} > logs/app.log || true
kubectl logs -l app.kubernetes.io/name=postgresql --namespace ${{ env.NAMESPACE }} > logs/db.log || true
name: Collect pod events
if: always()
run: |
kubectl get events --namespace ${{ env.NAMESPACE }} > logs/events.log || true
name: Upload logs as artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: kubernetes-logs
path: logs/
retention-days: 5 |
|
Директива if: always() гарантирует, что логи собираются даже в случае сбоя предыдущих шагов, что критически важно для отладки. Особенно полезным оказалось сохранение событий Kubernetes - часто именно там скрывается причина проблемы, например, нехватка ресурсов или ошибки при получении образа. Я раньше пробовал реализовать системы централизованного логирования (ELK, Grafana Loki) для этих целей, но обнаружил, что для контекста PR-тестирования проще собирать логи напрямую и сохранять их как артефакты GitHub Actions.
Внедрение метрик производительности
Кроме логов, очень полезно собирать метрики производительности приложения. Это помогает обнаруживать регрессии производительности еще на этапе PR. Я внедрил два уровня мониторинга:
1. Базовые метрики Kubernetes - использование CPU, памяти, сети:
| YAML | 1
2
3
4
5
| name: Collect resource metrics
if: always()
run: |
kubectl top pods --namespace ${{ env.NAMESPACE }} > logs/resource_usage.log
kubectl get pods -o wide --namespace ${{ env.NAMESPACE }} > logs/pods.log |
|
2. Бизнес-метрики приложения - среднее время ответа, количество запросов в секунду, процент ошибок. Для этого я использую тесты нагрузки с помощью k6:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| name: Run performance test
run: |
cat > performance.js <<EOF
import http from 'k6/http';
import { check, sleep } from 'k6';
export default function() {
const res = http.get('${{ env.APP_BASE_URL }}/api/v1/items');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 200ms': (r) => r.timings.duration < 200
});
sleep(1);
}
EOF
docker run --rm -v $(pwd):/scripts loadimpact/k6 run \
--summary-export=logs/performance.json \
/scripts/performance.js |
|
Хитрость в том, что результаты этих тестов я не только сохраняю как артефакты, но и сравниваю с базовыми показателями:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
| name: Compare performance with baseline
run: |
PR_P95=$(cat logs/performance.json | jq '.metrics.http_req_duration.values."p(95)"')
BASELINE_P95=$(cat baseline_metrics.json | jq '.metrics.http_req_duration.values."p(95)"')
if (( $(echo "$PR_P95 > $BASELINE_P95 * 1.2" | bc -l) )); then
echo "Performance degradation detected! P95 response time increased by more than 20%."
echo "PR: $PR_P95 ms, Baseline: $BASELINE_P95 ms"
echo "::warning::Performance degradation detected"
else
echo "Performance is within acceptable range"
fi |
|
Эта простая проверка спасла меня от множества регрессий производительности. Однажды разработчик случайно добавил N+1 запрос, который на маленьком наборе тестовых данных работал нормально, но сильно тормозил в продакшене - и именно сравнение метрик поймало эту проблему.
Интеграция результатов тестирования с PR
Результаты тестирования не должны оседать в логах CI/CD системы - они должны быть видны прямо в PR. Я использую GitHub Checks API для отображения детальной информации о результатах тестов:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| name: Report test results to PR
if: always()
uses: actions/github-script@v6
with:
script: |
const testResults = require('./test-results.json');
const summary = {
passed: testResults.filter(t => t.status === 'passed').length,
failed: testResults.filter(t => t.status === 'failed').length,
skipped: testResults.filter(t => t.status === 'skipped').length
};
const details = testResults
.filter(t => t.status === 'failed')
.map(t => `- ${t.name}: ${t.message}`)
.join('\n');
const conclusion = summary.failed > 0 ? 'failure' : 'success';
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Integration Tests',
head_sha: context.payload.pull_request.head.sha,
status: 'completed',
conclusion: conclusion,
output: {
title: `Tests: ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`,
summary: [INLINE]### Test Results\n\n${details}[/INLINE],
text: JSON.stringify(testResults, null, 2)
}
}); |
|
Кроме того, я добавляю интерактивный отчет о покрытии кода тестами прямо в PR:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| name: Generate code coverage report
run: |
./mvnw jacoco:report
name: Comment PR with coverage report
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const coverageData = JSON.parse(fs.readFileSync('./target/site/jacoco/jacoco.json', 'utf8'));
const coverage = {
instructions: coverageData.counters.find(c => c.type === 'INSTRUCTION').covered / coverageData.counters.find(c => c.type === 'INSTRUCTION').total * 100,
branches: coverageData.counters.find(c => c.type === 'BRANCH').covered / coverageData.counters.find(c => c.type === 'BRANCH').total * 100,
lines: coverageData.counters.find(c => c.type === 'LINE').covered / coverageData.counters.find(c => c.type === 'LINE').total * 100
};
const comment = `## Code Coverage Report
| Type | Coverage |
|------|----------|
| Instructions | ${coverage.instructions.toFixed(2)}% |
| Branches | ${coverage.branches.toFixed(2)}% |
| Lines | ${coverage.lines.toFixed(2)}% |
[View detailed report](${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
}); |
|
Такой подход делает процес ревью кода гораздо более эффективным - разработчики сразу видят проблемы и результаты тестов, не переключаясь между разными интерфейсами.
Профилирование приложений во время тестирования
Стандартные метрики не всегда помогают выявить узкие места в производительности. Для более глубокого анализа я внедрил профилирование JVM-приложений прямо в процесс тестирования PR. Для этого я использую Async Profiler и JFR:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| name: Run profiling
if: contains(github.event.pull_request.labels.*.name, 'profile')
run: |
# Получаем PID Java-процесса
POD_NAME=$(kubectl get pods -l app=vcluster-pipeline -o jsonpath='{.items[0].metadata.name}' --namespace ${{ env.NAMESPACE }})
JAVA_PID=$(kubectl exec $POD_NAME --namespace ${{ env.NAMESPACE }} -- jps | grep -v Jps | cut -d ' ' -f 1)
# Запускаем профилирование на 30 секунд
kubectl exec $POD_NAME --namespace ${{ env.NAMESPACE }} -- \
/opt/async-profiler/profiler.sh -d 30 -f /tmp/profile.html $JAVA_PID
# Копируем результаты профилирования
kubectl cp ${{ env.NAMESPACE }}/$POD_NAME:/tmp/profile.html ./logs/profile.html
name: Upload profile as artifact
if: contains(github.event.pull_request.labels.*.name, 'profile')
uses: actions/upload-artifact@v3
with:
name: performance-profile
path: logs/profile.html
retention-days: 5 |
|
Это решение я использую выборочно, только для PR, помеченных меткой "profile", так как профилирование создает дополнительную нагрузку на систему. Один раз этот подход помог обнаружить неоптимальное использование памяти в коллекциях, которое вызывало частые сборки мусора. Проблема не проявлялась в интеграционных тестах, но приводила к значительной деградации производительности в продакшене.
Автоматизация уведомлений для команды
Помимо отображения результатов в PR, часто требуется активное оповещение разработчиков о статусе тестирования. Я внедрил систему уведомлений через Slack, которая отправляет сообщения в зависимости от результатов тестов:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| name: Send Slack notification
if: always()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": "Integration Test Results for PR #${{ github.event.pull_request.number }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}*\n${{ job.status == 'success' && 'Tests passed' || 'Tests failed' }}"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "View PR: ${{ github.event.pull_request.html_url }}\nView workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK |
|
Оптимизация производительности и затрат
Когда я начал использовать Kubernetes для тестирования PR, я быстро понял, что без оптимизации этот процесс может стать невероятно затратным как по времени, так и по деньгам. Каждый PR запускает несколько подов, использует вычислительные ресурсы и хранит данные – все это стоит денег. При активной разработке счета за облачные ресурсы могут расти как на дрожжах. Вот несколько стратегий, которые я применил для оптимизации.
Кэширование Docker образов и зависимостей
Одно из первых узких мест, которое я обнаружил – это время сборки Docker образов. При каждом новом PR приходилось заново скачивать все зависимости и собирать образ с нуля, что отнимало кучу времени. Я внедрил несколько оптимизаций:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
| name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
tags: ${{ steps.meta.outputs.tags }}
push: true
cache-from: type=gha # Использование кэша GitHub Actions
cache-to: type=gha,mode=max |
|
Эта настройка позволяет кэшировать слои Docker между запусками, что значительно ускоряет сборку. На моем проекте время сборки сократилось с 5-6 минут до 1-2 минут. Для Maven/Gradle проектов я также добавил кэширование зависимостей:
| YAML | 1
2
3
4
5
6
| name: Cache Maven packages
uses: actions/cache@v3
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2 |
|
Оптимизация размера образов
Следующий шаг – оптимизация размера образов. Меньший образ быстрее загружается в кластер и экономит место в реестре. Я начал использовать многоэтапные сборки и убрал все ненужное:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
| # Стадия сборки
FROM maven:3.8.4-openjdk-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
# Финальный образ
FROM openjdk:17-slim
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"] |
|
Размер образа уменьшился с 800Мб до 150Мб, что существенно ускорило его развертывание в кластере.
Стратегия предварительного прогрева кластера
Еще одна хитрость, которую я использую – "прогрев" кластера. Суть в том, чтобы заранее подготовить кластер к запуску тестов, вместо настройки всего "на лету". Я создал специальный джоб, который запускается по расписанию и делает следующее:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| name: Warm up cluster
run: |
# Предварительно скачиваем популярные образы
kubectl create job warm-up-job --image=busybox -- echo "Warming up" || true
kubectl create job postgres-preload --image=postgres:15 -- echo "Preloading postgres" || true
# Создаем пулы подов для часто используемых компонентов
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-pool
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: postgres-pool
template:
metadata:
labels:
app: postgres-pool
spec:
containers:
- name: postgres
image: postgres:15
resources:
requests:
cpu: 100m
memory: 200Mi
command: ["sleep", "infinity"]
EOF |
|
Этот подход обеспечивает доступность образов на узлах кластера заранее, что ускоряет запуск подов во время тестирования PR.
Автоматическая очистка ресурсов
Забывать удалять ресурсы – верный способ получить неприятный счет в конце месяца. Я настроил автоматическую очистку по нескольким сценариям:
1. Очистка на основе TTL (Time To Live):
| YAML | 1
2
3
| name: Set TTL for resources
run: |
kubectl annotate namespace ${{ env.NAMESPACE }} janitor/ttl=24h |
|
2. Очистка на основе статуса PR – когда PR закрывается или мержится:
| YAML | 1
2
3
4
5
6
7
8
9
10
| name: Cleanup PR Environment
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete namespace
run: kubectl delete namespace pr-${{ github.event.pull_request.number }} --ignore-not-found |
|
3. Регулярная проверка и удаление "осиротевших" ресурсов:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
| name: Cleanup orphaned resources
run: |
# Находим PR, которые уже закрыты, но их среды остались
for ns in $(kubectl get ns -l created-by=pr-test --output=jsonpath={.items[*].metadata.name}); do
PR_NUMBER=$(echo $ns | sed 's/pr-//')
# Проверяем, существует ли еще PR
if ! gh pr view $PR_NUMBER &> /dev/null; then
kubectl delete ns $ns
fi
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
Благодаря этим оптимизациям мне удалось сократить затраты на GKE примерно на 40% без снижения качества тестирования. Самый главный урок, который я извлек – оптимизируйте не только производительность, но и затраты с самого начала, иначе в конце месяца вас может ждать неприятный сюрприз.
Интеграция с системами Service Mesh для комплексного тестирования
В процессе настройки тестирования PR на Kubernetes я столкнулся с проблемой, которая заставила меня искать более продвинутое решение. Мне требовалось протестировать не только работу отдельных сервисов, но и взаимодействие между ними, включая маршрутизацию, отказоустойчивость и политики безопасности. Внедрение Service Mesh стало ключевым шагом в этом направлении.
Service Mesh — это выделенный слой инфраструктуры, который контролирует взаимодействие между сервисами. Для тестирования PR я выбрал Istio, хотя Linkerd тоже был неплохим вариантом из-за своей легковесности.
Вот как я интегрировал Istio в процесс тестирования:
| YAML | 1
2
3
4
5
| name: Install Istio
run: |
curl -L [url]https://istio.io/downloadIstio[/url] | ISTIO_VERSION=1.18.2 sh -
./istio-1.18.2/bin/istioctl install --set profile=demo -y
kubectl label namespace ${{ env.NAMESPACE }} istio-injection=enabled |
|
Ключевой момент здесь — метка istio-injection=enabled, которая автоматически внедряет прокси-сайдкары Envoy во все поды в пространстве имен PR.
После этого я настроил виртуальные сервисы для тестирования различных сценариев маршрутизации:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| name: Configure traffic routing
run: |
cat <<EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: my-app-vs
namespace: ${{ env.NAMESPACE }}
spec:
hosts:
- "myapp.example.com"
gateways:
- my-gateway
http:
- match:
- uri:
prefix: /api/v1
route:
- destination:
host: vcluster-pipeline
port:
number: 8080
EOF |
|
Это позволило мне тестировать сложные сценарии, такие как канареечные деплои или деплои с голубым/зеленым переключением прямо в контексте PR. Также я смог настроить политики ретраев, таймауты и цепочки вызовов между сервисами.
Одно из лучших применений Service Mesh в PR-тестировании — это симуляция сбоев:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| name: Setup fault injection
run: |
cat <<EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: fault-injection
namespace: ${{ env.NAMESPACE }}
spec:
hosts:
- postgresql
http:
- fault:
delay:
percentage:
value: 50
fixedDelay: 5s
route:
- destination:
host: postgresql
EOF |
|
Этот манифест имитирует задержки в 50% запросов к базе данных, что помогает проверить устойчивость приложения к сбоям инфраструктуры.
Интеграция с Service Mesh открыла для меня новый уровень тестирования, позволяя моделировать реалистичные условия эксплуатации и выявлять проблемы, которые иначе проявились бы только в продакшене. Однако стоит учитывать, что добавление Service Mesh увеличивает потребление ресурсов кластера, особенно при большом колличестве одновременных PR.
Заключение
Проделав весь этот путь настройки тестирования Pull Request на Kubernetes, я пришол к нескольким важным выводам. Во-первых, такой подход действительно окупается — обнаружение проблем до слияния PR экономит уйму времени и нервов всей команде. Во-вторых, хотя первоначальная настройка инфраструктуры требует усилий, дальнейшее поддержание и развитие системы становится все проще.
Конечно, решение не лишено компромисов. Нам приходится балансировать между степенью изоляции тестовых сред и затратами на инфраструктуру. Мы должны решать, насколько близко к продакшену должно быть тестовое окружение, и сколько мы готовы за это платить.
Если вы только начинаете внедрять тестирование PR в Kubernetes, я рекомендую идти поэтапно. Сначала настройте базовую инфраструктуру и простые тесты, затем добавляйте мониторинг, оптимизацию и продвинутые инструменты вроде Service Mesh по мере необходимости.
Github хочет pull request, который не нужен В общем цвел и пах один проектик в локальном репо с несколькими ветками. Тут приспичило его... Тесты кода на GitHub Actions Как написать и запустить в github тесты?
CodeStyle тест для html/css/js/nodejs.
... Проблема в Docker файле, github actions Господа, подскажите пожалуйста - почему у меня проект в github actions собирается нормально, но... для чего в bitbucket существует Create pull request ? Всем привет,
Обьясните пожалуйста для чего в bitbucket существует Create pull request и как им... Pull Request в иные ветки Здравствуйте.
Не разобрался с Pull Request в иные ветки репозиториев.
Есть репозиторий:... Как обновлять программы c Github(git pull)? Собираюсь установить у себя медицинские экспертные системы. Если авторы что то поправят в... Запуск docker образа в kubernetes Контейнер в docker запускаю так:
docker run --cap-add=SYS_ADMIN -ti -e "container=docker" -v... Деплой телеграм бота на Google Kubernetes Engine через GitLab CI Доброго времни суток. Прошу помощи у форумчан тк. сам не могу разобраться.
Как задеплоить бота на... Возможно ли поднять в kubernetes proxy Задача.
Дано: На роутере настроены 10 ip-адресов внешних от провайдера. На сервере vmware поднято... Nginx + Kubernetes Добрый день всем!
Я решил попробовать использовать Kubernetes.
Вот что я сделал на текущий... Конфигурация ngnix для Kubernetes Deployment Подскажите, что не так с nginx.conf переданным в ConfigMap для k8s? У меня на порту сервиса сайт не... Где расположить БД для Kubernetes кластера в облаке Привет. Нагуглил и разобрал пример, как разместить Spring-овый микросервис в кубернетес-кластере....
|