Overview
You have probably experienced this scenario: a developer commits one line of code, CI runs for 40 minutes, CD deployment takes another 20. You come back after two cups of coffee only to find the build failed—time to start over. By the end of the day, three hours gone just waiting on the pipeline.
This is not an isolated case. Among the teams I have worked with, the majority have pipelines exceeding 30 minutes. The DORA report data is even more blunt: high-performing teams deploy 973 times more frequently than low-performing ones, and pipeline duration is the core dividing line—the former completes a deployment in under 5 minutes on average, while the latter takes over an hour.
I have been in this trench. In a ride-hailing project, the team had a Go microservice pipeline that took 90 minutes end-to-end from code commit to production deployment. Developers complained daily, and release days were chaos. We spent two weeks doing full-chain optimization and compressed 90 minutes to 5. This article documents the approach, methods, and pitfalls from that optimization so you can apply them directly.
There are four core optimization directions: build caching, parallel scheduling, image layering, and incremental deployment. Let us break them down one by one.
Where Is the Pipeline Slow: Profile First, Do Not Guess
Many people start tweaking configurations right away—adding a cache today, trying parallelism tomorrow. Two weeks later, the pipeline is still 40 minutes—because you never knew where the time was going.
The right approach is to profile first. Measure the duration of each pipeline stage down to the second and identify the real bottlenecks.
Stage-by-Stage Duration Analysis
A typical microservice CI/CD pipeline includes these stages:
| Stage | Duration (Before) | Share | Common Bottleneck |
|---|---|---|---|
| Code checkout | 1-3 min | 3% | Full clone, large repo LFS files |
| Dependency install | 8-15 min | 17% | No cache, full download, slow lockfile resolution |
| Code compilation | 10-20 min | 22% | No incremental compile, serial multi-module builds |
| Unit tests | 10-20 min | 22% | Serial execution, slow test init, no parallelism |
| Image build | 5-15 min | 12% | No layer cache, full rebuild |
| Image push | 3-8 min | 6% | Large image, no compression, network bottleneck |
| Deployment | 10-30 min | 18% | Slow rolling update, unoptimized health checks |
This is data I measured in production. Dependency install + compilation + unit tests together account for over 60%. That is where you need to focus.
Profiling with CI Built-in Tools
Both GitLab CI and Jenkins have stage duration tracking:
# GitLab CI: Duration tracking is built-in
# No extra config needed—GitLab records started_at and finished_at for each job
# Fetch detailed data via API:
# Get duration of each job in the latest pipeline
# curl --header "PRIVATE-TOKEN: <token>" \
# "https://gitlab.example.com/api/v4/projects/<project_id>/pipelines/<pipeline_id>/jobs" \
# | jq '.[] | {name: .name, duration: .duration, status: .status}'
// Jenkins: Record stage durations in Pipeline
pipeline {
agent any
stages {
stage('Build') {
steps {
script {
def startTime = currentBuild.startTimeInMillis
echo "Build started at: ${new Date(startTime)}"
}
// ... build steps
}
post {
always {
script {
def duration = (System.currentTimeMillis() - currentBuild.startTimeInMillis) / 1000
echo "Total duration: ${duration}s"
}
}
}
}
}
}
If you monitor your CI/CD platform with Prometheus (recommended), you can query historical trends with PromQL:
# Query P95 build duration trend over the last 7 days
histogram_quantile(0.95,
sum(rate(ci_build_duration_seconds_bucket[7d])) by (le, stage)
)
Once you have the profiling data, sort by duration and start optimizing from the highest-share stage. This is the same principle as performance tuning—go after the big items first, do not waste time on 1% stages.
Cut #1: Build Caching — Eliminate the Hidden Cost of Redundant Downloads
Dependency installation is the most overlooked time sink in CI/CD. Why? Because every build starts from scratch—containerized runners spin up a fresh environment each time, and all dependencies from the previous build are gone.
For a Node.js project, npm ci installing node_modules accounts for 40%-60% of build time. For a Java project, Maven dependency downloads take 20-30%. All of this can be eliminated with caching.
GitLab CI Cache Configuration
# .gitlab-ci.yml
variables:
# overlay2 driver is 3-5x faster than vfs
DOCKER_DRIVER: overlay2
CACHE_DIR: "${CI_PROJECT_DIR}/.cache"
# Global cache config
cache:
# Generate cache key from lockfiles to avoid cross-branch pollution
key:
files:
- package-lock.json
- go.sum
prefix: ${CI_COMMIT_REF_SLUG}
paths:
- ${CACHE_DIR}/npm/ # npm cache
- ${CACHE_DIR}/go-build/ # Go build cache
- ${CACHE_DIR}/go-pkg/ # Go module cache
- node_modules/ # Node.js dependencies
# Go project build job
build_go:
stage: build
image: golang:1.22-alpine
variables:
GOPATH: ${CACHE_DIR}/go-pkg
GOCACHE: ${CACHE_DIR}/go-build
script:
# Set Go proxy (essential for China mainland)
- export GOPROXY=https://goproxy.cn,direct
- go mod download # Skips network download on cache hit
- go build -o bin/app ./cmd/server
artifacts:
paths:
- bin/
expire_in: 1 hour # Retain build artifacts for 1 hour
Cache hit rate is the key metric. GitLab CI’s caching works like this: after a runner finishes a job, it packages the cache directory and uploads it to cache storage (S3 or local). On the next build, it downloads the cache package before executing. If package-lock.json has not changed, the cache hits directly and skips dependency downloads.
Measured results: after enabling caching for a Go project, go mod download dropped from 45s to 3s. For a Node.js project, npm ci dropped from 120s to 15s.
Jenkins Cache Strategy
Jenkins has a different caching mechanism than GitLab CI. Jenkins retains files from the previous build in the agent workspace by default, providing natural caching. But if you use the Kubernetes plugin to dynamically create agent pods, each build gets a fresh pod and the cache is lost.
// Jenkinsfile: Kubernetes agent with persistent cache volumes
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: golang
image: golang:1.22-alpine
command: ["sleep", "infinity"]
volumeMounts:
- name: go-cache
mountPath: /go/pkg # Go module cache
- name: go-build-cache
mountPath: /root/.cache/go-build # Go compile cache
env:
- name: GOPROXY
value: "https://goproxy.cn,direct"
volumes:
- name: go-cache
persistentVolumeClaim:
claimName: go-module-cache # PVC persistence
- name: go-build-cache
persistentVolumeClaim:
claimName: go-build-cache # PVC persistence
'''
}
}
stages {
stage('Build') {
steps {
sh 'go mod download'
sh 'go build -o bin/app ./cmd/server'
}
}
}
}
One caveat here: if the PVC is ReadWriteOnce, concurrent builds will fight over it. Either use ReadWriteMany (requires NFS or CephFS backend) or use multiple PVCs with round-robin.
Maven Project Cache Optimization
For Java projects, Maven dependency downloads are a big chunk. A medium-sized Spring Boot project takes 5-8 minutes for a full dependency download.
<!-- pom.xml: Enable incremental compilation -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<useIncrementalCompilation>true</useIncrementalCompilation>
<parallelCompilation>true</parallelCompilation>
</configuration>
</plugin>
# .gitlab-ci.yml: Maven cache
build_java:
image: maven:3.9-eclipse-temurin-17
cache:
key:
files:
- pom.xml
paths:
- .m2/repository/ # Maven local repo
script:
# Offline mode first (skip download on cache hit)
- mvn package -o -DskipTests || mvn package -DskipTests
# Try offline compile first, fall back to online if deps are missing
artifacts:
paths:
- target/*.jar
A useful trick: split mvn dependency:go-offline into a separate preliminary job. This job only downloads dependencies without compiling. Once the cache is built, subsequent compile jobs hit the cache and skip downloads.
Cache Invalidation Strategies
Caching is not set-it-and-forget-it. Cache too large, cache pollution, and cache invalidation are three common issues.
| Problem | Cause | Solution |
|---|---|---|
| Sudden cache miss rate spike | Frequent lockfile changes | Generate cache key from package-lock.json hash |
| Cache package too large, slow upload | Accumulated stale deps | Periodic cleanup (expire_in: 7 days) |
| Cross-branch cache pollution | Cache key not branch-specific | Add prefix: ${CI_COMMIT_REF_SLUG} |
| Concurrent build cache contention | Multiple jobs writing cache simultaneously | Use cache:policy: pull-push to separate pull and push |
My recommendation: set expire_in: 7 days for cache directories. This maintains hit rate while controlling size. Do not use expire_in: never—after six months, cache packages can balloon to several GB.
Cut #2: Parallel Scheduling — Turn Serial into Parallel, Halve the Time
Most teams run their pipelines serially: compile → unit tests → image build → push → deploy, one step at a time. But many steps have no dependencies between them and can run in parallel.
Parallel Multi-Module Builds
In a microservice architecture, a single commit may involve multiple services. Serially building 5 services takes 25 minutes; parallel building takes 6 minutes.
# .gitlab-ci.yml: Parallel builds for multiple microservices
stages:
- build
- test
- package
# Parallel builds for multiple services
build_user_service:
stage: build
script:
- cd services/user && go build -o ../../bin/user ./cmd/server
rules:
- changes:
- services/user/**/* # Only build if user service code changed
build_order_service:
stage: build
script:
- cd services/order && go build -o ../../bin/order ./cmd/server
rules:
- changes:
- services/order/**/*
build_payment_service:
stage: build
script:
- cd services/payment && go build -o ../../bin/payment ./cmd/server
rules:
- changes:
- services/payment/**/*
rules: changes enables conditional builds in GitLab CI. Only the service whose code changed gets built. Full build of 5 services takes 25 minutes; building just 1 changed service takes 6 minutes.
Parallel Test Execution
Testing is another time sink. 2000 unit test cases running serially take 15 minutes; running them in parallel can compress to 3 minutes.
# .gitlab-ci.yml: Test parallelization
test_unit:
stage: test
parallel: 4 # Split into 4 parallel jobs
script:
# Go tests have built-in parallelism, -parallel controls concurrency
- go test -parallel 4 -count=1 -coverprofile=coverage.out ./...
# GitLab automatically distributes test cases across 4 parallel jobs
// Jenkinsfile: Parallel tests
stage('Test') {
parallel {
stage('Unit Tests') {
steps {
sh 'go test -short ./...'
}
}
stage('Integration Tests') {
steps {
sh 'go test -run Integration ./test/integration/...'
}
}
stage('Lint') {
steps {
sh 'golangci-lint run ./...'
}
}
}
}
Jenkins’s parallel block runs three test types simultaneously. Serial execution took 15 minutes (5+7+3); after parallelizing, it takes the maximum of 7 minutes.
A prerequisite for parallel tests: test cases must not share state. If your tests depend on the same database and modify each other’s data, parallel execution will interfere. Using Docker containers to isolate each parallel job’s test environment is the safest approach.
The needs Keyword to Break Stage Dependencies
GitLab CI executes stages sequentially by default—all jobs in stage1 must complete before stage2 starts. But some jobs do not depend on all jobs in the previous stage. The needs keyword breaks this limitation:
# .gitlab-ci.yml: Using needs to break stage dependencies
stages:
- build
- test
- deploy
build:
stage: build
script: go build -o bin/app ./cmd/server
test_unit:
stage: test
needs: ["build"] # Only depends on build, not other test jobs
script: go test -short ./...
test_integration:
stage: test
needs: ["build"] # Runs in parallel with test_unit
script: go test -run Integration ./...
deploy_staging:
stage: deploy
needs: ["test_unit"] # Deploy once unit tests pass, don't wait for integration
script: kubectl apply -f k8s/staging/
Originally three test jobs waited serially within the test stage. Now with needs, they run in parallel. deploy_staging does not need to wait for integration tests either—deploy to staging once unit tests pass, run integration tests concurrently, then push to production when they pass.
Cut #3: Docker Image Build Optimization — Layer Caching and Multi-Stage Builds
Image builds are slow 90% of the time because the Dockerfile is written poorly. Every build rebuilds all layers from scratch, wasting all cache.
Multi-Stage Build Separating Build Environment from Artifact
# Dockerfile: Multi-stage build (Go project example)
# Stage 1: Build environment (full Go toolchain)
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Key: Copy go.mod and go.sum first, then go mod download
# This way, as long as deps haven't changed, this layer's cache is hit
COPY go.mod go.sum ./
RUN go mod download
# Then copy source code (source changes often, but dep layer cache is unaffected)
COPY . .
# Compile (CGO disabled, static binary)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/bin/server ./cmd/server
# Stage 2: Runtime environment (minimal image, no Go toolchain)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/bin/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Note the order of COPY go.mod go.sum and COPY . .. Docker’s layer cache follows Dockerfile instruction order—if an earlier layer has not changed, later layers use cache. If you COPY . . first then go mod download, every code change triggers go mod download to re-execute, wasting the cache.
Measured comparison:
| Build Method | Image Size | Build Time (Cache Hit) | Build Time (Full) |
|---|---|---|---|
| Single-stage (golang:1.22) | 850 MB | 25s | 95s |
| Multi-stage (distroless) | 22 MB | 8s | 60s |
| Multi-stage + BuildKit | 22 MB | 5s | 45s |
Image shrank from 850MB to 22MB, and push time correspondingly dropped from 45s to 3s.
Enabling BuildKit for Acceleration
BuildKit is Docker’s next-generation build engine, 30%-50% faster than the traditional builder. It supports parallel building of unrelated layers, intelligent cache forwarding, and skipping unnecessary instructions.
# .gitlab-ci.yml: Enable BuildKit
variables:
DOCKER_BUILDKIT: "1"
BUILDX_BUILDER: "default"
build_image:
stage: package
image: docker:24.0
services:
- docker:24.0-dind
script:
# Enable BuildKit
- export DOCKER_BUILDKIT=1
- docker build
--build-arg BUILDKIT_INLINE_CACHE=1
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
-t $CI_REGISTRY_IMAGE:latest
.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
--cache-from and --cache-to push build cache to the image registry. On the next build, cache layers are pulled first—hit layers are skipped. This is especially useful when multiple runners share cache—Runner A builds and pushes cache, Runner B uses it on the next build.
Image Cache Strategy Comparison
| Cache Strategy | Config Complexity | Hit Rate | Use Case |
|---|---|---|---|
| Local cache (Docker daemon) | Low | High (single runner) | Single-runner environment |
| PVC persistent cache | Medium | High (shared across runners) | K8s runner pool |
| Registry remote cache | Medium | Medium (network dependent) | Multi-runner cross-node |
| BuildKit inline cache | Low | Medium | Quick enablement for simple projects |
| BuildKit registry cache | High | High | Large-scale CI/CD platforms |
My experience: for clusters under 100 nodes, PVC persistent cache is the best value—simple config, high hit rate. For large-scale CI/CD across data centers, Registry remote cache works best despite network latency, because cache sharing coverage is the widest.
For more on image optimization, see the previously published Related Article: Docker Image Optimization, which covers shrinking from 1GB to 50MB in detail.
Cut #4: Incremental Deployment — Stop Doing Full Replacements Every Time
The root cause of slow deployments is often full replacement. 10 Pods rolling update, replacing one by one, each waiting 30 seconds for health check—5 minutes total. If you only changed a config value, you do not need to rebuild the image at all.
Config Hot Reload vs Image Rebuild
| Change Type | Traditional Approach | Incremental Approach | Time Comparison |
|---|---|---|---|
| Config file change | Rebuild image + rolling update | ConfigMap hot reload | 5min → 3s |
| Single service code change | Full build + full deploy | Single service build + targeted deploy | 15min → 2min |
| Dependency version upgrade | Full build + full deploy | Full build + blue-green switch | 15min → 1min |
| Emergency fix | Rolling update | Direct Pod patch | 5min → 30s |
# K8s config hot reload (no Pod rebuild needed)
# After modifying ConfigMap, the config file in the Pod updates automatically
# (if using configmap volume)
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
config.yaml: |
log_level: debug # Changed from info to debug, no Pod rebuild needed
max_connections: 1000
# Only modify ConfigMap, do not trigger Pod rebuild
kubectl create configmap app-config --from-file=config.yaml -o yaml --dry-run=client | kubectl apply -f -
# If the app supports hot reload (e.g., Go's viper library), config takes effect immediately
# If not, restart Pods but skip image rebuild
kubectl rollout restart deployment/app
Rolling Update Parameter Tuning
K8s default rolling update parameters are not ideal for production. Default maxUnavailable=25% and maxSurge=25% means a 10-Pod Deployment replaces at most 3 at a time, waiting for 3 new Pods to be ready before replacing the next batch.
# Tuning rolling update parameters for different services
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
spec:
replicas: 10
strategy:
type: RollingUpdate
rollingUpdate:
# API gateway: fast update (stateless service)
maxUnavailable: 50% # Replace half at once, fast but riskier
maxSurge: 50%
template:
spec:
containers:
- name: app
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 2 # Don't wait 30s, check immediately if app starts fast
periodSeconds: 2 # Check every 2s
successThreshold: 1 # Ready after 1 success
failureThreshold: 3 # Fail after 3 failures
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
# Payment service: conservative update (stateful, strong consistency)
maxUnavailable: 0 # Never reduce available Pods
maxSurge: 1 # At most 1 extra Pod
template:
spec:
containers:
- name: app
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
successThreshold: 1
failureThreshold: 5
Stateless services can use aggressive maxUnavailable: 50%—10 Pods, replace 5 at a time, with fast health checks, rolling update completes in 30 seconds. Stateful services should be conservative: maxUnavailable: 0 + maxSurge: 1, ensuring sufficient instances are always available.
For a complete canary release and rollback strategy, see Related Article: Change Management: Canary Release and Rollback Strategies.
Putting It All Together: A Complete Optimized Pipeline
We have covered four optimization directions. Now let us string them together and see what a fully optimized pipeline looks like:
# .gitlab-ci.yml: Optimized complete pipeline (target: under 5 minutes)
variables:
DOCKER_DRIVER: overlay2
DOCKER_BUILDKIT: "1"
GOPROXY: "https://goproxy.cn,direct"
# Global cache
cache:
key:
files: [go.sum, package-lock.json]
prefix: ${CI_COMMIT_REF_SLUG}
paths:
- .cache/go-build/
- .cache/go-pkg/
- node_modules/
policy: pull-push # Pull then push
stages:
- check
- build
- test
- package
- deploy
# 1. Code check (lint + security scan, parallel)
lint:
stage: check
image: golangci/golangci-lint:v1.57
script: golangci-lint run --timeout 2m ./...
needs: [] # No dependencies, start immediately
security_scan:
stage: check
image: aquasec/trivy:latest
script: trivy fs --severity HIGH,CRITICAL --exit-code 1 .
needs: [] # Parallel with lint
# 2. Compile (with cache, completes in seconds)
build:
stage: build
image: golang:1.22-alpine
variables:
GOCACHE: ${CI_PROJECT_DIR}/.cache/go-build
GOPATH: ${CI_PROJECT_DIR}/.cache/go-pkg
script:
- go mod download
- go build -ldflags="-s -w" -o bin/server ./cmd/server
artifacts:
paths: [bin/]
expire_in: 1 hour
# 3. Tests (split parallel, 4-way)
test_unit:
stage: test
image: golang:1.22-alpine
parallel: 4
needs: ["build"]
script:
- go test -parallel 4 -count=1 -coverprofile=cov.out ./...
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: cov.out
# 4. Image build (multi-stage + BuildKit + remote cache)
docker_build:
stage: package
image: docker:24.0
services: [docker:24.0-dind]
needs: ["build"] # Does not wait for tests, runs in parallel
script:
- export DOCKER_BUILDKIT=1
- docker build
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# 5. Deploy (progressive by environment)
deploy_staging:
stage: deploy
image: bitnami/kubectl:1.29
needs: ["test_unit", "docker_build"]
script:
- kubectl set image deployment/app-staging
app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- kubectl rollout status deployment/app-staging --timeout=120s
environment:
name: staging
deploy_production:
stage: deploy
image: bitnami/kubectl:1.29
needs: ["deploy_staging"]
script:
- kubectl set image deployment/app-prod
app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- kubectl rollout status deployment/app-prod --timeout=180s
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # Production requires manual confirmation
Theoretical duration of this pipeline:
| Stage | Duration | Notes |
|---|---|---|
| lint + security_scan | 30s | Parallel execution |
| build | 15s | Cache hit, compile is fast |
| test_unit + docker_build | 60s | Parallel, take the max |
| deploy_staging | 30s | Image ready, just update Pods |
| deploy_production | 60s | After manual confirmation |
| Total | ~3 min | Automated portion (excludes manual confirmation) |
Custom Scheduling Engine: Production Experience
Everything discussed so far uses off-the-shelf CI/CD tools (GitLab CI, Jenkins) for optimization. But in a ride-hailing project, the problem we faced was that the scheduling model of existing tools was not flexible enough.
The team had 120+ microservices, each with its own pipeline. During a full release, 120 pipelines ran simultaneously, but there were not enough runner resources—40 minutes of queuing before execution even started. GitLab CI’s scheduling is first-come-first-served with no priority concept—the payment service build and the logging service build were in the same queue with no differentiation.
We built a DAG scheduling engine in Go that solved three core problems:
- Priority scheduling: Core business pipelines first, edge services queue
- Dependency awareness: Service A depends on Service B’s API—ensure B builds successfully before building A
- Resource reuse: When multiple services share the same runner, allocate intelligently based on resource requirements
// DAG scheduling engine core structure (simplified)
type TaskNode struct {
ID string
ServiceName string
Priority int // 1=high (core business), 5=low
DependsOn []string // Other Task IDs this depends on
Status TaskStatus // pending / running / success / failed
ResourceReq ResourceSpec // CPU/memory requirements
}
type Scheduler struct {
nodes map[string]*TaskNode
runnerPool chan *Runner // Runner resource pool
maxParallel int // Max parallelism
}
// Scheduling core logic: priority + dependency + resource, three-dimensional decision
func (s *Scheduler) Schedule() {
for {
// Find all pending tasks whose dependencies are satisfied
ready := s.findReadyTasks()
// Sort by priority
sort.Slice(ready, func(i, j int) bool {
return ready[i].Priority < ready[j].Priority
})
// Try to assign a runner to each ready task
for _, task := range ready {
runner := s.tryAcquireRunner(task.ResourceReq)
if runner != nil {
go s.execute(task, runner)
}
}
}
}
The result: full release of 120 services went from 40 minutes queuing + 90 minutes execution = 130 minutes, down to 5 minutes for core services (prioritized) + 15 minutes for full completion = 20 minutes.
A custom scheduling engine is not a silver bullet. It is only worth the investment when you have over 50 services and existing CI/CD tools’ scheduling models are insufficient. Small teams can get by with GitLab CI’s needs + parallel. For more on custom ops platform architecture, see Related Article: Building an Automated Inspection Platform.
Performance Optimization Assessment
Optimization is not finished when the work is done. You must continuously monitor pipeline duration to ensure the gains do not degrade.
Key Metrics
| Metric | Before | After | Improvement |
|---|---|---|---|
| End-to-end build duration | 90 min | 5 min | 94% |
| Dependency install duration | 12 min | 15 s | 98% |
| Image build duration | 8 min | 45 s | 91% |
| Image size | 850 MB | 22 MB | 97% |
| Deployment duration | 25 min | 2 min | 92% |
| Cache hit rate | 0% | 85% | - |
| Daily build count | 15 | 80 | 5x |
| Build failure rate | 8% | 2% | 75% |
Daily build count rose from 15 to 80, indicating improved developer confidence—the pipeline is fast, so people are willing to commit and deploy more frequently. Build failure rate dropped from 8% to 2% because caching stabilized and environment consistency improved.
Continuous Monitoring
Feed pipeline duration into Prometheus + Grafana and set alert thresholds:
# Alert if build duration exceeds 10 minutes
ci_build_duration_seconds{stage="total"} > 600
# Alert if cache hit rate drops below 50%
1 - (
ci_build_cache_miss_total / ci_build_total
) < 0.5
If build duration suddenly jumps from 5 minutes back to 15 minutes, the most likely cause is cache invalidation—either the lockfile changed or cache storage is full. Monitoring lets you catch issues before developers complain.
For more on building a monitoring system, see Related Article: Quick Setup: Prometheus Monitoring Stack.
Common Pitfalls and How to Avoid Them
Pitfalls encountered during optimization, to help you avoid the same mistakes.
Pitfall 1: Wrong Cache Key Causes Permanent Cache Miss
# Wrong: Using commit SHA as cache key—different every commit, cache never hits
cache:
key: ${CI_COMMIT_SHA}
paths: [node_modules/]
# Correct: Use lockfile hash as cache key
cache:
key:
files: [package-lock.json]
paths: [node_modules/]
Pitfall 2: Parallel Tests Sharing a Database Cause Interference
# Wrong: Multiple parallel test jobs connect to the same database, data conflicts
# test_unit_job_1 INSERT INTO users (id=1)
# test_unit_job_2 INSERT INTO users (id=1) → conflict
# Correct: Each parallel job gets its own database instance
test_unit:
parallel: 4
services:
- name: postgres:15
alias: db
# Each parallel job automatically gets an independent db instance
variables:
DATABASE_URL: "postgres://test:test@db:5432/test_$CI_NODE_INDEX"
Pitfall 3: Wrong Dockerfile COPY Order Invalidates Cache
# Wrong: COPY source first, every code change triggers go mod download re-execution
COPY . .
RUN go mod download
# Correct: COPY dependency files first, then go mod download, then COPY source
COPY go.mod go.sum ./
RUN go mod download
COPY . .
Pitfall 4: Aggressive Rolling Update Parameters Cause Service Outage
# Dangerous: maxUnavailable: 100% means delete all old Pods before creating new ones
# If new Pods fail to start, service is completely down
strategy:
rollingUpdate:
maxUnavailable: 100% # Do not do this
maxSurge: 0
For stateless services, maxUnavailable: 50% is my upper limit. Beyond that, the risk is too high—if the new version has a bug, you have already lost half your instances.
Pitfall 5: Forgetting to Clean Build Artifacts Fills Up Disk
# Every build generates artifacts, never cleaned, disk fills up in a month
artifacts:
paths: [bin/]
expire_in: 1 hour # Must set expiration
Build artifacts on the runner, if not cleaned, each at 100MB, 80 builds a day = 8GB. After a month, 240GB—disk full, builds fail immediately.
Summary
CI/CD deployment speed optimization is not a one-time effort; it is a continuous process. The core approach is four steps:
- Profile first: Do not guess bottlenecks—use data. Measure each stage’s duration first, then optimize from the highest share.
- Caching is the highest-ROI optimization: Dependency cache + compile cache + image layer cache—these three together can cut 50% of build time. Low config cost, immediate effect.
- Parallelism is the ultimate weapon: Serial to parallel, time goes from addition to maximum.
needsbreaks stage dependencies,parallelsplits concurrent tasks, multi-module parallel builds. - Incremental deployment reduces unnecessary rebuilds: Config changes use hot reload, code changes use targeted deployment, only major versions do full replacement.
My production data from the ride-hailing project: 90 minutes → 5 minutes, daily builds 15 → 80. The key improvement was developer experience—deployment went from “wait half a day” to “commit and get results,” taking the team’s iteration speed up a notch.
One final piece of advice: do not forget to set up monitoring after optimization. Pipeline duration, cache hit rate, build failure rate—keep an eye on these three metrics continuously. If they degrade, investigate immediately. Do not let optimization gains quietly slip away over a few months.
References & Acknowledgments
This article referenced the following materials during writing. Thanks to the original authors for their contributions:
- CI/CD Pipeline Optimization: Using Caching and Parallelization to Halve Build Time — Jianshu, systematically covers caching and parallelization configuration
- Containerized CI/CD Runner: Efficiency Optimization from Build Cache to Parallel Scheduling — CSDN, detailed analysis of Runner scheduling models and cache strategies
- CI/CD Pipeline Optimization: Image Build Acceleration from Jenkins to GitLab CI — CSDN, compares Jenkins and GitLab CI image build capabilities
- CI/CD Pipeline Optimization: From Build to Deployment Full Process — CSDN, provides a complete case from 30 minutes to 5 minutes
- Goodbye Slow Builds: Docker and GitLab CI 16.0 Multi-Stage Pipeline Performance Tuning — CSDN, deep dive into multi-stage builds and BuildKit acceleration
- Parallel Builds and Pipeline Optimization in Jenkins — Tencent Cloud Developer Community, Jenkins parallel build configuration examples
- Anti-patterns in Continuous Integration and Delivery Pipelines — Tencent Cloud Developer Community, summarizes common CI/CD pipeline anti-patterns
- What is CI/CD? — VMware, CI/CD fundamentals and process overview