Recentemente, publiquei um artigo sobre o uso do Testcontainers para emular dependências externas como um banco de dados e cache para testes de integração de backend. Esse artigo também explicou as diferentes formas de executar os testes de integração, a estrutura do ambiente e seus prós e contras.

Neste artigo, quero mostrar outra alternativa caso você use o GitHub Actions como sua plataforma de CI (a solução de CI/CD mais popular no momento). Essa alternativa é chamada Service Containers, e percebi que muitos desenvolvedores parecem não conhecer.

Neste tutorial prático, vou demonstrar como criar um fluxo de trabalho do GitHub Actions para testes de integração com dependências externas (MongoDB e Redis) usando a aplicação demo em Go que criamos naquele tutorial anterior. Também iremos revisar os prós e contras dos GitHub Service Containers.

Pré-requisitos

  • Um entendimento básico dos fluxos de trabalho do GitHub Actions.

  • Familiaridade com contêineres Docker.

  • Conhecimento básico da ferramenta Go.

Sumário

O que são Contêineres de Serviço?

Contêineres de Serviço são contêineres Docker que oferecem uma maneira simples e portátil de hospedar dependências como bancos de dados (MongoDB em nosso exemplo), serviços da web, ou sistemas de cache (Redis em nosso exemplo) que sua aplicação precisa dentro de um fluxo de trabalho.

Este artigo foca em testes de integração, mas há muitas outras aplicações possíveis para contêineres de serviço. Por exemplo, você também pode usá-los para executar ferramentas de suporte necessárias para o seu fluxo de trabalho, como ferramentas de análise de código, linters, ou scanners de segurança.

Por que não Docker Compose?

Parece semelhante aos serviços no Docker Compose, certo? Bem, é porque é.

Mas, embora tecnicamente você possa usar o Docker Compose dentro de um fluxo de trabalho do GitHub Actions instalando o Docker Compose e executando docker-compose up, os contêineres de serviço oferecem uma abordagem mais integrada e simplificada, especificamente projetada para o ambiente do GitHub Actions.

Também, embora sejam semelhantes, eles resolvem problemas diferentes e têm propósitos gerais diferentes:

  • O Docker Compose é bom quando você precisa gerenciar uma aplicação multi-contêiner em sua máquina local ou em um único servidor. É mais adequado para ambientes de longa duração.

  • Os Contêineres de Serviço são efêmeros e existem apenas durante a execução de um fluxo de trabalho, sendo definidos diretamente em seu arquivo de fluxo de trabalho do GitHub Actions.

Apenas tenha em mente que o conjunto de recursos dos contêineres de serviço (pelo menos no momento atual) é mais limitado em comparação com o Docker Compose, então esteja preparado para encontrar possíveis gargalos. Iremos abordar alguns deles no final deste artigo.

Tempo de Execução do Trabalho

Você pode executar trabalhos do GitHub diretamente em uma máquina runner ou em um contêiner Docker (especificando a propriedade container). A segunda opção simplifica o acesso aos seus serviços usando rótulos que você define na seção de serviços.

Para executar diretamente em uma máquina runner:

.github/workflows/test.yaml

jobs:
  integration-tests:
    runs-on: ubuntu-24.04

    services:
      mongo:
        image: mongodb/mongodb-community-server:7.0-ubi8
        ports:
          - 27017:27017

    steps:
      - run: |
          echo "addr 127.0.0.1:27017"

Ou você pode executá-lo em um contêiner (Chainguard Go Image no nosso caso):

jobs:
  integration-tests:
    runs-on: ubuntu-24.04
    container: cgr.dev/chainguard/go:latest

    services:
      mongo:
        image: mongodb/mongodb-community-server:7.0-ubi8
        ports:
          - 27017:27017
    steps:
      - run: |
          echo "addr mongo:27017"

Você também pode omitir a porta do host, para que a porta do contêiner seja atribuída aleatoriamente a uma porta livre no host. Você pode então acessar a porta usando a variável.

Vantagens de omitir a porta do host:

  • Avoids conflitos de porta – por exemplo, quando você executa muitos serviços no mesmo host.

  • Realça a Portabilidade – suas configurações se tornam menos dependentes do ambiente host específico.

jobs:
  integration-tests:
    runs-on: ubuntu-24.04
    container: cgr.dev/chainguard/go:1.23

    services:
      mongo:
        image: mongodb/mongodb-community-server:7.0-ubi8
        ports:
          - 27017/tcp
    steps:
      - run: |
          echo "addr mongo:${{ job.services.mongo.ports['27017'] }}"

Claro, existem prós e contras para cada abordagem.

Executando em um contêiner:

  • Prós: Acesso à rede simplificado (use rótulos como nomes de host) e exposição automática da porta dentro da rede do contêiner. Você também obtém melhor isolamento/segurança, pois o trabalho é executado em um ambiente isolado.

  • Contras: Sobrecarga implícita da conteinerização.

Executando na máquina runner:

  • Prós: Potencialmente menos sobrecarga do que executar a tarefa dentro de um contêiner.

  • Contras: Requer mapeamento manual de portas para acesso ao contêiner de serviço (usando localhost:). Há também menos isolamento/segurança, já que a tarefa é executada diretamente na máquina runner. Isso pode afetar outras tarefas ou o próprio runner se algo der errado.

Verificação de Prontidão

Antes de executar os testes de integração que se conectam aos seus contêineres provisionados, você geralmente precisará garantir que os serviços estejam prontos. Você pode fazer isso especificando opções de criação do docker como health-cmd.

Isso é muito importante – caso contrário, os serviços podem não estar prontos quando você começar a acessá-los.

No caso do MongoDB e Redis, estes serão os seguintes:

    services:
      mongo:
        image: mongodb/mongodb-community-server:7.0-ubi8
        ports:
          - 27017/27017
        options: >-
          --health-cmd "echo 'db.runCommand("ping").ok' | mongosh mongodb://localhost:27017/test --quiet"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10

      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10

Nos logs de ação, você pode ver o status de prontidão:

Registros de Contêiner Privados

No nosso exemplo, estamos usando imagens públicas do Dockerhub, mas também é possível usar imagens privadas dos seus registros privados, como Amazon Elastic Container Registry (ECR), Google Artifact Registry, e assim por diante.

Certifique-se de armazenar as credenciais em Secrets e então referenciá-las na seção de credenciais.

services:
  private_service:
    image: ghcr.io/org/service_repo
    credentials:
      username: ${{ secrets.registry_username }}
      password: ${{ secrets.registry_token }}

Compartilhando Dados Entre Serviços

Você pode usar volumes para compartilhar dados entre serviços ou outras etapas em um trabalho. Você pode especificar volumes nomeados do Docker, volumes anônimos do Docker, ou bind mounts no host. Mas não é diretamente possível montar o código fonte como um volume de contêiner. Você pode consultar esta discussão aberta para mais contexto.

Para especificar um volume, você especifica o caminho de origem e destino: <origem>:<caminhoDestino>

A <origem> é um nome de volume ou um caminho absoluto na máquina host, e <caminhoDestino> é um caminho absoluto no contêiner.

volumes:
  - /src/dir:/dst/dir

Volumes no Docker (e GitHub Actions usando Docker) fornecem armazenamento de dados persistente e compartilhamento entre contêineres ou etapas de trabalho, desacoplando os dados das imagens dos contêineres.

Configuração do Projeto

Antes de mergulhar no código-fonte completo, vamos configurar nosso projeto para executar testes de integração com Contêineres de Serviço do GitHub.

  1. Crie um novo repositório no GitHub.

  2. Inicialize um módulo Go usando go mod init

  3. Crie uma aplicação Go simples.

  4. Adicione testes de integração em integration_test.go

  5. Crie um diretório .github/workflows.

  6. Crie um arquivo chamado integration-tests.yaml dentro do diretório .github/workflows.

Testes de Integração Golang

Agora que podemos provisionar nossas dependências externas, vamos dar uma olhada em como executar nossos testes de integração em Go. Faremos isso na seção etapas do nosso arquivo de fluxo de trabalho.

Faremos nossos testes em um contêiner que usa imagem do Chainguard Go. Isso significa que não precisamos instalar/configurar o Go. Se você quiser executar seus testes diretamente em uma máquina runner, precisará usar a ação setup-go.

Você pode encontrar o código-fonte completo com testes e este fluxo de trabalho aqui.

.github/workflows/integration-tests.yaml

name: "integration-tests"

on:
  workflow_dispatch:
  push:
    branches:
      - main

jobs:
  integration-tests:
    runs-on: ubuntu-24.04
    container: cgr.dev/chainguard/go:latest

    env:
      MONGO_URI: mongodb://mongo:27017
      REDIS_URI: redis://redis:6379

    services:
      mongo:
        image: mongodb/mongodb-community-server:7.0-ubi8
        ports:
          - 27017:27017
        options: >-
          --health-cmd "echo 'db.runCommand("ping").ok' | mongosh mongodb://localhost:27017/test --quiet"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10

      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10

    steps:
      - name: Check out repository code
        uses: actions/checkout@v4

      - name: Download dependencies
        run: go mod download

      - name: Run Integration Tests
        run: go test -tags=integration -timeout=120s -v ./...

Para resumir o que está acontecendo aqui:

  1. Executamos nosso trabalho em um contêiner com Go (contêiner)

  2. Levantamos dois serviços: MongoDB e Redis (serviços)

  3. Configuramos verificações de integridade para garantir que nossos serviços estejam “Saudáveis” quando executamos os testes (opções)

  4. Realizamos um checkout padrão de código

  5. Então executamos os testes do Go

Uma vez que a Ação é concluída (levou ~1 min para este exemplo), todos os serviços serão parados e órfãos, então não precisamos nos preocupar com isso.

Experiência Pessoal & Limitações

Estamos usando contêineres de serviço para executar testes de integração backend na BINARLY há algum tempo, e eles funcionam muito bem. Mas a criação do fluxo de trabalho inicial levou algum tempo e encontramos os seguintes gargalos:

  • Não é possível sobrescrever ou executar comandos personalizados em um contêiner de serviço de ação (como você faria no Docker Compose usando a propriedade command). Abrir pull request

    • Solução alternativa: tivemos que encontrar uma solução que não exigisse isso. No nosso caso, tivemos sorte e conseguimos fazer o mesmo com variáveis de ambiente.
  • Não é possível montar o código-fonte como um volume de contêiner diretamente. Discussão aberta

    • Embora essa seja realmente uma grande limitação, você pode copiar o código do seu repositório para o seu diretório montado após o contêiner de serviço ter iniciado.

Conclusão

Os contêineres de serviço do GitHub são uma ótima opção para criar um ambiente de teste efêmero, configurando-o diretamente no seu fluxo de trabalho do GitHub. Com a configuração sendo um pouco semelhante ao Docker Compose, é fácil executar qualquer aplicação conteinerizada e se comunicar com ela em seu pipeline. Isso garante que os runners do GitHub cuidem de encerrar tudo após a conclusão.

Se você usar o GitHub Actions, essa abordagem funciona extremamente bem, pois é especificamente projetada para o ambiente do GitHub Actions.

Recursos