Mục lục Series | Chương trước: Part 3: Thiết Kế Ranh Giới (DDD) Trong Modular Monolith → | Chương tiếp theo: Part 5: Observability Trong Bộ Nhớ →

📖 Xem bản tiếng Anh trên tanhdev.com: Modular Monolith CI/CD: Fast Builds & Test Pipelines

Answer-first: Modular Monolith loại bỏ tình trạng CI/CD chậm chạp nhờ kỹ thuật lọc Git diff, cache biên dịch Go và kiểm thử song song theo module. Triển khai một file binary duy nhất cho phép phát hành đơn nguyên tử (Atomic Deployments), đồng bộ hoàn hảo giữa mã nguồn và migration cơ sở dữ liệu.


1. Ảo Tưởng “Độc Lập Triển Khai” và Địa Ngục Đồng Bộ (Dependency Hell)

Một trong những động lực lớn nhất từng thúc đẩy các tổ chức công nghệ chuyển hướng sang Microservices là lời hứa hẹn về “Khả năng triển khai độc lập” (Independent Deployability). Trên lý thuyết, nhóm kỹ sư phụ trách Dịch vụ A có thể phát hành phiên bản mới bất cứ lúc nào mà không cần quan tâm đến Dịch vụ B hay C.

Thế nhưng, thực tế vận hành sản xuất lại phức tạp hơn nhiều khi xuất hiện hiện tượng “Dependency Hell” (Địa ngục phụ thuộc):

  • Nếu Service A cập nhật cấu trúc trường dữ liệu trong API response, Service B và Service C bắt buộc phải cập nhật theo để tránh lỗi gãy luồng xử lý.
  • Các đội ngũ kỹ sư buộc phải xây dựng các quy trình kiểm thử hợp đồng API (Contract Testing như Pact) phức tạp, thiết lập các cuộc họp đồng bộ release xuyên phòng ban, và tạo ra các ma trận tương thích phiên bản (Version Compatibility Matrices) đồ sộ.
  • Tốc độ phát hành phần mềm thực tế không hề tăng lên; trái lại, nó bị kẹt cứng trong các vòng lặp đồng bộ và chờ đợi lẫn nhau.

Ngược lại, kiến trúc Modular Monolith tận dụng mô hình Phát hành đơn nguyên tử (Atomic Deployments) — một phương pháp phát hành an toàn, tiết kiệm chi phí và có độ tin cậy vượt trội trong môi trường thực chiến.

sequenceDiagram
    autonumber
    participant Dev as "Lập trình viên (Git Commit)"
    participant Filter as "Git Diff Path Filter"
    participant GoBuild as "Go Build Cache & Test Runner"
    participant Deploy as "Pipeline Atomic Deployment"
    
    Dev->>Filter: Đẩy Commit Hash mới (Git Diff)
    Filter->>GoBuild: Chỉ kích hoạt kiểm thử cho module bị sửa đổi ("internal/billing")
    GoBuild->>GoBuild: Tận dụng $GOCACHE & Thực thi test song song
    GoBuild-->>Deploy: Toàn bộ kiểm thử vượt qua thành công
    Deploy->>Deploy: Đóng gói Single-Binary Container & Chạy Migration CSDL
    Deploy-->>Dev: Triển khai hoàn tất trong < 3 phút!

2. Bản Chất Kỹ Thuật Của Atomic Deployments

Atomic Deployment (Phát hành đơn nguyên tử) có nghĩa là toàn bộ hệ thống ứng dụng được nâng cấp như một khối thống nhất tại một thời điểm chính xác:

Trong Modular Monolith, mã nguồn logic nghiệp vụ và các định nghĩa cấu trúc cơ sở dữ liệu (Database Migrations) di chuyển cùng nhau trong một Commit Hash duy nhất. Khi bạn triển khai một phiên bản mới, toàn bộ các module đều được nâng cấp đồng thời:

  1. Không bao giờ bị lệch pha phiên bản: Bạn sẽ không bao giờ gặp phải tình trạng lỗi kinh điển: “Phiên bản API của Service A không khớp với phiên bản Service B.”
  2. Kịch bản Rollback tuyệt đối an toàn: Trong Microservices, điều gì sẽ xảy ra nếu Service A deploy thành công nhưng Service B bị lỗi và buộc phải rollback? Hệ thống sẽ rơi vào trạng thái phân kỳ dữ liệu (data divergence) cực kỳ nguy hiểm. Với Monolith, hoặc toàn bộ hệ thống cùng tiến lên phiên bản mới, hoặc toàn bộ cùng quay về phiên bản cũ. Trạng thái hệ thống luôn đảm bảo tính nhất quán tuyệt đối.
  3. Cập nhật Rolling Update không downtime: Khi triển khai trên Kubernetes hoặc Amazon ECS, vì toàn bộ ứng dụng được đóng gói trong một container image duy nhất, bộ điều khiển triển khai (Deployment Controller) chỉ cần thay thế các Pod/Task cũ bằng Pod/Task mới một cách tuần tự mà không cần điều phối thứ tự khởi động phức tạp giữa hàng chục service khác nhau.

3. Kiến Trúc CI/CD Dưới 3 Phút Cho Monorepo Quy Mô Lớn

Một định kiến sai lầm phổ biến là: “Mã nguồn Monolith quá lớn nên quá trình chạy CI/CD sẽ kéo dài hàng tiếng đồng hồ.” Thực tế cho thấy, nếu không biết cách tối ưu hóa, một bộ kiểm thử vốn chỉ mất 3 phút cho 5 lập trình viên có thể phình to thành 45 phút khi đội ngũ tăng lên 50 người, làm tắc nghẽn toàn bộ hàng đợi Pull Request.

Để giải quyết triệt để vấn đề này, các tập đoàn như Shopify hay GitHub đã chuẩn hóa Quy trình CI 4 Trụ Cột giữ thời gian chạy kiểm thử luôn dưới 3 phút:

flowchart TD
    A["Nhận diện thay đổi qua Git Diff"] --> B{"Phân tích cây phụ thuộc AST"}
    B --> C["Xác định các gói chịu ảnh hưởng trực tiếp"]
    B --> D["Bỏ qua các module không thay đổi (Cache Hit)"]
    C --> E["Khai thác bộ nhớ đệm Go $GOCACHE trên CI Runner"]
    E --> F["Phân bổ test suite vào Worker Pool song song"]
    F --> G["Tách riêng Integration Test qua Build Tags"]
    G --> H["Hoàn tất toàn bộ Pipeline dưới 3 phút!"]

3.1 Trụ Cột 1: Cô Lập Test Suite Bằng Go Build Tags

Các bài kiểm thử tích hợp (Integration Tests) đòi hỏi khởi động cơ sở dữ liệu PostgreSQL thực tế thường tiêu tốn rất nhiều thời gian. Chúng ta phân tách rõ ràng giữa Unit Test chạy trong vài mili-giây và Integration Test bằng cách sử dụng Go Build Tags:

//go:build integration

package billing_test

import "testing"

func TestBillingDatabaseMigration_Integration(t *testing.T) {
	// Kiểm thử logic nạp migration thực tế trên PostgreSQL
	// Chỉ được kích hoạt khi truyền cờ -tags=integration
}

Khi lập trình viên gõ go test ./..., Go runtime chỉ chạy các unit test siêu tốc trong RAM. Các bài kiểm thử tích hợp nặng sẽ được CI Runner kích hoạt có chọn lọc ở giai đoạn staging.

3.2 Trụ Cột 2: Phân Tích Đồ Thị Phụ Thuộc AST (Bazel / Go Diff)

Các công cụ xây dựng hiện đại như Bazel (với rules_go) xây dựng đồ thị có hướng không chu trình (DAG) cho toàn bộ cây cú pháp trừu tượng (AST) của dự án.

Công thức xác định các package cần kiểm thử: $$\text{Affected Packages} = \text{TargetPackage} \cup \text{TransitiveDependents}(\text{TargetPackage})$$

Nếu một Pull Request chỉ sửa đổi mã nguồn bên trong internal/billing và không có module nào khác phụ thuộc vào nó, hệ thống CI sẽ tự động bỏ qua toàn bộ việc kiểm thử của internal/inventory, internal/orders và internal/shipping, tiết kiệm tới 85% thời gian compute của CI.

3.3 Trụ Cột 3: Tái Sử Dụng Bộ Nhớ Đệm Biên Dịch $GOCACHE

Go sở hữu hệ thống bộ nhớ đệm biên dịch tự nhiên cực kỳ mạnh mẽ. Bằng cách mount thư mục $GOCACHE và $GOPATH/pkg/mod vào ổ đĩa bền vững (persistent caching layer) giữa các lần chạy của CI Runner, Go sẽ không bao giờ biên dịch lại những package bên thứ ba chưa bị thay đổi.

3.4 Trụ Cột 4: Kiểm Thử Hợp Đồng Giao Diện Nội Bộ (Interface Contract Testing)

Trong Microservices, việc kiểm thử giao tiếp đòi hỏi phải dựng các mock server mạng hoặc chạy Docker Compose phức tạp. Trong Go Modular Monolith, việc kiểm tra hợp đồng được thực thi ngay trong lúc biên dịch thông qua Compile-time Interface Assertion:

package billing_test

import (
	"testing"
	"github.com/myrepo/internal/billing"
)

// Khẳng định module ModuleImpl tuân thủ 100% hợp đồng interface Service tại thời điểm compile
var _ billing.Service = (*billing.ModuleImpl)(nil)

func TestBillingModule_ContractInvariants(t *testing.T) {
	// Kiểm tra các ràng buộc nghiệp vụ của Interface mà không cần mock mạng
}

4. Script Thực Thi Kiểm Thử Song Song Trong Go 1.25 Bằng Worker Pool

Dưới đây là một công cụ tự động hóa hoàn chỉnh viết bằng Go 1.25, minh họa cách thức CI Runner phân bổ các gói kiểm thử bị thay đổi vào một nhóm Worker Pool chạy song song, kết hợp kiểm soát thời gian chờ (timeout context) để ngăn ngừa tình trạng bài test bị treo vô hạn:

package main

import (
	"context"
	"fmt"
	"os/exec"
	"path/filepath"
	"sync"
	"time"
)

// TestTask định nghĩa gói package cần thực thi kiểm thử
type TestTask struct {
	PackagePath string
	Module      string
}

// TestResult ghi nhận kết quả và thời lượng thực thi của từng package
type TestResult struct {
	PackagePath string
	Duration    time.Duration
	Err         error
}

// RunSelectiveTests phân bổ các gói test cần chạy vào worker pool đồng thời
func RunSelectiveTests(ctx context.Context, modules []string) ([]TestResult, time.Duration) {
	start := time.Now()
	tasks := make(chan TestTask, len(modules))
	results := make(chan TestResult, len(modules))

	var wg sync.WaitGroup
	workers := 4 // Số lượng worker chạy kiểm thử song song tương ứng số lõi CPU

	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			for task := range tasks {
				t0 := time.Now()
				// Thực thi kiểm thử Go với cờ race condition detection
				cmd := exec.CommandContext(ctx, "go", "test", "-v", "-race", task.PackagePath)
				out, err := cmd.CombinedOutput()
				_ = out // Biến đầu ra có thể log khi cần thiết

				results <- TestResult{
					PackagePath: task.PackagePath,
					Duration:    time.Since(t0),
					Err:         err,
				}
			}
		}(w)
	}

	// Đẩy các module bị thay đổi vào hàng đợi xử lý
	for _, mod := range modules {
		pkgPath := filepath.Join("./internal", mod, "...")
		tasks <- TestTask{PackagePath: pkgPath, Module: mod}
	}
	close(tasks)

	wg.Wait()
	close(results)

	var resList []TestResult
	for res := range results {
		resList = append(resList, res)
	}

	return resList, time.Since(start)
}

func main() {
	// Giới hạn thời gian tối đa cho toàn bộ suite kiểm thử là 2 phút
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	// Danh sách các module được phát hiện thay đổi qua git diff
	changedModules := []string{"billing", "orders"}
	fmt.Println("Bắt đầu chạy kiểm thử có chọn lọc cho các module thay đổi...")

	results, elapsed := RunSelectiveTests(ctx, changedModules)
	fmt.Printf("Hoàn tất kiểm thử trong %v cho %d gói packages\n", elapsed, len(results))

	hasError := false
	for _, r := range results {
		if r.Err != nil {
			hasError = true
			fmt.Printf("❌ THẤT BẠI: %s (Thời gian: %v)\n", r.PackagePath, r.Duration)
		} else {
			fmt.Printf("✅ VƯỢT QUA: %s (Thời gian: %v)\n", r.PackagePath, r.Duration)
		}
	}

	if hasError {
		fmt.Println("Pipeline phát hiện lỗi kiểm thử!")
	} else {
		fmt.Println("Toàn bộ bài kiểm thử thành công, sẵn sàng phát hành!")
	}
}

5. Pipeline GitHub Actions Chuẩn Mực Cho Modular Monolith

Dưới đây là cấu hình hoàn chỉnh cho file .github/workflows/ci.yml sử dụng action dorny/paths-filter để nhận diện chính xác các thư mục module bị thay đổi, kết hợp bộ nhớ đệm cache của actions/setup-go:

name: Monolith Selective CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      modules: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            billing: 'internal/billing/**'
            inventory: 'internal/inventory/**'
            orders: 'internal/orders/**'

  test:
    needs: detect-changes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.25'
          cache: true # Tự động cache thư mục $GOCACHE

      - name: Kiểm thử Module Billing
        if: ${{ contains(needs.detect-changes.outputs.modules, 'billing') }}
        run: go test -v -race ./internal/billing/...

      - name: Kiểm thử Module Inventory
        if: ${{ contains(needs.detect-changes.outputs.modules, 'inventory') }}
        run: go test -v -race ./internal/inventory/...

      - name: Kiểm thử Module Orders
        if: ${{ contains(needs.detect-changes.outputs.modules, 'orders') }}
        run: go test -v -race ./internal/orders/...

6. Chiến Lược Di Trú Dữ Liệu An Toàn: Expand/Contract Pattern & Zero-Downtime

Trong mô hình Atomic Deployment, việc phát hành mã nguồn đi kèm với migration cơ sở dữ liệu đòi hỏi một quy trình chuyển đổi không phá vỡ (non-breaking schema evolution). Nếu bạn xóa bỏ hoặc đổi tên một cột trong database cùng lúc với việc deploy code mới, các container cũ đang phục vụ request sẽ lập tức ném ra lỗi 500 Internal Server Error.

Để đạt được trạng thái Zero-Downtime Migration, kỹ sư áp dụng mẫu hình Expand/Contract (Mở rộng / Thu hẹp) gồm 3 giai đoạn:

  1. Giai đoạn 1 (Expand - Mở rộng):
    • Thêm cột mới trong CSDL (cho phép NULL hoặc có giá trị mặc định).
    • Mã nguồn ứng dụng bắt đầu thực hiện ghi kép (Dual-write) vào cả cột cũ và cột mới, nhưng vẫn đọc từ cột cũ.
    • Chạy background worker để sao chép toàn bộ dữ liệu lịch sử từ cột cũ sang cột mới.
  2. Giai đoạn 2 (Chuyển mạch đọc):
    • Triển khai phiên bản ứng dụng tiếp theo: Chuyển hướng toàn bộ thao tác đọc sang cột mới.
    • Vẫn duy trì ghi kép dự phòng để hỗ trợ khả năng Rollback tức thì nếu phát hiện lỗi logic.
  3. Giai đoạn 3 (Contract - Thu hẹp):
    • Sau khi phiên bản mới chạy ổn định từ 24h đến 48h trên môi trường production, chạy một migration cuối cùng để gỡ bỏ logic ghi kép và xóa bỏ cột cũ trong CSDL.

Quy trình này đảm bảo rằng tại bất kỳ thời điểm nào trong quá trình rolling update, cả phiên bản ứng dụng cũ và mới đều hoạt động trơn tru với cùng một cấu trúc cơ sở dữ liệu.


7. Ma Trận So Sánh Các Công Cụ Xây Dựng Monorepo CI/CD

Tùy thuộc vào quy mô mã nguồn và số lượng ngôn ngữ lập trình trong repository, các kiến trúc sư có thể lựa chọn giải pháp quản lý monorepo phù hợp:

Công Cụ CI/CDCơ Chế CachingNgôn Ngữ Hỗ TrợĐộ Phức Tạp Cấu HìnhĐiểm Mạnh Nổi Bật
Go Native ($GOCACHE)Hash mã nguồn & object files nội bộThuần GoRất thấp (Mặc định có sẵn)Tốc độ siêu tốc, không cần cài đặt thêm phần mềm phụ trợ
Bazel (rules_go)Content-addressable remote cachingĐa ngôn ngữ (Polyglot)Rất cao (Đòi hỏi chuyên gia)Khả năng scale cho codebase hàng triệu dòng như Google, Uber
EarthlyDocker syntax kết hợp Makefile cachingĐa nền tảngTrung bìnhTái lập môi trường CI 100% giống hệt trên máy lập trình viên
Turborepo / NxPipeline DAG & Remote Task CachingTypeScript, Go, RustThấp đến Trung bìnhTối ưu hóa cực tốt cho dự án Fullstack có frontend lẫn backend

8. Triển Khai Không Downtime Với Kamal 2 Cho Single-Binary

Để triển khai Modular Monolith lên các máy chủ Bare-metal hoặc máy ảo EC2 mà không cần sự cồng kềnh của Kubernetes, công cụ Kamal 2 (do 37signals phát triển) là tiêu chuẩn hiện đại nhất. Kamal 2 sử dụng Docker và proxy Traefik tích hợp sẵn, hỗ trợ hook chạy migration cơ sở dữ liệu trước khi chuyển mạch lưu lượng (traffic cutover):

# deploy.yml: Cấu hình triển khai Kamal 2 cho Single-Binary Monolith
service: modular-monolith-core
image: registry.example.com/modular-monolith-core

servers:
  web:
    - 10.0.1.10
    - 10.0.1.11

# Hook pre-deploy tự động thực thi migration CSDL một cách an toàn
hooks:
  pre-deploy: |
    docker run --rm --net=host registry.example.com/modular-monolith-core:latest ./migrate -path ./db/migrations up

❓ Câu Hỏi Thường Gặp (FAQ)

Lợi ích cốt lõi của việc phát hành đơn nguyên tử (Atomic Deployments) là gì?

Atomic Deployments phát hành toàn bộ mã nguồn ứng dụng và các migration cơ sở dữ liệu cùng lúc dưới một commit hash duy nhất. Điều này loại bỏ hoàn toàn sự không tương thích phiên bản API giữa các dịch vụ và tránh được các trạng thái lỗi phức tạp khi phải rollback từng phần trong môi trường phân tán.

Làm thế nào để kiểm thử có chọn lọc (Selective Testing) giúp CI chạy dưới 3 phút?

Kiểm thử có chọn lọc kết hợp Git diff và phân tích đồ thị phụ thuộc AST để xác định chính xác những package nào bị tác động bởi commit mới. Bằng cách bỏ qua việc kiểm thử các module không liên quan và tận dụng bộ nhớ đệm biên dịch $GOCACHE, hệ thống CI có thể tiết kiệm tới 80-90% thời gian xử lý.

Merge Queue đóng vai trò gì trong việc bảo vệ tính ổn định của nhánh main ở repo lớn?

Merge Queue tự động gom các Pull Request đã được phê duyệt vào một hàng đợi và chạy thử nghiệm tích hợp tuần tự trên một nhánh ảo trước khi merge chính thức vào nhánh main. Cơ chế này ngăn chặn triệt để các xung đột ngầm và lỗi gãy build do nhiều lập trình viên cùng merge code đồng thời.

Làm cách nào công cụ Kamal 2 đảm bảo quá trình cập nhật diễn ra không có thời gian chết (Zero-downtime)?

Kamal 2 khởi động container phiên bản mới trên máy chủ, chạy các bài kiểm tra sức khỏe (health checks) để xác nhận ứng dụng đã sẵn sàng, sau đó ra lệnh cho proxy đảo chiều lưu lượng truy cập từ container cũ sang container mới một cách mượt mà trước khi dừng container cũ.

🔗 Chuyên Đề Tiếp Theo