
Introduction
Microservices only bring value when you can deploy quickly and securely. Without good CI/CD, microservices become more of a burden than monoliths — having to deploy dozens of services manually, each deployment is a risky event.
The goal of CI/CD in microservices: Each team deploys its service multiple times per day, confidently and independently.
1. CI/CD Architecture overview
1.1 Pipeline per service
Each service has its own pipeline, triggered by code changes:
Microservices CI/CD Model:
team-order pushes to order-service repo
↓
GitHub Actions (order-service pipeline)
↓
Build → Test → Scan → Push Image → Deploy Staging → Deploy Prod
team-payment pushes to payment-service repo (cùng lúc)
↓
GitHub Actions (payment-service pipeline)
↓
Build → Test → Scan → Push Image → Deploy Staging → Deploy Prod
→ Hai pipeline chạy song song, độc lập hoàn toàn
1.2 Pipeline Stages
┌─────────────────────────────────────────────────────────────┐
│ CI Pipeline │
│ │
│ ┌─────────┐ ┌────────┐ ┌────────┐ ┌─────────────────┐ │
│ │ Build │→ │ Test │→ │ Scan │→ │ Build & Push │ │
│ │ │ │ │ │ │ │ Container Image│ │
│ └─────────┘ └────────┘ └────────┘ └────────┬────────┘ │
└──────────────────────────────────────────────── ┼ ─────────┘
│
┌──────────────────────────────────────────────── ┼ ─────────┐
│ CD Pipeline │ │
│ ▼ │
│ ┌────────────────┐ ┌─────────────────────────────────┐ │
│ │ Deploy Staging │→ │ Integration Tests / E2E │ │
│ └────────────────┘ └──────────────┬──────────────────┘ │
│ │ (pass) │
│ ┌─────────────▼──────────────┐ │
│ │ Manual Approval (Prod) │ │
│ └─────────────┬──────────────┘ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Deploy Production │ │
│ │ (Canary → Full) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2. CI Pipeline — GitHub Actions
2.1 Complete Pipeline for Java service
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: registry.example.com
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
redis:
image: redis:7
options: --health-cmd "redis-cli ping" --health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Run tests
run: ./gradlew test integrationTest --no-daemon
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/test_db
SPRING_REDIS_HOST: localhost
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/reports/tests/
- name: Code coverage check
run: ./gradlew jacocoTestCoverageVerification --no-daemon
# Fail nếu coverage < 80%
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: SAST — SonarQube
uses: SonarSource/sonarcloud-github-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: Dependency vulnerability scan
run: ./gradlew dependencyCheckAnalyze --no-daemon
# OWASP Dependency-Check, fail on CVSS >= 7
build-image:
needs: [build-and-test, security-scan]
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # SLSA provenance
sbom: true # Software Bill of Materials
- name: Container image vulnerability scan (Trivy)
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
exit-code: '1'
severity: 'CRITICAL,HIGH'
2.2 Contract Testing with Pact
Consumer-driven contract testing ensures API compatibility between services:
// Order Service (Consumer) định nghĩa contract
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "payment-service")
class PaymentServiceContractTest {
@Pact(consumer = "order-service")
RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("payment method is valid")
.uponReceiving("a charge request")
.path("/charges")
.method("POST")
.body(new PactDslJsonBody()
.stringType("orderId")
.numberType("amount")
.stringValue("currency", "VND"))
.willRespondWith()
.status(201)
.body(new PactDslJsonBody()
.stringType("chargeId")
.stringValue("status", "SUCCESS"))
.toPact();
}
@Test
void testChargeRequest(MockServer mockServer) {
PaymentClient client = new PaymentClient(mockServer.getUrl());
ChargeResponse response = client.charge(new ChargeRequest("O-001", 500000, "VND"));
assertThat(response.getStatus()).isEqualTo("SUCCESS");
}
}
# CI: verify Payment Service còn match contract
- name: Publish contract
run: ./gradlew pactPublish
env:
PACT_BROKER_URL: https://pact-broker.internal
PACT_BROKER_TOKEN: ${{ secrets.PACT_TOKEN }}
# Payment Service CI:
- name: Verify consumer contracts
run: ./gradlew pactVerify
3. CD Pipeline — Deploy to Kubernetes
3.1 Update image tag in GitOps repo
# Sau khi build image, CD pipeline cập nhật manifest repo
- name: Update K8s manifest
run: |
git clone https://github.com/myorg/k8s-manifests.git
cd k8s-manifests
# Update image tag
yq e '.spec.template.spec.containers[0].image = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}"' \
-i services/order-service/overlays/staging/deployment.yaml
git config user.email "[email protected]"
git config user.name "CI Bot"
git add .
git commit -m "chore: update order-service image to ${{ steps.meta.outputs.version }}"
git push
# ArgoCD auto-sync sẽ detect change và deploy
3.2 Smoke Tests after Deploy
- name: Wait for deploy and run smoke tests
run: |
# Đợi rollout hoàn thành
kubectl rollout status deployment/order-service \
-n services-staging --timeout=5m
# Chạy smoke tests
./gradlew smokeTest \
--tests "com.example.SmokeTest" \
-Dbase.url=https://staging-api.example.com
// Smoke Test — kiểm tra luồng cơ bản hoạt động
@SpringBootTest
@Tag("smoke")
class SmokeTest {
@Test
void healthEndpointIsUp() {
given()
.get(baseUrl + "/actuator/health")
.then()
.statusCode(200)
.body("status", equalTo("UP"));
}
@Test
void canCreateAndRetrieveOrder() {
String orderId = given()
.contentType(ContentType.JSON)
.body("""{"customerId":"C-001","items":[{"productId":"P-1","qty":1}]}""")
.post(baseUrl + "/api/orders")
.then().statusCode(201)
.extract().path("id");
given()
.get(baseUrl + "/api/orders/" + orderId)
.then()
.statusCode(200)
.body("id", equalTo(orderId));
}
}
4. Monorepo vs Polyrepo
4.1 Polyrepo (Separate repos per service)
github.com/myorg/order-service (1 repo)
github.com/myorg/payment-service (1 repo)
github.com/myorg/inventory-service (1 repo)
Advantages:
- Team is completely independent
- Smaller codebase, faster clone/checkout
- Access control per service
Disadvantages:
- Difficult to refactor shared code
- Contract testing is more complicated
- Hard to find "who changed what ruined me?"
4.2 Monorepo
github.com/myorg/platform/
services/
order-service/
payment-service/
inventory-service/
shared/
proto/ ← Shared protobuf definitions
common/ ← Common utilities
tools/
Advantages:
- Atomic commits across services
- Easy to refactor shared code
- One place to find the entire codebase
Disadvantages:
- CI must be smart: only the build/test service is changed
- Need tooling like Nx, Turborepo, Bazel to manage
4.3 Monorepo CI with NX (affected detection)
# .github/workflows/ci.yml (monorepo)
- name: Detect affected services
id: affected
run: |
AFFECTED=$(npx nx affected:apps --plain --base=origin/main)
echo "services=$AFFECTED" >> $GITHUB_OUTPUT
- name: Build and test affected services
run: |
for service in ${{ steps.affected.outputs.services }}; do
npx nx test $service
npx nx build $service
npx nx docker-build $service
done
5. Testing Strategy Pyramid
/\
/ \ E2E Tests
/ 10%\ (Slow, expensive, validates full flows)
/──────\
/ \ Integration Tests
/ 30% \ (Test service boundaries, DB, message queues)
/────────────\
/ \ Contract Tests
/ 20% \ (API compatibility between services)
/──────────────────\
/ \ Unit Tests
/ 40% \ (Fast, isolated, business logic)
/────────────────────────\
Unit Tests: Test business logic, no external dependencies, runs < 100ms per test.
Integration Tests: Test service with real DB/cache (TestContainers), test repositories, message handlers.
Contract Tests: Pact — consumer defines contract, provider verifies.
E2E Tests: Playwright/Cypress for UI, REST Assured for API — only smoke test the most important flow.
6. Best Practices
Fail Fast:
jobs:
lint: # Nhanh nhất, chạy đầu tiên
unit-test: # Song song với lint
integration: # Sau unit tests
security-scan: # Song song với integration
build-image: # Sau tất cả tests pass
Immutable Images:
❌ Sai: Deploy script pull latest image trên server
✅ Đúng: Mỗi commit → unique image tag (SHA-based)
Deploy = thay đổi image tag trong manifest
Semantic Versioning:
feat: → minor version bump (1.2.0 → 1.3.0)
fix: → patch version bump (1.2.0 → 1.2.1)
BREAKING CHANGE → major version bump (1.2.0 → 2.0.0)
Summary
| Concept | Purpose |
|---|---|
| Pipeline per service | Deploy independently, do not block each other |
| Contract Testing | Ensure API compatibility |
| Immutable Images | Reproducible, rollback-able deployments |
| Smoke Tests | Confirm the service works after deploy |
| Test Pyramid | Balance between speed and confidence |
| Monorepo nx affected | Only test/build service is changed |
Next article: GitOps with ArgoCD