Dependências Entre Jobs
Já conseguimos gerar algum tipo de dependência até agora utilizando diferentes stages, mas todos os jobs estão executando em paralelo e quando queremos que um job executa depois do outro acabamos mudando para outro stage.
Dependência entre jobs no GitLab CI funciona com base em estágios (stages) e em chaves como needs
e dependencies
.
Needs
Com needs criamos dependência direta entre jobs, independente do state que se encontra.
Permite que jobs de estágios diferentes rodem fora da ordem de stages se necessário, mas só depois que os jobs especificados terminarem. O que quero dizer com isso?
stages:
- build
- test
job_a:
stage: build
script: echo "Build"
job_b:
stage: test
script: echo "Test"
# É uma lista de jobs que precisam terminar para que este execute.
needs: [job_a] # job_b depende de job_a, mas pode começar logo após job_a terminar, sem esperar o stage inteiro
Com isso conseguimos ganhar velocidade no pipeline. Uma coisa muito importante é que não é possível que um job de no stage de anterior dependa de um job no stage posterior.
stages:
- build
- test
job_a:
stage: build # stage 1
needs: [job_b] # Estará dependendo de algo que não começou?
script: echo "Build"
job_b:
stage: test # stage 2
script: echo "Test"
Mesmo se o fluxo for possível, como esse exemplo abaixo não podemos executar isso, pois a regra é clara no Gitlab CI "A job can only need jobs from earlier or the same stage"
.
stages:
- check
- build
- test
job_a:
stage: check # stage 1
script: echo "check"
job_b:
stage: build # stage 2
needs: [job_d]
script: echo "build"
job_c:
stage: test # stage 3
script: echo "Test"
job_d:
stage: test # stage 4
needs: [job_a] # Em teoria esse job executaria logo depois do job_a, antes do job_b que depende deste.
script: echo "Test"
Um outro detalhe que é importante para a performance é colocar um needs vazio (needs: []) para iniciar. Isso garante que execute logo no início do pipeline.
Ao usar needs o GitLab CI/ são criadas dependências explícitas entre jobs. O parâmetro artifacts: dentro de needs controla se o job atual irá ou não fazer download dos artefatos do job dependente. Quando não precisar do artefato desative para ajuar a ganhar velocidade. O padrão é artifacts: true
(ou omitido, que é o default) → baixa os artefatos.
stages:
- build
- test
job_a:
stage: build
script: echo "Build"
artifact:
#... push de um arquivo por exemplo
job_b:
stage: test
script: echo "Test"
needs:
- job: job_a
artifacts: false # Não fará o pull dos artefatos do job_a
Dependences
Só para constar além de needs existe o dependences
.
- Era usado só pra puxar artifacts de outros jobs, sem controlar execução.
- Não influenciava na ordem dos jobs nem liberava paralelismo.
- Substituído por needs, que faz tudo isso e melhor.
- Dependencies só pega artifacts de jobs de estágios anteriores e não permite execução paralela, já que todos os jobs de uma stage anterior precisam terminar.
- Needs é mais flexível: você pode usar para pegar artifacts de jobs em qualquer stage anterior ou da mesma stage, e permite paralelismo, executando os jobs assim que a dependência necessária for concluída.
Se o próprio GitLab enfatiza para que se use needs é melhor utilizar por que é assim que algo começa a ser depreciado. Na própria documentação do oficial temos a seguinte frase.
"To fetch artifacts from a job in the same stage, you must use needs:artifacts. You should not combine dependencies with needs in the same job. "
Agora vamos colocar alguns needs no nosso pipeline do projeto.
Só relembrando esse é o nosso stage.
stages:
- check # Análises que não precisam da pasta build/
- build # build + image build
- deploy # ainda fake
Vamos fazer dois needs aqui. O job build dentro o stage build pode iniciar junto com os checks mesmo no stage posterior para que possamos ganhar velocidade e para isso usamos needs: []. Na criação de imagem vamos esperar o job build finalizar.
Temos isso para o nosso stage de build.
.rules-only-main-mr: # NESTE MOMENTO VOU MANTER ESSA REGRA SÓ PARA FICAR FÁCIL DE ENTENDER, MAS MUDAREMOS MAIS PRA FRENTE.
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
when: always
build:
stage: build
needs: [] # Não depende de jobs de estágios anteriores, então executa logo
extends: [.rules-only-main-mr]
script:
- node --version
- npm --version
- npm ci
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/
image-build:
stage: build
needs: [build] # Depende só do job de build acima.
extends: [.rules-only-main-mr] # Atenção aqui... Explicado abaixo
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
--verbosity info
Se não tivesse a mesma rule em image-build que a rule de build, teremos problemas. Esse job seria lançado sem o job do qual ele depende e teremos esse erro.
Veja o job build sendo executado ao mesmo tempo que os jobs do stage de check.
Agora vamos acertar algumas coisas no momento de build para que utilize a pasta build gerada pelo job anterior e vamos colocar em um arquivo separado chamado Dockerfile.release. Nas pipelines usaremos este Dockerfile.release e locamente podemos utilizar o Dockerfile.
A Diferença é que utilizando utilizando as instruções do arquivo Dockerfile ele irá buildar o projeto usando um container e depois, já com os arquivos gerados por esse container, irá fazer o build de outro utilizando arquivos do primeiro. No nosso estudo precisamos pegar a pasta build gerada pelo processo de build e por isso outro Dockerfile.release foi disponibilizado para aproveitar o artefato gerado pelo build.
Crie um Dockerfile.release na raiz do projeto com o seguinte conteúdo.
#Dockerfile.release
FROM node:22-alpine
RUN npm install -g serve
WORKDIR /app
COPY build/ ./build
EXPOSE 3000
CMD ["serve", "-s", "build", "-l", "3000"]. Até agora o Kaniko esta buildando a imagem mas não esta guardando nem fazendo o push. Outra coisa é que o Dockerfile não esta usando o build gerado pelo processo de build, esta refazendo tudo.
Nós ainda vamos melhorar isso no futuro para ganhar performance.
Utilizando o Kaniko, poderíamos fazer o build e ao mesmo tempo fazer o push em um único comando, mas vamos separar essas responsabilidade para criar mais dependências entre os jobs e explorar os conceitos do estudo.
Para buildar a imagem não precisamos de acesso ao Docker Hub, mas para fazer o push sim. Tanto em development (branch develop) quanto em production (branch main) usaremos o artefato gerado pelo build, mas faremos o push com tags diferentes.
- A branch develop irá gerar a imagem com a tag latest
- A branch main irá gerar a imagem com tag stable
- Somente deve ser feito o push da imagem se o merge requesto for aceito.
Vamos usar a tag latest em dev
e stable em prod
. Não é para fazer na vida real!
O fluxo será o seguinte:
- No merge request vamos executar todo o stage de check.
- Ao aceitar o merge rodamos vamos executar todo o stage de build e no futuro de deploy.
- A branch main só deve aceitar o merge request vindo da develop: Isso deve ser uma política do Gitlab, não do pipeline. Usar o CI para bloquear merges errados funciona, mas tem limitações e riscos.
- A pessoa já abriu o merge request, talvez já iniciou revisão ou até aprovou. Só falha na pipeline.
- Alguém com permissão pode ignorar falhas, desabilitar o job ou forçar o merge.
- Só impede que o pipeline passe, mas não evita o erro na origem, que é o próprio MR.
Para esse último caso como devemos agir? Utilizando Merge request branch workflow
em settings > merge requests. Porém esse recurso esta disponível por enquanto somente no plano pago do GitLab. Com esse recurso podemos definir regras para abrir o merge request. Geralmente empresas grandes costumam pagar para usar o GitLab pois tem outras vantagens!.
Como não vamos por esse caminho, vamos manter as proteções ativadas para a branches develop e main para que somente mantainers do repositório possam aceitar um merge request tendo estas branches como target. "Grandes poderes, grandes responsabilidades".
Aqui o que vamos precisar no stage de build para começar.
# Estamos mudando agora a regra anterior que se chamava .rules-only-main-mr
.rules-merged-accepted:
rules:
- if: '$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: latest # Se for develop a tag da imagem será latest
when: always
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: stable # Se for produção a tag da imagem será stable
when: always
# Precisaremos do build para gerar a pasta build que será disponibilizada como artefato.
build:
stage: build
needs: [] # Mas podemos adiantar o processo para que ele seja executado mais rápido.
extends: [.rules-merged-accepted] # Aproveitando a regra e economizando código.
script:
- npm ci
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/
build-image:
stage: build
needs: [build] # Só para lembrar, por default ele baixa os artefatos.
extends: [.rules-merged-accepted] # Mais uma economia de código
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug # ATENÇÃO VAMOS DESSAS IMAGENS DEBUG MAIS ADIANTE
entrypoint: [""] # ATENÇÃO VAMOS EXPLICAR POR QUE DESATIVAMOS O ENTRYPOINT MAIS ADIANTE
variables:
DOCKER_CONFIG: "/kaniko/.docker"
before_script:
- mkdir -p /kaniko/.docker # Cria o diretório do Docker config
# As variáveis DOCKER_USERNAME E DOCKER_TOKEN devem ser definidas no repositório
# O kanico utiliza esse arquivo para fazer login no registry em caso de push
- echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"username\":\"$DOCKER_USERNAME\",\"password\":\"$DOCKER_TOKEN\"}}}" > /kaniko/.docker/config.json
script:
- >
/kaniko/executor \
--context "${CI_PROJECT_DIR}" \
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.release" \
--tarPath "image.tar" \
--destination "learn-gitlab-app:latest" \
--no-push
# Não vamos fazer o push, só gerar a imagem mesmo e subir no artifact com o nome image.tar
artifacts:
paths:
- image.tar
expire_in: 1 hour
# O processo do push é igual do build a diferença é que ele sobe.
# O kanico não permite usar a imagem.tar como fizemos acima. Ele refaz o build completamente. Já vamos solucionar isso.
push-image:
extends: [build-image] # Economizando código!
needs:
- job: build-image
artifacts: false # Não precisaremos do artefato
script:
- >
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.release"
--destination "docker.io/${DOCKER_USERNAME}/${CI_PROJECT_NAME}:${TAG}"
artifacts: {}
Enxugamos o pipeline aproveitando os conceitos de extend, melhoramos a rule para criar imagem somente se o merge for aceito para branches específicas, salvamos o artefato (./pics/imagem.tar), mas o push não esta sendo feito dessa imagem pois o kaniko não consegue fazer isso, ele precisa rebuildar a imagem.
Poderíamos executar o push-image em pararelo pois não faz sentido esperar o que não precisamos, afinal o kaniko não esta usando a imagem que esta no artefato. Fizemos isso só para ilustrar uma idéia de dependência.
push-image:
...
needs: # Poderíamos remover todo esse bloco.
- job: build-image
artifacts: false
Porém o que estamos querendo fazer o push da imagem gerada e para isso temos outras ferramentas capazes de fazer isso (crane e skopeo). Já falaremos disso.
Imagens DEBUG
Uma imagem como o kaniko e o crane que vamos usar mais abaixo possuem o entrypoint sendo o próprio comand line da ferramenta. Para reduzir a imagem tudo é removido, inclusive o shell. Porém o GitLab precisa de um shell para executar o script. Por isso optamos por imagens geralmente com a tag debug que incluem um shell dentro, mas ainda sim o entrypoint é o comand line da ferramenta e por isso desativamos o entrypoint usando (entrypoint: [""]
) para alterar para o entrypoint para o shell padrão da imagem. O que nos importa na imagem é o que elas nos oferece instalado.
Dessa forma conseguimos utilizar o before_script, script e after_script.
Usando o Crane
Sabendo que o Kanico não faz como queremos, vamos ajustar para fazer o push do próprio artefato imagem.tar e para isso podemos usar o crane ou até mesmo o skopeo.
Vamos ajustar esse pipeline para usar o crane.
.rules-merged-accepted:
rules:
- if: '$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: latest
when: always
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
variables:
TAG: stable
when: always
build:
stage: build
needs: []
extends: [.rules-merged-accepted]
script:
- node --version
- npm --version
- npm ci
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/
build-image:
stage: build
needs: [build]
extends: [.rules-merged-accepted]
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
variables:
DOCKER_CONFIG: "/kaniko/.docker"
before_script:
- mkdir -p /kaniko/.docker # Cria o diretório do Docker config
# as variáveis DOCKER_USERNAME E DOCKER_TOKEN devem ser definidas no repositório
- echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"username\":\"$DOCKER_USERNAME\",\"password\":\"$DOCKER_TOKEN\"}}}" > /kaniko/.docker/config.json
script:
- >
/kaniko/executor \
--context "${CI_PROJECT_DIR}" \
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.release" \
--tarPath "image.tar" \
--destination "learn-gitlab-app:latest" \
--no-push
artifacts:
paths:
- image.tar
expire_in: 1 hour
push-image:
stage: build
needs: [build-image]
extends: [.rules-merged-accepted]
image:
name: gcr.io/go-containerregistry/crane:debug # Debug....
entrypoint: [""] # Zerando o entrypoint
variables:
REGISTRY: docker.io
script:
- crane auth login $REGISTRY -u $DOCKER_USERNAME -p $DOCKER_TOKEN
- crane push image.tar docker.io/${DOCKER_USERNAME}/${CI_PROJECT_NAME}:${TAG}
Agora sim estamos fazendo o push do image.tar
.