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 noclobbercủa Shell kết hợp toán tử>. Khi bậtnoclobber, 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.locksẽ nằm lại trên server mãi mãi. Do đó, bên trong file lock ta sẽ lưutimestamplú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