doc/development/cicd/_index.md
CI/CD pipelines are a fundamental part of GitLab development and deployment processes, automating tasks like building, testing, and deploying code changes. When developing features that interact with or trigger pipelines, it's essential to consider the broader implications these actions have on the system's security and operational integrity.
This document provides guidelines to help you develop features that use CI/CD pipelines securely and effectively. It emphasizes the importance of understanding the implications of running pipelines, managing authentication tokens responsibly, and integrating security considerations from the beginning of the development process.
CI_JOB_TOKEN). Consider whether the job
user should change and who the actor of the action is.Development guides that are specific to CI/CD are listed here:
See the CI/CD YAML reference documentation guide to learn how to update the CI/CD YAML syntax reference page.
This section describes the dashboards and metrics that can be used by engineers during development, change validation and incident investigation.
We maintain a ci-sample-projects group, with projects that showcase
examples of .gitlab-ci.yml for different use cases of GitLab CI/CD. They also cover specific syntax that could
be used for different scenarios.
The following is a simplified diagram of the CI architecture. Some details are left out to focus on the main components.
On the left side we have the events that can trigger a pipeline based on various events (triggered by a user or automation):
git push is the most common event that triggers a pipeline.Triggering any of these events invokes the CreatePipelineService
which takes as input event data and the user triggering it, then attempts to create a pipeline.
The CreatePipelineService relies heavily on the YAML Processor
component, which is responsible for taking in a YAML blob as input and returns the abstract data structure of a
pipeline (including stages and all jobs). This component also validates the structure of the YAML while
processing it, and returns any syntax or semantic errors. The YAML Processor component is where we define
all the keywords available to structure a pipeline.
The CreatePipelineService receives the abstract data structure returned by the YAML Processor,
which then converts it to persisted models (like pipeline, stages, and jobs). After that, the pipeline is ready
to be processed. Processing a pipeline means running the jobs in order of execution (stage or needs)
until either one of the following:
The component that processes a pipeline is ProcessPipelineService which handles:
pending if they're ready to run or created if they're waiting on dependent jobspending based on recently completed dependent jobsOn the right side of the diagram we have a list of runners
connected to the GitLab instance. These can be instance runners, group runners, or project runners.
The communication between runners and the Rails server occurs through a set of API endpoints, grouped as
the Runner API Gateway.
We can register, delete, and verify runners, which also causes read/write queries to the database. After a runner is connected,
it keeps asking for the next job to execute. This invokes the RegisterJobService
which picks the next job and assigns it to the runner. At this point the job transitions to a
running state, which again triggers ProcessPipelineService due to the status change.
For more details read Job scheduling).
While a job is being executed, the runner sends logs back to the server as well any possible artifacts that must be stored. Also, a job may depend on artifacts from previous jobs to run. In this case the runner downloads them using a dedicated API endpoint.
Artifacts are stored in object storage, while metadata is kept in the database. An important example of artifacts are reports (like JUnit, SAST, and DAST) which are parsed and rendered in the merge request.
Job status transitions are not all automated. A user may run manual jobs, cancel a pipeline, retry
specific failed jobs or the entire pipeline. Anything that
causes a job to change status triggers ProcessPipelineService, as it's responsible for
tracking the status of the entire pipeline.
A special type of job is the bridge job which is executed server-side
when transitioning to the pending state. This job is responsible for creating a downstream pipeline, such as
a multi-project or child pipeline. The workflow loop starts again
from the CreatePipelineService every time a downstream pipeline is triggered.
<i class="fa-youtube-play" aria-hidden="true"></i> You can watch a walkthrough of the architecture in CI Backend Architectural Walkthrough.
When a pipeline is created, the ProcessPipelineService runs automatically via Ci::InitialPipelineProcessWorker.
This service is also triggered via PipelineProcessWorker whenever a job changes statuses.
This service is responsible for moving all the pipeline's jobs to a completed state by setting them to either:
pending status if their requirements are met and they're ready to run and can be picked up by a runnercreated status if they need to wait, with processed marked as trueAfter a job has been executed it can complete successfully or fail. Each status transition for a job within a pipeline triggers this service again, which looks for the next jobs to be transitioned towards completion. While doing that, ProcessPipelineService updates the status of jobs, stages and the overall pipeline. If any jobs require further processing, the service will reschedule itself.
The processed flag acts as a "needs processing" indicator that gets reset to false before each status transition. This ensures that whenever a job's status changes, those changes are propagated to the stage and pipeline levels, and later builds and next builds in the DAG can be marked as pending. The job won't be marked as processed until the state is also propagated to the pipeline and stage.
The PipelineProcessWorker uses an exponential backoff that provides self-healing capabilities for pipelines when encountering intermittent errors.
If processing fails due to temporary issues (such as database timeouts, Redis errors, or out-of-memory conditions), the worker retries with increasing delays.
Since the worker is idempotent and checks that jobs and pipelines need_processing?, it can safely execute multiple
times. This retry mechanism can fix pipelines where all jobs are completed but the pipeline was not marked as completed
due to a temporary error during processing.
When a Pipeline is created all its jobs are created at once for all stages, with an initial state of created. This makes it possible to visualize the full content of a pipeline.
A job with the created state isn't seen by the runner yet. To make it possible to assign a job to a runner, the job must transition first into the pending state, which can happen if:
pending.needs: and all the dependent jobs are completed.Ci::PipelineCreation::DropNotRunnableBuildsService.When the runner is connected, it requests the next pending job to run by polling the server continuously.
[!note] API endpoints used by the runner to interact with GitLab are defined in
lib/api/ci/runner.rb
After the server receives the request it selects a pending job based on the Ci::RegisterJobService algorithm, then assigns and sends the job to the runner.
Once all jobs are completed for the current stage, the server "unlocks" all the jobs from the next stage by changing their state to pending. These can now be picked by the scheduling algorithm when the runner requests new jobs, and continues like this until all stages are completed.
After the runner is registered using the registration token, the server knows what type of jobs it can execute. This depends on:
The runner initiates the communication by requesting jobs to execute with POST /api/v4/jobs/request. Although polling happens every few seconds, we leverage caching through HTTP headers to reduce the server-side work load if the job queue doesn't change.
This API endpoint runs Ci::RegisterJobService, which:
pending jobsCi::RegisterJobServiceThis service uses 3 top level queries to gather the majority of the jobs and they are selected based on the level where the runner is registered to:
This list of jobs is then filtered further by matching tags between job and runner tags.
[!note] If a job contains tags, the runner doesn't pick the job if it does not match all the tags. The runner may have more tags than defined for the job, but not vice-versa.
Finally if the runner can only pick jobs that are tagged, all untagged jobs are filtered out.
At this point we loop through remaining pending jobs and we try to assign the first job that the runner "can pick" based on additional policies. For example, runners marked as protected can only pick jobs that run against protected branches (such as production deployments).
As we increase the number of runners in the pool we also increase the chances of conflicts which would arise if assigning the same job to different runners. To prevent that we gracefully rescue conflict errors and assign the next job in the list.
There are two ways of marking builds as "stuck" and drop them.
Ci::PipelineCreation::DropNotRunnableBuildsService checks for upfront known conditions that would make jobs not executable:
ci_quota_exceeded.allowed_plans, then the build is immediately dropped with no_matching_runner.Ci::StuckBuilds::DropPendingService.
failed with an appropriate failure reason.Compute minutes quota mechanism is handled early when the job is created because it is a constant decision for most of the time. Once a project exceeds the limit, every next job matching it will be applicable for it until next month starts. Of course, the project owner can buy additional minutes, but that is a manual action that the project need to take.
The same mechanism will be used for allowed_plans soon.
If the project is not on the required plan and a job is targeting such runner,
it will be failing constantly until the project owner changes the configuration or upgrades the namespace to the required plan.
Both mechanisms apply only to GitLab.com and consume significant compute resources at scale. Doing the check before the job is even transitioned to pending and failing early makes a lot of sense here.
Why we don't handle other cases for pending and drop jobs early? In some cases, a job is in pending only because the runner is slow on taking up jobs. This is not something that you can know at GitLab level. Depending on the runner's configuration and capacity and the size of the queue in GitLab, a job may be taken immediately, or may need to wait.
There are other possible reasons:
allowed_plans configuration.These issues are typically temporary and should be detected and fixed quickly. We definitely don't want to drop jobs immediately when one of these conditions is happening. Dropping a job only because a runner is at capacity or because there is a temporary unavailability/configuration mistake would be very harmful to users.
On GitLab.com, when traces are sent from a runner the content is stored in a redis_trace_chunks Redis cluster with a key containing the job ID and index.
A Ci::BuildTraceChunk record is also created to keep track of each chunk. This record indicates the current data store for the chunk. Admins of a GitLab
installation can configure which data stores are used. On non-GitLab.com instances, this behavior also occurs when ci_job_live_trace_enabled? is enabled.
PATCH /api/v4/jobs/1/trace (sends trace contents)Ci::AppendBuildTraceService stores chunk data in the live store (Redis trace chunks cluster for .com)Ci::BuildTraceChunk record in PostgreSQL with data_store: redis_trace_chunksCi::BuildTraceMetadata record to track trace metadatabuild.trace_chunks will contain records with redis_trace_chunks or another live store as the data_storePUT /api/v4/jobs/1 (job update)Ci::BuildTraceChunkFlushWorkerCi::BuildTraceChunkFlushWorker retrieves chunk data from Redis trace chunks clusterredis_trace_chunks cluster on .com)Ci::BuildTraceChunk record in PostgreSQL with data_store: fogbuild.trace_chunks will contain records with fog or another persisted store as the data_storePUT /api/v4/jobs/1 (job completion)Ci::BuildFinishedWorkerCi::ArchiveTraceWorker retrieves all fog chunks from object storageJobArtifact of type: :traceCi::BuildTraceChunk records from PostgreSQL
Ci::Build::Trace changes from the live to archived statebuild.trace_chunks will be an empty arraysequenceDiagram
participant Runner
participant API
participant Redis as Redis Trace Chunks
participant ObjectStorage as Object Storage
participant PostgreSQL
participant Archive as Archive Storage
Note over Runner, Archive: 1. Trace Append Phase
Runner->>API: PATCH /api/v4/jobs/1/trace
(trace contents)
API->>Redis: Ci::AppendBuildTraceService
stores chunk data
API->>PostgreSQL: Create Ci::BuildTraceChunk
(data_store: redis_trace_chunks)
API->>Runner: 200 OK
Note over Runner, Archive: 2. Job Update Phase
Runner->>API: PUT /api/v4/jobs/1
(job update)
API->>Redis: Ci::BuildTraceChunkFlushWorker
retrieves chunk data
API->>ObjectStorage: Copy chunk to storage
(each chunk = own file)
API->>Redis: Delete chunk in redis
API->>PostgreSQL: Update Ci::BuildTraceChunk
(data_store: fog)
API->>Runner: 200 OK
Note over Runner, Archive: 3. Job Completion & Archive Phase
Runner->>API: PUT /api/v4/jobs/1
(job completion)
API->>API: Trigger Ci::BuildFinishedWorker
API->>ObjectStorage: Ci::ArchiveTraceWorker
retrieves all fog chunks
API->>Archive: Create single archive file
API->>ObjectStorage: Delete individual chunks
API->>PostgreSQL: Update Ci::Build::Trace
(live → archived)
API->>Runner: 200 OK
"Job" in GitLab CI context refers a task to drive Continuous Integration, Delivery and Deployment. Typically, a pipeline contains multiple stages, and a stage contains multiple jobs.
In Active Record modeling, Job is defined as CommitStatus class.
On top of that, we have the following types of jobs:
Ci::Build ... The job to be executed by runners.Ci::Bridge ... The job to trigger a downstream pipeline.GenericCommitStatus ... The job to be executed in an external CI/CD system, for example Jenkins.When you use the "Job" terminology in codebase, readers would
assume that the class/object is any type of above.
If you specifically refer Ci::Build class, you should not name the object/class
as "job" as this could cause some confusions. In documentation,
we should use "Job" in general, instead of "Build".
We have a few inconsistencies in our codebase that should be refactored.
For example, CommitStatus should be Ci::Job and Ci::JobArtifact should be Ci::BuildArtifact.
See this issue for the full refactoring plan.