Learning Platform
Глоссарий Troubleshooting
Урок 19.04 · 23 мин
Средний
dockercomposeciintegration-testsgithub-actions

Compose в CI: интеграционные тесты

В уроке 17-01 мы запускали отдельные testcontainer’ы из pytest. Это идеально для unit/integration тестов одного компонента (тестим ETL-функцию, ей нужна Postgres). Но иногда нужно протестировать связку сервисов — Airflow дёргает Spark, который пишет в ClickHouse, который подписан на Kafka. Это end-to-end тест стека, и для него удобнее compose-файл.

В этом уроке: как запустить compose в CI, дождаться готовности всех сервисов через --wait, прогнать тесты, и корректно teardown.


docker compose up —wait

Главная фича Compose v2 для CI: --wait. Она блокирует команду, пока все сервисы не станут healthy (или один не упадёт).

docker compose up -d --wait

Без --wait:

docker compose up -d
# Команда вернулась мгновенно. Контейнеры стартуют, но Postgres может ещё не быть готов.
# Тесты запускаются и падают с "connection refused".

С --wait:

docker compose up -d --wait
# Команда блокируется до:
#  - все healthchecks вернули healthy, ИЛИ
#  - один из сервисов завершился с не-нулевым exit code.
# Только после этого можно запускать тесты.

Это обязательно для CI. Без --wait тесты гоняют race condition: иногда зелёные, иногда красные — зависит от того, насколько быстро стартовал Postgres на конкретном runner’е.

Поток CI с docker compose --wait
1. checkoutGitHub Actions runner стартует, checkout кода, setup-docker.
up --wait
2. compose updocker compose up -d --wait. Контейнеры стартуют. CI ждёт, пока healthchecks вернут healthy. ТАЙМ-АУТ если не дождался.
3. run testsВсе сервисы healthy. Запускаем pytest или curl-проверки.
результаты
4. teardowndocker compose down -v. Удаляются контейнеры и volumes. Runner чистый для следующих jobs.

Healthcheck’и — это критично

--wait работает только если у сервисов есть healthcheck. Без healthcheck Compose считает сервис готовым, как только процесс стартовал. Это никогда не значит, что сервис реально принимает запросы.

# compose.test.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "test"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 5s

  kafka:
    image: bitnami/kafka:3.8
    # ... настройки KRaft
    healthcheck:
      test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"]
      interval: 10s
      retries: 10
      start_period: 20s

  api:
    build: .
    depends_on:
      postgres:
        condition: service_healthy
      kafka:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:8000/health"]
      interval: 5s
      retries: 5
      start_period: 10s

Ключевые параметры:

  • interval — как часто проверять. Слишком часто = шум, слишком редко = долго ждать. 5-10 секунд для большинства.
  • timeout — таймаут одной проверки. Меньше interval, обычно 3-5 секунд.
  • retries — сколько раз подряд должна упасть проверка, прежде чем сервис считается unhealthy.
  • start_period — grace period после старта. Healthcheck в это время не считается failure. Полезно для тяжёлых сервисов (Kafka стартует 20-30 секунд).
WARNING

ВНИМАНИЕ: depends_on с condition: service_healthy работает только в Compose v2. Если CI использует docker-compose (legacy v1, через pip install), эти условия игнорируются. Всегда используй docker compose (без дефиса) в современном CI.


GitHub Actions workflow

# .github/workflows/integration.yml
name: integration-tests

on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start stack
        run: docker compose -f compose.test.yml up -d --wait
        timeout-minutes: 5   # если за 5 минут не поднялось -- fail

      - name: Show running services
        run: docker compose -f compose.test.yml ps

      - name: Run smoke tests
        run: |
          curl --fail http://localhost:8000/health
          curl --fail http://localhost:8000/api/users

      - name: Run pytest
        run: |
          pip install -r requirements-test.txt
          pytest tests/integration/ -v

      - name: Capture logs on failure
        if: failure()
        run: docker compose -f compose.test.yml logs > compose-logs.txt

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: compose-logs
          path: compose-logs.txt

      - name: Teardown
        if: always()
        run: docker compose -f compose.test.yml down -v

Несколько важных деталей:

  • timeout-minutes: 5 — если --wait не вернулся за 5 минут, GHA убивает step. Иначе застрянет на час.
  • Capture logs on failure — при падении тестов экспортируем логи всех контейнеров в файл. Без этого ты не поймёшь, что упало.
  • upload-artifact — логи доступны в Actions UI для скачивания и анализа.
  • if: always() на teardown — выполняется и при успехе, и при failure. Без always() runner может застрять с running контейнерами.

Что проверять: примеры

Smoke tests через curl

# Health endpoint
curl --fail http://localhost:8000/health

# Конкретный API endpoint
RESPONSE=$(curl -s http://localhost:8000/api/events?limit=10)
echo "$RESPONSE" | jq -e '.events | length > 0'

# Insert + read цикл
curl -X POST http://localhost:8000/api/events \
  -H "Content-Type: application/json" \
  -d '{"user_id": 1, "type": "test"}' --fail

sleep 2  # дать pipeline время обработать

curl -s http://localhost:8000/api/events | jq -e '.events[-1].type == "test"'

--fail важен: без него curl возвращает 0 при HTTP 500, и CI считает успех. С --fail curl exit’ит с не-нулевым кодом при >=400.

docker compose exec для проверки БД

# Прочитать состояние Postgres
docker compose exec -T postgres psql -U test -d test -c "SELECT count(*) FROM events"

# Прочитать topic в Kafka
docker compose exec -T kafka kafka-topics.sh \
  --bootstrap-server localhost:9092 --list

# Прочитать ClickHouse
docker compose exec -T clickhouse clickhouse-client \
  --query "SELECT count() FROM analytics.events"

-T отключает TTY — обязательно в CI (нет терминала).

pytest с runtime-сервисами

Если тесты пишутся на Python, лучше использовать те же compose-сервисы:

# tests/integration/test_pipeline.py
import os
import requests
import psycopg2

API_URL = os.environ.get("API_URL", "http://localhost:8000")
DB_URL = os.environ.get("DB_URL", "postgresql://test:test@localhost:5432/test")

def test_end_to_end():
    # POST event
    r = requests.post(f"{API_URL}/api/events", json={"user_id": 1, "type": "test"})
    assert r.status_code == 201

    # Дать pipeline обработать
    import time
    time.sleep(2)

    # Проверить в БД
    conn = psycopg2.connect(DB_URL)
    with conn.cursor() as cur:
        cur.execute("SELECT count(*) FROM events WHERE type = 'test'")
        assert cur.fetchone()[0] >= 1
    conn.close()

Типичные race conditions

Жизненный цикл процесса: от fork до ready

Race 1: healthcheck тривиальный

postgres:
  healthcheck:
    test: ["CMD", "echo", "ok"]   # БЕСПОЛЕЗНО

echo ok всегда возвращает 0. Healthcheck “проходит” мгновенно, но Postgres ещё не готов. Используй pg_isready -U user — он реально проверяет, что postmaster принимает connections.

Race 2: depends_on без condition

api:
  depends_on:
    - postgres   # БЕСПОЛЕЗНО без condition

depends_on: [postgres] (короткая форма) гарантирует только порядок старта, не готовность. API контейнер стартует, как только postgres-контейнер стартовал (даже за 0.1 секунды). Postgres ещё пишет конфиги — API уже пытается подключиться.

api:
  depends_on:
    postgres:
      condition: service_healthy   # ДА

Race 3: тесты в том же compose

Некоторые любят запускать тесты внутри compose:

test-runner:
  build: .
  command: pytest
  depends_on:
    api:
      condition: service_healthy

Это работает, но усложняет CI: нужно docker compose up --abort-on-container-exit --exit-code-from test-runner. Проще запускать тесты на хосте и общаться с compose-сервисами через localhost:port.

Race 4: streaming-задержки

def test_kafka_to_postgres():
    requests.post(API_URL + "/events", json={...})
    # БЕСПОЛЕЗНО:
    conn = psycopg2.connect(DB_URL)
    cur.execute("SELECT count() FROM events")
    assert cur.fetchone()[0] == 1   # 50% времени fail

Между producer и consumer есть delay (обычно сотни ms — секунды). Используй retry-логику:

import time

def wait_for(condition, timeout=10, interval=0.5):
    deadline = time.time() + timeout
    while time.time() < deadline:
        if condition():
            return True
        time.sleep(interval)
    raise TimeoutError("Condition not met")

wait_for(lambda: db_count() >= 1, timeout=10)

Пример полного workflow

name: integration

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - name: Build test image
        run: docker compose -f compose.test.yml build

  integration:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Start compose stack
        run: docker compose -f compose.test.yml up -d --wait
        timeout-minutes: 5

      - name: Print stack status
        run: |
          docker compose -f compose.test.yml ps
          docker compose -f compose.test.yml exec -T postgres pg_isready

      - name: Install test deps
        run: pip install pytest requests psycopg2-binary confluent-kafka

      - name: Run integration tests
        env:
          API_URL: http://localhost:8000
          DB_URL: postgresql://test:test@localhost:5432/test
          KAFKA_BOOTSTRAP: localhost:29092
        run: pytest tests/integration/ -v --tb=short

      - name: Dump logs on failure
        if: failure()
        run: |
          mkdir -p logs
          docker compose -f compose.test.yml logs --no-color > logs/all.log
          for svc in $(docker compose -f compose.test.yml ps --services); do
            docker compose -f compose.test.yml logs --no-color $svc > logs/$svc.log
          done

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: compose-logs
          path: logs/

      - name: Teardown
        if: always()
        run: docker compose -f compose.test.yml down -v --remove-orphans

Этот workflow:

  1. Собирает образы (отдельный job — параллельно с другими CI-проверками).
  2. Поднимает стек, ждёт --wait.
  3. Гоняет pytest с переменными окружения.
  4. Падает дроссельно — логи всегда сохраняются.
  5. Teardown гарантирован через if: always().

Попробуй сам

# 1. Создай compose.test.yml:
cat > compose.test.yml <<'EOF'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "test"]
      interval: 3s
      retries: 10
EOF

# 2. Запусти с --wait и измерь время
time docker compose -f compose.test.yml up -d --wait
# real    0m6.234s  -- Postgres готов за ~6 секунд

# 3. Проверь
docker compose -f compose.test.yml exec -T postgres \
  psql -U test -d test -c "SELECT 1"

# 4. Запусти "тест"
psql postgresql://test:test@localhost:5432/test -c "SELECT version()"

# 5. Симулируй CI failure: останови postgres
docker compose -f compose.test.yml stop postgres
psql postgresql://test:test@localhost:5432/test -c "SELECT 1"
# psql: connection refused -- здесь упадёт твой тест

# 6. Logs
docker compose -f compose.test.yml logs postgres | tail -20

# 7. Teardown
docker compose -f compose.test.yml down -v

Проверка знанийKnowledge check
В CI ты запускаешь docker compose up -d --wait, и команда висит 5 минут, потом упадает по timeout. В docker compose ps статус контейнера postgres -- "starting" (healthcheck не прошёл). pg_isready внутри контейнера руками работает. Что не так?
ОтветAnswer
Скорее всего проблема в самом healthcheck CMD-конфиге. Варианты: (1) test: ["CMD", "pg_isready"] -- без -U user. Postgres по умолчанию проверяет с переменной USER, которая в контейнере пустая (или wrong user). Нужно ["CMD", "pg_isready", "-U", "test"]. (2) start_period слишком короткий -- Postgres на cold-start делает initdb (3-5 секунд), healthcheck не успевает дождаться. Добавь start_period: 10s. (3) interval 30s + retries 3 = 1.5 минуты ожидания. (4) В test нужен CMD-SHELL вместо CMD для использования pipes/glob. Дебаг: docker inspect container | grep Health -- увидишь Status и LastOutput с реальной ошибкой healthcheck.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что делает флаг --wait в docker compose up --wait?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4