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:

StageDuration (Before)ShareCommon Bottleneck
Code checkout1-3 min3%Full clone, large repo LFS files
Dependency install8-15 min17%No cache, full download, slow lockfile resolution
Code compilation10-20 min22%No incremental compile, serial multi-module builds
Unit tests10-20 min22%Serial execution, slow test init, no parallelism
Image build5-15 min12%No layer cache, full rebuild
Image push3-8 min6%Large image, no compression, network bottleneck
Deployment10-30 min18%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.

ProblemCauseSolution
Sudden cache miss rate spikeFrequent lockfile changesGenerate cache key from package-lock.json hash
Cache package too large, slow uploadAccumulated stale depsPeriodic cleanup (expire_in: 7 days)
Cross-branch cache pollutionCache key not branch-specificAdd prefix: ${CI_COMMIT_REF_SLUG}
Concurrent build cache contentionMultiple jobs writing cache simultaneouslyUse 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 MethodImage SizeBuild Time (Cache Hit)Build Time (Full)
Single-stage (golang:1.22)850 MB25s95s
Multi-stage (distroless)22 MB8s60s
Multi-stage + BuildKit22 MB5s45s

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 StrategyConfig ComplexityHit RateUse Case
Local cache (Docker daemon)LowHigh (single runner)Single-runner environment
PVC persistent cacheMediumHigh (shared across runners)K8s runner pool
Registry remote cacheMediumMedium (network dependent)Multi-runner cross-node
BuildKit inline cacheLowMediumQuick enablement for simple projects
BuildKit registry cacheHighHighLarge-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 TypeTraditional ApproachIncremental ApproachTime Comparison
Config file changeRebuild image + rolling updateConfigMap hot reload5min → 3s
Single service code changeFull build + full deploySingle service build + targeted deploy15min → 2min
Dependency version upgradeFull build + full deployFull build + blue-green switch15min → 1min
Emergency fixRolling updateDirect Pod patch5min → 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:

StageDurationNotes
lint + security_scan30sParallel execution
build15sCache hit, compile is fast
test_unit + docker_build60sParallel, take the max
deploy_staging30sImage ready, just update Pods
deploy_production60sAfter manual confirmation
Total~3 minAutomated 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:

  1. Priority scheduling: Core business pipelines first, edge services queue
  2. Dependency awareness: Service A depends on Service B’s API—ensure B builds successfully before building A
  3. 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

MetricBeforeAfterImprovement
End-to-end build duration90 min5 min94%
Dependency install duration12 min15 s98%
Image build duration8 min45 s91%
Image size850 MB22 MB97%
Deployment duration25 min2 min92%
Cache hit rate0%85%-
Daily build count15805x
Build failure rate8%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:

  1. Profile first: Do not guess bottlenecks—use data. Measure each stage’s duration first, then optimize from the highest share.
  2. 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.
  3. Parallelism is the ultimate weapon: Serial to parallel, time goes from addition to maximum. needs breaks stage dependencies, parallel splits concurrent tasks, multi-module parallel builds.
  4. 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:

  1. CI/CD Pipeline Optimization: Using Caching and Parallelization to Halve Build Time — Jianshu, systematically covers caching and parallelization configuration
  2. Containerized CI/CD Runner: Efficiency Optimization from Build Cache to Parallel Scheduling — CSDN, detailed analysis of Runner scheduling models and cache strategies
  3. CI/CD Pipeline Optimization: Image Build Acceleration from Jenkins to GitLab CI — CSDN, compares Jenkins and GitLab CI image build capabilities
  4. CI/CD Pipeline Optimization: From Build to Deployment Full Process — CSDN, provides a complete case from 30 minutes to 5 minutes
  5. Goodbye Slow Builds: Docker and GitLab CI 16.0 Multi-Stage Pipeline Performance Tuning — CSDN, deep dive into multi-stage builds and BuildKit acceleration
  6. Parallel Builds and Pipeline Optimization in Jenkins — Tencent Cloud Developer Community, Jenkins parallel build configuration examples
  7. Anti-patterns in Continuous Integration and Delivery Pipelines — Tencent Cloud Developer Community, summarizes common CI/CD pipeline anti-patterns
  8. What is CI/CD? — VMware, CI/CD fundamentals and process overview