Skip to main content

Services

Vamos adicionar um Dockerfile para criar uma imagem? Não vamos entrar em detalhes sobre como montar um Dockerfile.

# Etapa 1: Build
FROM node:22-alpine AS builder

WORKDIR /app

# Copia os arquivos e instala dependências
COPY package*.json ./
RUN npm ci

# Copia o resto e roda o build
COPY . .
RUN npm run build

# Etapa 2: Runtime
FROM node:22-alpine

# Cria diretório da app
WORKDIR /app

# Copia apenas o build e os arquivos necessários pra rodar
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

# Expõe a porta usada pelo serve
EXPOSE 3000

# Comando de start
CMD ["serve", "-s", "build", "-l", "3000"]

Se fossemos rodar localmente então executaríamos.

docker build -t learn-gitlab-app .
docker run -p 3000:3000 learn-gitlab-app

INFO Accepting connections at http://localhost:3000

Como seria criar um job simples para isso?

docker-build:
stage: build
image: docker:24.0.2
services: # novo conceito
- docker:dind
script:
- docker build -t learn-gitlab-app .
when: manual # Vamos testar.

No GitLab CI, cada job roda dentro de um container isolado. Mas às vezes esse container precisa se comunicar com outro serviço (como um banco ou um daemon do Docker). É aí que entra o services.

Services X Privileged

Documentação Services

Quando você configura o GitLab Runner para usar o Docker executor, ele roda dentro de um container Docker, mas esse container pode ou não ter permissão para gerenciar outros containers dependendo da configuração que ele possui. Isso é feito através da interação com o Docker daemon (responsável pela criação e controle de containers).

[[runners]]
###...
[runners.docker]
tls_verify = false
image = "debian:bullseye-slim"
# Isso permite que o runner crie e gerencie outros containers se estiver em TRUE
privileged = false

Nesse caso privileged não esta em true e vamos observar o que acontece jájá.

Ao adicionar esse job somente com o when: manual, somente conseguimos disparar o pipeline se solicitarmos isso. Quando fazemos um push o job irá estar em skipped mas com a possibilidade de execução no botão de play.

alt text

Mas também podemos executar na aba pipelines. Esse new pipeline não é para criar uma pipeline e sim para executar. Deveriam melhorar isso! Escolha a branch ou tag e crie.

alt text

alt text

Aperte para executar e veja rodando!

No log temos exatamente o esperado, ele não consegue se comunir com o host do docker.

$ docker build -t learn-gitlab-app .
ERROR: error during connect: Get "http://docker:2375/_ping": dial tcp: lookup docker on 10.0.0.1:53: no such host
Cleaning up project directory and file based variables
00:01
ERROR: Job failed: exit code 1

Temos algumas opções para que isso funcione.

  • Ativar o privileged true e aceitar alguns riscos.
  • Usar um runner do próprio do Gitlab.
  • Usar o kaniko

Resolver o problema depende de cada cenário onde o runner esta executando. No meu cenário tenho um servidor HomeLab que roda containers dentro de um host, porém para acessar o docker.sock preciso montar no container como volume, além de privileged = true no config.toml.

  [runners.docker]
privileged = true
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]

Com isso configurado, reiniciando o runner e executando novamente o pipeline temos o job executado. Lembrando que podemos reiniciar o job sem precisar criar o pipeline.

alt text

alt text

Vamos fazer uma melhoria simples nisso? O docker build utilizar o motor antigo do docker mas utilizar o novo motor donhecido como Docker BuildKit é mais eficiente. Não vou entrar em detalhes, mas já fica a dica.

docker-build:
stage: build
image: docker:24.0.2
services:
- docker:24.0.2-dind
script:
#- docker build -t learn-gitlab-app .
- docker buildx build -t learn-gitlab-app .
when: manual

Veja a diferença de tempo. 15 segundos contra 24 em um build simples. Se fosse algo mais complexo a diferença seria ainda maior.

alt text alt text

Alternativas Melhores ao Dind

O docker:dind (Docker-in-Docker) funciona, mas não é a melhor prática na maioria dos casos — especialmente pra DevSecOps ou Platform Engineering.

Se você observou bem precisamos dar acesso privileged e não gosto muito desse cenário.

  • Roda um daemon dentro do container → isso pode ser pesado e inseguro.
  • Requer privileged mode, o que abre riscos de segurança.
  • Pode dar pau com caching e permissões dependendo do runner.

Duas opções interessantes são o Kaniko e o Buildah + Postman

Buildah e Podman

O host onde o runner está instalado precisa ter o Podman e/ou o Buildah instalados.

Pra usar Buildah ou Podman de forma tranquila e segura, o melhor tipo de executor é o shell. Não gosto dessa abordagem, mas é bom que conheça.

Kaniko (by Google)

Kaniko é uma ferramenta open-source criada pelo Google pra construir imagens de container sem precisar de Docker daemon. É voltada pra ambientes CI/CD, cloud e Kubernetes, onde rodar o docker build tradicional pode ser um problema (por causa do daemon, permissões, segurança, etc).

  • Ele lê o Dockerfile e o contexto, igual ao docker build.
  • Em vez de usar um daemon, ele executa os comandos diretamente em user space. Não precisa de daemon nem privileged mode.
  • Ele cria a imagem camada por camada e depois empacota como uma imagem OCI compatível.
  • No final, ele faz push direto pro registry (Docker Hub, GitLab Registry, etc).
  • GitLab CI, Jenkins, Tekton, Argo, etc.

Ideal pra cloud-native pipelines (e você, como futuro platform engineer, vai amar).

Build de imagens com comandos complexos pode ser mais lento.

CritérioKanikoDocker-in-Docker (dind)Docker BuildKit
Precisa de daemon?❌ Não✅ Sim✅ Sim (mas embutido no Docker)
Precisa de privileged?❌ Não (rootless)✅ Sim (inseguro em CI)⚠️ Sim (quando usa com dind)
Segurança🔒 Alta (rootless, sem daemon)🔓 Baixa (privileged + daemon)⚠️ Média (depende do setup)
Cache eficiente⚠️ Limitado, mas possível (remoto)❌ Cache inconsistente✅ Muito eficiente (inline + remoto)
VelocidadeMédiaLenta em ambientes CIRápido, especialmente com cache
Suporte em CI/CD✅ Ótimo (K8s, GitLab, Argo, etc.)✅ Mas inseguro e gambiarra✅ Nativo via Docker CLI
Complexidade de setup✅ Simples (1 container)⚠️ Simples mas exige cuidado com segurança⚠️ Simples localmente, mas mais chato no CI
Compatibilidade com Dockerfile✅ Alta✅ Alta✅ Alta
Rootless✅ Sim❌ Não⚠️ Ainda depende do daemon/root

Hoje sem dúvida o kaniko é a solução mais sólida e temos documentação no GitLab.

Build com kanico

image-build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--no-push
#--tar-path /kaniko/learn-gitlab-app.tar
when: manual

No job acima, estamos apenas realizando um build de teste. Normalmente, usamos a flag --destination para que a imagem seja enviada diretamente a um registry, o que exige credenciais que não passamos ainda. Caso não deseje fazer o push é necessário usar o --no-push e utilizar a flag --tar-path para salvar a imagem como um arquivo .tar se for guardar a imagem. Esse arquivo pode ser armazenado como artifact. Como não há um daemon Docker disponível no ambiente, comandos como docker image ls não funcionam e por isso a imagem precisa ser manipulada via .tar ou enviada a um registry.

Kaniko é uma escolha excelente: seguro, rootless, e sem precisar de daemon, porém um pouco mais demorado!

Observe que não usamos services, mas não vamos fugir do assunto!

alt text


Descomplicando o Services

Quando você define um serviço no GitLab CI, você cria um ambiente completo de dependências para o seu job. Muitas vezes queremos testar a execução do job, e para isso é necessário criar todo um ambiente que satisfaça as dependências para que ele execute. Um exemplo clássico é a comunicação com um banco de dados.

Os services funcionam no gitlab-ci como um mini docker-compose embutido no pipeline.

A diferença é que o GitLab faz tudo isso nos bastidores, baseado no que você declara no .gitlab-ci.yml.

Observe o exemplo abaixo.

test:
stage: check
image: node:22-alpine
services: # um container
- name: postgres:16
alias: db
variables: # Esta variables esta em nível de job
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
DB_HOST: db
DB_PORT: 5432
before_script: # Instalando o postgree client para fazer testes
- echo "Instalando cliente PostgreSQL..."
- apk add --no-cache postgresql-client
script:
- echo "Aguardando o PostgreSQL estar disponível..."
- until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done
- echo "Conectando ao banco e criando a tabela..."
- PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY, name TEXT);"
- echo "Inserindo dados..."
- PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "INSERT INTO users(name) VALUES ('DevSecOps');"
- echo "Consultando dados..."
- PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "SELECT * FROM users;"

Services é uma lista, mas acima só criamos um. O services executa um containers separados para os serviços (neste caso, o PostgreSQL) e linka esses containers ao job principal. Os containers criados pelo services e o do job principal compartilham a mesma rede o que permite acessar o serviço pelo alias, por exemplo db:5432 como se fosse domain:port.

  • O container do serviço (PostgreSQL) é um container irmão, não é um subprocesso do container principal.
  • O alias: db se torna um hostname interno acessível no seu job.
  • Variáveis de ambiente definidas no job não são automaticamente passadas para o serviço. Essas variáveis existem apenas dentro do container do job (o node:22-alpine, neste caso). O container do service (postgres:16) é outro container separado, e não herda essas variáveis automaticamente.

Mas por que o PostgreSQL ainda parece funcionar?

Porque a imagem oficial postgres foi feita para ler essas variáveis de ambiente no momento em que o container dela é iniciado. E aqui vem o detalhe.

O GitLab Runner detecta essas variáveis do job e, em alguns casos, injeta elas no container se esse service for uma imagem oficial reconhecida do GitLab. No caso do PostgreSQL funciona porque o GitLab repassa as variáveis pro container do service como parte da criação dele via Docker Compose-like.

Isso não é garantido pra qualquer service, nem pra qualquer executor (ex: Kubernetes executors não fazem isso do mesmo jeito).

Para alguns services oficiais como esse PostgreSQL, se as mesmas variáveis de ambiente que o service precisa ele estivem presentes no job, ele herda, mas somente as que ele precisa.

Outro detalhes é que esse service é um runner também e recebe todas as variáveis principais do gitlab-ci.

Seria a mesma coisa que fizer isso.

job:
#...
services:
- name: postgres:16
alias: db
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
DB_HOST: db
DB_PORT: 5432
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass

Particulamente prefiro ser explicito suposições, apesar de mais verboso evita suposições.

Abaixo temos alguns services que conseguem fazer esse reaproveitamento das variáveis do job.

  • MySQL e Maria DB usam as mesmas
    • MYSQL_DATABASE
    • MYSQL_ROOT_PASSWORD
    • MYSQL_USER
    • MYSQL_PASSWORD
  • PostgreSQL
    • POSTGRES_DB
    • POSTGRES_USER
    • POSTGRES_PASSWORD
    • POSTGRES_HOST_AUTH_METHOD
    • PGDATA
    • POSTGRES_INITDB_ARGS
  • RabbitMQ
    • RABBITMQ_ERLANG_COOKIE
    • RABBITMQ_DEFAULT_USER
    • RABBITMQ_DEFAULT_PASS
    • RABBITMQ_DEFAULT_VHOST
  • Elasticsearch
    • ELASTICSEARCH_URL
    • discovery.type
    • xpack.security.enabled
  • Docker-in-Docker (DinD)
    • DOCKER_TLS_CERTDIR: Define o diretório para certificados TLS
    • DOCKER_HOST: Usado para configurar a conexão com o daemon Docker

alt text

Vamos executar o job e ver o que acontece!

Mesmo que dois containers são executados temos só um job! Cada container gera um runner, mas estão linkados no mesmo job.

unning with gitlab-runner 17.11.0 (0f67ff19)
on general-debian jyvyfkmfg, system ID: r_szdZCOX2meST
Preparing the "docker" executor
00:22
Using Docker executor with image node:22-alpine ...
Starting service postgres:16... ### VEJA QUE TB ESTA INICIANDO OUTRO CONTAINER
Using locally found image version due to "if-not-present" pull policy
Using docker image sha256:2698c2096ca78a41ae7477580afb30fe36d5368564511b2ea593dbfb26401fdd for postgres:16 with digest postgres@sha256:301bcb60b8a3ee4ab7e147932723e3abd1cef53516ce5210b39fd9fe5e3602ae ...
Waiting for services to be up and running (timeout 30 seconds)... # ATENÇÃO AQUI.
Using locally found image version due to "if-not-present" pull policy
Using docker image sha256:461edc13e56b039ebc3d898b858ac3acea00c47f31e93ec1258379cae8990522 for node:22-alpine with digest node@sha256:ad1aedbcc1b0575074a91ac146d6956476c1f9985994810e4ee02efd932a68fd ...
Preparing environment
00:01
Running on runner-jyvyfkmfg-project-69186599-concurrent-0 via 27213b29e8e9...
Getting source from Git repository
00:03
Fetching changes with git depth set to 20...
Reinitialized existing Git repository in /builds/puziol/learn-gitlab-app/.git/
Created fresh repository.
Checking out c5421d02 as detached HEAD (ref is pipe/rules)...
Skipping Git submodules setup
Executing "step_script" stage of the job script
00:04
Using docker image sha256:461edc13e56b039ebc3d898b858ac3acea00c47f31e93ec1258379cae8990522 for node:22-alpine with digest node@sha256:ad1aedbcc1b0575074a91ac146d6956476c1f9985994810e4ee02efd932a68fd ...
$ echo "Instalando cliente PostgreSQL..."
Instalando cliente PostgreSQL...
$ apk add --no-cache postgresql-client
fetch https://dl-cdn.alpinelinux.org/alpine/v3.21/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.21/community/x86_64/APKINDEX.tar.gz
(1/8) Installing postgresql-common (1.2-r1)
Executing postgresql-common-1.2-r1.pre-install
(2/8) Installing lz4-libs (1.10.0-r0)
(3/8) Installing libpq (17.4-r0)
(4/8) Installing ncurses-terminfo-base (6.5_p20241006-r3)
(5/8) Installing libncursesw (6.5_p20241006-r3)
(6/8) Installing readline (8.2.13-r0)
(7/8) Installing zstd-libs (1.5.6-r2)
(8/8) Installing postgresql17-client (17.4-r0)
Executing busybox-1.37.0-r12.trigger
Executing postgresql-common-1.2-r1.trigger
* Setting postgresql17 as the default version
WARNING: opening from cache https://dl-cdn.alpinelinux.org/alpine/v3.21/main: No such file or directory
WARNING: opening from cache https://dl-cdn.alpinelinux.org/alpine/v3.21/community: No such file or directory
OK: 15 MiB in 25 packages
$ echo "Aguardando o PostgreSQL estar disponível..."
Aguardando o PostgreSQL estar disponível...
$ until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done
$ echo "Conectando ao banco e criando a tabela..."
Conectando ao banco e criando a tabela...
$ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY, name TEXT);"
CREATE TABLE
$ echo "Inserindo dados..."
Inserindo dados...
$ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "INSERT INTO users(name) VALUES ('Mestre DevSecOps');"
INSERT 0 1
$ echo "Consultando dados..."
Consultando dados...
$ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "SELECT * FROM users;"
id | name
----+------------------
1 | Mestre DevSecOps
(1 row)
Cleaning up project directory and file based variables
00:01
Job succeeded

Poderíamos fazer uma analogia com o docker-compose abaixo.

version: '3.8'
services:
app:
image: node:22-alpine
environment:
- DB_HOST=db
- DB_PORT=5432
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpass
depends_on:
- db
command: |
sh -c "apk add --no-cache postgresql-client &&
until PGPASSWORD=$POSTGRES_PASSWORD psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done &&
psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY, name TEXT);' &&
psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'INSERT INTO users(name) VALUES (\"DevSecOps\");' &&
psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'SELECT * FROM users;'"
db:
image: postgres:16
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass

Para garantir que o container do PostgreSQL esteja em execução, eu coloquei essa linha no script como uma forma de forçar uma espera caso o service não esteja pronto.

 until PGPASSWORD=$POSTGRES_PASSWORD psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done &&

O valor de espera default é 30s mas pode ser alterado nas configurações do runner, por exemplo para 60 segundos. Esse seria o tempo máximo. Geralmente constainers sobem rápido.

[runner.docker]
wait_for_services_timeout = 60

Removendo o until acima o job rodaria normalmente pois o PostgreSQL sobe rápido.

O bloco service possui essa estrutura.

job:
services:
# service 1
- name:
alias:
entrypoint:
command:
variables:
# service 1
- name:
alias:
entrypoint:
command:
variables: