0

Pre-flight Checks Bài 6: State Locking (Chống đụng độ khi Deploy) & Đóng gói Binary hoàn chỉnh

Trong một team có từ 2 lập trình viên trở lên, tình huống sau xảy ra như cơm bữa: Dev A đang chạy lệnh deploy lên môi trường staging (đang chạy dở composer install hoặc database migration), thì Dev B vừa merge code xong cũng gõ lệnh deploy vào đúng môi trường staging đó. Hậu quả là 2 tiến trình ghi đè file lên nhau, làm hỏng thư mục build hoặc gây deadlock database.

Ở bài học cuối cùng của series Pre-flight Checks này, chúng ta sẽ giải quyết triệt để bài toán Concurrency Control (Kiểm soát đồng thời) bằng cơ chế Atomic Distributed Lock qua SSH kèm theo TTL (Time-To-Live) và cơ chế Signal Trap (Ctrl+C) để tự động dọn khóa an toàn. Cuối bài, chúng ta sẽ đóng gói toàn bộ công cụ thành file chạy độc lập (Binary) cho Windows, macOS và Linux để phát cho cả team.

1. Tư duy thiết kế State Locking chống Race Condition

Làm sao để tạo file .deploy.lock trên Server mà không bị "đụng độ" nếu 2 người gõ lệnh ở cùng một mili-giây?

  • Tuyệt đối không dùng: Kiểm tra file tồn tại (test -f) rồi mới tạo file (echo > .deploy.lock) bằng 2 lệnh tách rời, vì khoảng trễ giữa 2 lệnh vẫn gây ra Race Condition.

  • Giải pháp chuẩn Unix (Atomic Operation): Sử dụng cờ set -o noclobber của Shell kết hợp toán tử >. Khi bật noclobber, hệ điều hành Linux đảm bảo ở cấp độ Kernel rằng lệnh { set -C; echo '...' > .deploy.lock; } sẽ thất bại ngay lập tức nếu file đã tồn tại. Chỉ duy nhất 1 người đầu tiên tạo được file.

  • Chống treo khóa vĩnh viễn (Stale Lock / TTL): Nếu máy của Dev A đang deploy mà bị mất điện hoặc rớt mạng, file .deploy.lock sẽ nằm lại trên server mãi mãi. Do đó, bên trong file lock ta sẽ lưu timestamp lúc bắt đầu. Nếu khóa đã tồn tại quá lock_ttl_minutes (ví dụ: 15 phút), tool sẽ tự động coi đó là khóa chết (Stale Lock), xóa đi và cho phép người mới chiếm quyền.

2. Cập nhật .deploy.yaml và internal/config/config.go

Thêm cấu hình lock_ttl_minutes vào mục preflight trong file .deploy.yaml:

YAML

preflight:
  min_disk_free_gb: 5
  require_clean_git: true
  lock_ttl_minutes: 15         # [MỚI] Tự động hủy khóa nếu quá 15 phút (tránh treo lock khi mất mạng)
  local_checks:
    - "go vet ./..."
    - "go test ./..."

Mở file internal/config/config.go, thêm trường LockTTLMinutes vào PreflightConfig và gán giá trị mặc định trong hàm Load():

Go

type PreflightConfig struct {
	MinDiskFreeGB   int         `yaml:"min_disk_free_gb"`
	RequireCleanGit bool        `yaml:"require_clean_git"`
	LockTTLMinutes  int         `yaml:"lock_ttl_minutes"` // [MỚI] Thời gian sống tối đa của file lock
	LocalChecks     []string    `yaml:"local_checks"`
	SonarQube       SonarConfig `yaml:"sonarqube"`
}

Và bên trong hàm Load(path string) của internal/config/config.go, ngay dưới đoạn kiểm tra MinDiskFreeGB, thêm giá trị mặc định:

Go

	if cfg.Preflight.LockTTLMinutes <= 0 {
		cfg.Preflight.LockTTLMinutes = 15 // Mặc định khóa có hiệu lực tối đa 15 phút
	}

3. Xây dựng Module LockCheck (internal/preflight/lock_check.go)

Module này không chỉ thực hiện việc Chiếm khóa (Acquire Lock) trong lúc chạy Pre-flight, mà còn cung cấp hàm Release() (Nhả khóa) để main.go gọi tự động khi quá trình deploy kết thúc hoặc khi người dùng bấm Ctrl+C hủy ngang.

Tạo file internal/preflight/lock_check.go:

Go

package preflight

import (
	"context"
	"encoding/json"
	"fmt"
	"godeploy/internal/sshutil"
	"os"
	"os/user"
	"path/filepath"
	"strings"
	"time"
)

// LockMetadata lưu trữ thông tin người đang giữ khóa dưới dạng JSON trong file .deploy.lock
type LockMetadata struct {
	LockedBy  string `json:"locked_by"`
	HostName  string `json:"hostname"`
	Branch    string `json:"branch"`
	Commit    string `json:"commit"`
	CreatedAt int64  `json:"created_at"` // Unix timestamp (giây)
}

type LockCheck struct {
	ConfigStep *ConfigCheck
	GitStep    *GitCheck
	acquired   bool // Đánh dấu xem phiên hiện tại có phải là chủ sở hữu hợp pháp của khóa không
}

func (c *LockCheck) Name() string {
	return "State Locking (Chống đụng độ Deploy)"
}

// getLocalActor lấy tên User trên máy tính hiện tại (ví dụ: hieu@MacBook-Pro)
func getLocalActor() (string, string) {
	username := "unknown"
	if u, err := user.Current(); err == nil {
		username = u.Username
	}
	hostname, err := os.Hostname()
	if err != nil {
		hostname = "local"
	}
	return username, hostname
}

func (c *LockCheck) Run(ctx context.Context) Result {
	if c.ConfigStep == nil || c.ConfigStep.LoadedConfig == nil {
		return Result{
			Status:  StatusFail,
			Message: "Chưa có dữ liệu cấu hình từ bước trước.",
		}
	}

	cfg := c.ConfigStep.LoadedConfig
	targetEnv := c.ConfigStep.TargetEnv
	envCfg := cfg.Environments[targetEnv]

	// Ta đặt file .deploy.lock trên Host đầu tiên (Primary Host) của môi trường đích
	primaryHost := envCfg.Hosts[0]
	lockFilePath := filepath.ToSlash(filepath.Join(envCfg.DeployPath, ".deploy.lock"))
	ttlSeconds := int64(cfg.Preflight.LockTTLMinutes * 60)
	now := time.Now().Unix()

	actor, hostname := getLocalActor()
	commitHash := "unknown"
	if c.GitStep != nil && c.GitStep.CommitHash != "" {
		commitHash = c.GitStep.CommitHash
	}

	meta := LockMetadata{
		LockedBy:  actor,
		HostName:  hostname,
		Branch:    envCfg.Branch,
		Commit:    commitHash,
		CreatedAt: now,
	}
	metaBytes, _ := json.Marshal(meta)
	metaJSON := string(metaBytes)

	// Kịch bản Bash Atomic:
	// 1. Dùng `set -C` (noclobber) để tạo file nguyên tử. Nếu thành công -> in ra ACQUIRED.
	// 2. Nếu thất bại (file đã tồn tại) -> đọc nội dung file lock hiện tại trả về cho Go xử lý.
	acquireScript := fmt.Sprintf(
		"mkdir -p '%s' && ( set -C; echo '%s' > '%s' ) 2>/dev/null && echo 'LOCK_ACQUIRED' || cat '%s'",
		envCfg.DeployPath, metaJSON, lockFilePath, lockFilePath,
	)

	output, err := sshutil.RunCommand(ctx, envCfg.User, primaryHost, envCfg.Port, acquireScript)
	if err != nil {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("Không thể kiểm tra trạng thái khóa trên %s: %v", primaryHost, err),
			FixHint: "Kiểm tra lại kết nối SSH tới máy chủ đích.",
		}
	}

	// Trường hợp 1: Chiếm khóa thành công ngay lần đầu
	if strings.TrimSpace(output) == "LOCK_ACQUIRED" {
		c.acquired = true
		return Result{
			Status:  StatusPass,
			Message: fmt.Sprintf("Đã khóa phiên deploy thành công trên %s (Người giữ: %s@%s).", primaryHost, actor, hostname),
		}
	}

	// Trường hợp 2: Đã có file lock -> Parse JSON để kiểm tra xem ai đang giữ và đã hết hạn (TTL) chưa
	var existingLock LockMetadata
	if err := json.Unmarshal([]byte(output), &existingLock); err == nil && existingLock.CreatedAt > 0 {
		elapsedSeconds := now - existingLock.CreatedAt

		// Nếu khóa đã vượt quá thời gian TTL -> Xóa khóa chết (Stale lock) và chiếm lại quyền
		if elapsedSeconds > ttlSeconds {
			forceScript := fmt.Sprintf("echo '%s' > '%s' && echo 'LOCK_OVERRIDDEN'", metaJSON, lockFilePath)
			outOverride, errOverride := sshutil.RunCommand(ctx, envCfg.User, primaryHost, envCfg.Port, forceScript)
			if errOverride == nil && strings.TrimSpace(outOverride) == "LOCK_OVERRIDDEN" {
				c.acquired = true
				return Result{
					Status: StatusWarn,
					Message: fmt.Sprintf("Phát hiện khóa cũ của '%s' đã treo quá %d phút. Đã tự động thu hồi và cấp khóa mới cho bạn!",
						existingLock.LockedBy, cfg.Preflight.LockTTLMinutes),
				}
			}
		}

		// Nếu khóa vẫn còn hiệu lực -> Chặn đứng và báo rõ tên đồng nghiệp đang deploy!
		elapsedDuration := time.Duration(elapsedSeconds) * time.Second
		remainMinutes := int((ttlSeconds - elapsedSeconds) / 60) + 1
		return Result{
			Status: StatusFail,
			Message: fmt.Sprintf("Môi trường '%s' đang bận! '%s@%s' đang deploy nhánh '%s' (commit %s) cách đây %v.",
				targetEnv, existingLock.LockedBy, existingLock.HostName, existingLock.Branch, existingLock.Commit, elapsedDuration),
			FixHint: fmt.Sprintf("Vui lòng đợi đồng nghiệp deploy xong (tự động mở khóa sau tối đa %d phút) hoặc chạy lệnh xóa file `%s` nếu chắc chắn phiên trước bị lỗi.", remainMinutes, lockFilePath),
		}
	}

	// Phòng hờ file .deploy.lock bị hỏng định dạng JSON
	return Result{
		Status:  StatusFail,
		Message: fmt.Sprintf("Phát hiện file khóa '%s' trên server nhưng không đọc được thông tin.", lockFilePath),
		FixHint: fmt.Sprintf("SSH vào %s và chạy `rm -f %s` để xóa file khóa lỗi.", primaryHost, lockFilePath),
	}
}

// Release tự động xóa file .deploy.lock trên Server khi kết thúc hoặc khi bị hủy ngang (Ctrl+C)
func (c *LockCheck) Release() {
	if !c.acquired || c.ConfigStep == nil || c.ConfigStep.LoadedConfig == nil {
		return
	}

	cfg := c.ConfigStep.LoadedConfig
	envCfg := cfg.Environments[c.ConfigStep.TargetEnv]
	primaryHost := envCfg.Hosts[0]
	lockFilePath := filepath.ToSlash(filepath.Join(envCfg.DeployPath, ".deploy.lock"))

	// Tạo một context độc lập 5 giây để đảm bảo dù Context chính đã bị hủy (Ctrl+C) vẫn đủ thời gian gửi lệnh xóa khóa
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	rmCmd := fmt.Sprintf("rm -f '%s'", lockFilePath)
	_, err := sshutil.RunCommand(ctx, envCfg.User, primaryHost, envCfg.Port, rmCmd)
	if err != nil {
		fmt.Printf("\n⚠️  Cảnh báo: Không thể tự động xóa file lock (%s) trên %s: %v\n", lockFilePath, primaryHost, err)
	} else {
		fmt.Printf("🔓 Đã mở khóa phiên deploy (%s) trên %s an toàn.\n", lockFilePath, primaryHost)
	}
	c.acquired = false
}

4. Hoàn thiện cmd/main.go (Tích hợp Signal Trap & Phiên bản hoàn chỉnh)

Điểm cực kỳ quan trọng của một CLI chuyên nghiệp: Nếu Dev đang chạy mà bấm Ctrl + C (SIGINT) hoặc tắt cửa sổ Terminal (SIGTERM), chương trình phải bắt được tín hiệu đó để chạy hàm lockCheck.Release() xóa khóa trên server rồi mới thoát.

Cập nhật toàn bộ file cmd/main.go:

Go

package main

import (
	"context"
	"fmt"
	"godeploy/internal/preflight"
	"os"
	"os/signal"
	"syscall"
	"time"
)

// Version sẽ được truyền tự động lúc chạy lệnh go build -ldflags
var Version = "dev"

func main() {
	// Hỗ trợ gõ `godeploy --version` hoặc `godeploy -v`
	if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
		fmt.Printf("godeploy version %s\n", Version)
		os.Exit(0)
	}

	targetEnv := "staging"
	if len(os.Args) > 1 {
		targetEnv = os.Args[1]
	}

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	// Khởi tạo đủ 6 chốt chặn Pre-flight
	configCheck := &preflight.ConfigCheck{
		ConfigPath: ".deploy.yaml",
		TargetEnv:  targetEnv,
	}
	gitCheck := &preflight.GitCheck{
		ConfigStep: configCheck,
	}
	localCmdCheck := &preflight.LocalCmdCheck{
		ConfigStep: configCheck,
	}
	sonarCheck := &preflight.SonarCheck{
		ConfigStep: configCheck,
	}
	remoteCheck := &preflight.RemoteCheck{
		ConfigStep: configCheck,
	}
	lockCheck := &preflight.LockCheck{
		ConfigStep: configCheck,
		GitStep:    gitCheck,
	}

	// Đảm bảo luôn mở khóa khi hàm main kết thúc
	defer lockCheck.Release()

	// Bắt tín hiệu Ctrl+C (SIGINT) hoặc SIGTERM để dọn khóa sạch sẽ trước khi thoát
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
	go func() {
		sig := <-sigChan
		fmt.Printf("\n\n⚠️  Nhận tín hiệu ngắt (%v)! Đang hủy quy trình và dọn dẹp khóa trên Server...\n", sig)
		cancel()            // Hủy ngay lập tức các lệnh đang chạy dở
		lockCheck.Release() // Mở khóa trên server
		os.Exit(130)
	}()

	checks := []preflight.Check{
		configCheck,
		gitCheck,
		localCmdCheck,
		sonarCheck,
		remoteCheck,
		lockCheck,
	}

	runner := preflight.NewRunner(true, checks...)

	if !runner.Execute(ctx) {
		os.Exit(1)
	}

	// =========================================================================
	// KHU VỰC THỰC THI DEPLOY CHÍNH THỨC (SAU KHI VƯỢT QUA 100% PRE-FLIGHT)
	// =========================================================================
	cfg := configCheck.LoadedConfig
	fmt.Printf("🚀 Đang tiến hành Deploy dự án '%s' (Commit: %s) lên môi trường [%s]...\n",
		cfg.Project.Name, gitCheck.CommitHash, targetEnv)

	// Giả lập quá trình deploy mất 3 giây (Bạn có thể bấm thử Ctrl+C trong lúc này để xem Lock tự gỡ)
	time.Sleep(3 * time.Second)

	fmt.Printf("🎉 DEPLOY HOÀN TẤT THÀNH CÔNG!\n")
}

5. Kết quả khi 2 người cùng chạy lệnh Deploy một lúc

Giả sử bạn mở 2 cửa sổ Terminal cùng lúc:

  • Terminal 1 (Bạn chạy trước): Vượt qua bước [6/6] và giữ khóa:

    Plaintext

    [6/6] Đang kiểm tra: State Locking (Chống đụng độ Deploy)...    [PASS] (115ms)
          └─ Đã khóa phiên deploy thành công trên 127.0.0.1 (Người giữ: hieu@MacBook-Pro).
    ------------------------------------------------------------
    ✅ TẤT CẢ KIỂM TRA HOÀN TẤT (620ms) - SẴN SÀNG DEPLOY!
    🚀 Đang tiến hành Deploy dự án 'order-service' (Commit: 9b1d4e2) lên môi trường [staging]...
    
    
  • Terminal 2 (Đồng nghiệp bấm chạy ngay sau đó 2 giây): Bị chặn đứng ngay tại bước [6/6] với thông tin cực kỳ chi tiết:

    Plaintext

    [6/6] Đang kiểm tra: State Locking (Chống đụng độ Deploy)...    [FAIL] (110ms)
          ├─ ❌ Lỗi: Môi trường 'staging' đang bận! 'hieu@MacBook-Pro' đang deploy nhánh 'develop' (commit 9b1d4e2) cách đây 2s.
          └─ 💡 Cách xử lý: Vui lòng đợi đồng nghiệp deploy xong (tự động mở khóa sau tối đa 15 phút) hoặc chạy lệnh xóa file `/var/www/order-service-staging/.deploy.lock` nếu chắc chắn phiên trước bị lỗi.
    ------------------------------------------------------------
    ⛔ QUY TRÌNH DEPLOY BỊ CHẶN ĐỨNG DO LỖI PRE-FLIGHT!
    
    

Và ngay khi Terminal 1 chạy xong (hoặc bấm Ctrl+C), dòng thông báo 🔓 Đã mở khóa phiên deploy... hiện ra, file .deploy.lock biến mất, trả lại mặt bằng sạch sẽ cho lần tiếp theo!

6. Đóng gói Cross-Compile thành Binary phát cho cả Team

Đây là lúc tận hưởng ưu thế tuyệt đối của Golang: Anh em trong team dùng macOS (chip M1/M2/M3 hay Intel), Linux (Ubuntu/CentOS), hay Windows đều không cần cài Go, chỉ cần tải đúng 1 file chạy duy nhất của bạn về là dùng được ngay.

Hãy tạo một file Makefile ở thư mục gốc godeploy/Makefile để tự động hóa việc build cho mọi hệ điều hành và nhúng luôn số phiên bản (v1.0.0) vào trong file thực thi:

Makefile

VERSION ?= v1.0.0
LDFLAGS := -ldflags="-s -w -X main.Version=$(VERSION)"

.PHONY: build-all clean install-local

## Cài trực tiếp vào máy của bạn để gõ lệnh `godeploy` ở bất kỳ thư mục nào
install-local:
	go build $(LDFLAGS) -o /usr/local/bin/godeploy ./cmd/main.go
	@echo "✅ Đã cài đặt thành công! Hãy thử gõ: godeploy --version"

## Đóng gói cho toàn bộ hệ điều hành của các thành viên trong Team
build-all: clean
	mkdir -p dist
	# 1. Linux (64-bit - Dùng cho Ubuntu/Debian/CI Runner như Jenkins, GitLab CI, Bitbucket)
	GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/godeploy-linux-amd64 ./cmd/main.go
	
	# 2. macOS Apple Silicon (Chip M1/M2/M3/M4)
	GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/godeploy-darwin-arm64 ./cmd/main.go
	
	# 3. macOS Intel
	GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/godeploy-darwin-amd64 ./cmd/main.go
	
	# 4. Windows (64-bit)
	GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/godeploy-windows-amd64.exe ./cmd/main.go
	
	@echo "🎉 Đã đóng gói hoàn tất toàn bộ Binary trong thư mục ./dist/:"
	@ls -lh dist/

clean:
	rm -rf dist/

(Lưu ý: Trong Makefile, các dòng thụt lề bên dưới install-local:, build-all:, clean: phải sử dụng phím Tab).

Chạy lệnh đóng gói:

Bash

make build-all VERSION=v1.0.0

Chỉ sau khoảng 2 giây, thư mục dist/ của bạn sẽ xuất hiện 4 file thực thi siêu nhẹ (đã được nén dung lượng nhờ cờ -s -w):

  • godeploy-linux-amd64

  • godeploy-darwin-arm64

  • godeploy-darwin-amd64

  • godeploy-windows-amd64.exe

Từ giờ, bạn chỉ cần copy file tương ứng vào /usr/local/bin/godeploy trên máy của các thành viên trong team (hoặc đưa vào Docker image của Jenkins/Bitbucket Runner). Đứng ở bất kỳ dự án nào có file .deploy.yaml, anh em chỉ cần gõ:

Bash

godeploy staging
# hoặc
godeploy production

Chúc mừng bạn đã hoàn thành trọn vẹn Series 6 bài học xây dựng Bộ Pre-flight Checks chuẩn Production từ con số 0 cho đến khi ra lò một CLI Binary thực thụ!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí