0

Pre-flight Checks Bài 5: Remote Server Readiness (Khảo sát mặt bằng Server đích qua SSH)

Sau khi source code và chất lượng đã vượt qua 4 chốt chặn dưới Local, một tai nạn kinh điển khác thường xảy ra khi bắt đầu đẩy code lên Server: Đang giải nén hoặc chạy composer install / npm ci giữa chừng thì ổ cứng Server đầy 100% (No space left on device), hoặc user SSH bị thiếu quyền ghi (Permission denied) vào thư mục /var/www, khiến nửa số server chạy code mới, nửa số server chết đứng.

Ở Bài 5 này, chúng ta sẽ xây dựng module Remote Server Readiness sử dụng thư viện chuẩn golang.org/x/crypto/ssh kết hợp Goroutines để:

  1. Tự động xác thực SSH qua SSH Agent hoặc Private Key (~/.ssh/id_ed25519, ~/.ssh/id_rsa).

  2. Quét song song (concurrently) toàn bộ danh sách hosts của môi trường đích.

  3. Gộp toàn bộ bài kiểm tra (Dung lượng ổ cứng trống, Quyền ghi thư mục deploy_path, và Trạng thái các dịch vụ như nginx, php-fpm, supervisor) vào 1 phiên SSH duy nhất trên mỗi host để đạt tốc độ tính bằng mili-giây.

1. Cài đặt thư viện SSH và nâng cấp .deploy.yaml

Chạy lệnh sau tại thư mục godeploy để tải gói crypto/ssh chính thức của Go:

Bash

go get golang.org/x/crypto/ssh

Cập nhật .deploy.yaml

Chúng ta bổ sung thêm trường required_services vào từng môi trường để tool tự động kiểm tra xem các dịch vụ nền tảng trên server có đang chạy (active) hay không trước khi deploy:

YAML

project:
  name: "order-service"
  repository: "git@github.com:my-team/order-service.git"

preflight:
  min_disk_free_gb: 5
  require_clean_git: true
  local_checks:
    - "go vet ./..."
    - "go test ./..."
  sonarqube:
    enabled: false
    host_url: "https://sonar.internal.company.com"
    project_key: "order-service-backend"
    token_env: "SONAR_TOKEN"

environments:
  staging:
    branch: "develop"
    deploy_path: "/var/www/order-service-staging"
    user: "deploy"
    port: 22
    hosts:
      - "10.0.1.15"
    required_services:           # [MỚI] Các systemd service bắt buộc phải đang chạy
      - "nginx"
      - "supervisor"
    healthcheck_url: "https://staging-orders.internal/health"

  production:
    branch: "main"
    deploy_path: "/var/www/order-service"
    user: "deploy"
    port: 22
    hosts:
      - "10.0.2.101"
      - "10.0.2.102"
    required_services:
      - "nginx"
      - "supervisor"
    healthcheck_url: "https://orders.example.com/health"

Cập nhật EnvConfig trong internal/config/config.go

Mở file internal/config/config.go và thêm dòng RequiredServices vào struct EnvConfig:

Go

type EnvConfig struct {
	Branch           string   `yaml:"branch"`
	DeployPath       string   `yaml:"deploy_path"`
	User             string   `yaml:"user"`
	Port             int      `yaml:"port"`
	Hosts            []string `yaml:"hosts"`
	RequiredServices []string `yaml:"required_services"` // [MỚI] Danh sách dịch vụ cần kiểm tra
	HealthcheckURL   string   `yaml:"healthcheck_url"`
}

2. Xây dựng bộ SSH Client dùng chung (internal/sshutil/client.go)

Vì cả Bài 5 (Khảo sát Server), Bài 6 (Tạo Lock File) và bước Deploy chính thức sau này đều cần kết nối SSH, chúng ta sẽ tách một package riêng là internal/sshutil để tái sử dụng. Bộ client này sẽ tự động ưu tiên lấy key từ SSH_AUTH_SOCK (SSH Agent đang chạy trên máy bạn) trước, sau đó mới tìm đến các file ~/.ssh/id_ed25519 và ~/.ssh/id_rsa.

Tạo thư mục internal/sshutil và file internal/sshutil/client.go:

Go

package sshutil

import (
	"bytes"
	"context"
	"fmt"
	"net"
	"os"
	"path/filepath"
	"strings"
	"time"

	"golang.org/x/crypto/ssh"
	"golang.org/x/crypto/ssh/agent"
)

// RunCommand kết nối SSH tới host và thực thi câu lệnh bash với Context Timeout
func RunCommand(ctx context.Context, user, host string, port int, command string) (string, error) {
	authMethods, cleanup, err := getAuthMethods()
	if err != nil {
		return "", err
	}
	defer cleanup()

	sshConfig := &ssh.ClientConfig{
		User:            user,
		Auth:            authMethods,
		HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Trong mạng nội bộ CI/CD cho phép bỏ qua prompt hỏi known_hosts
		Timeout:         8 * time.Second,
	}

	addr := fmt.Sprintf("%s:%d", host, port)

	// Sử dụng net.Dialer gắn với Context để hủy ngay lập tức nếu quá thời gian
	var d net.Dialer
	conn, err := d.DialContext(ctx, "tcp", addr)
	if err != nil {
		return "", fmt.Errorf("không thể mở kết nối TCP tới %s: %w", addr, err)
	}

	c, chans, reqs, err := ssh.NewClientConn(conn, addr, sshConfig)
	if err != nil {
		conn.Close()
		return "", fmt.Errorf("xác thực SSH thất bại tại %s (user: %s): %w", addr, user, err)
	}
	client := ssh.NewClient(c, chans, reqs)
	defer client.Close()

	session, err := client.NewSession()
	if err != nil {
		return "", fmt.Errorf("không thể khởi tạo phiên SSH: %w", err)
	}
	defer session.Close()

	var stdout, stderr bytes.Buffer
	session.Stdout = &stdout
	session.Stderr = &stderr

	// Chạy lệnh trong goroutine để bắt sự kiện ctx.Done() (Timeout)
	errChan := make(chan error, 1)
	go func() {
		errChan <- session.Run(command)
	}()

	select {
	case <-ctx.Done():
		_ = session.Signal(ssh.SIGKILL)
		return "", fmt.Errorf("lệnh SSH vượt quá thời gian chờ (timeout)")
	case err := <-errChan:
		if err != nil {
			errMsg := strings.TrimSpace(stderr.String())
			if errMsg == "" {
				errMsg = err.Error()
			}
			return strings.TrimSpace(stdout.String()), fmt.Errorf("%s", errMsg)
		}
		return strings.TrimSpace(stdout.String()), nil
	}
}

// getAuthMethods tự động thu thập phương thức đăng nhập từ SSH Agent và ~/.ssh/
func getAuthMethods() ([]ssh.AuthMethod, func(), error) {
	var methods []ssh.AuthMethod
	var cleanups []func()

	cleanupAll := func() {
		for _, fn := range cleanups {
			fn()
		}
	}

	// 1. Thử lấy khóa từ SSH Agent (hỗ trợ cả key có đặt passphrase đã được ssh-add)
	if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
		if conn, err := net.Dial("unix", sock); err == nil {
			agentClient := agent.NewClient(conn)
			methods = append(methods, ssh.PublicKeysCallback(agentClient.Signers))
			cleanups = append(cleanups, func() { conn.Close() })
		}
	}

	// 2. Quét các file Private Key mặc định trong thư mục ~/.ssh/
	if home, err := os.UserHomeDir(); err == nil {
		keyFiles := []string{"id_ed25519", "id_rsa", "id_ecdsa"}
		var signers []ssh.Signer

		for _, name := range keyFiles {
			keyPath := filepath.Join(home, ".ssh", name)
			if keyBytes, err := os.ReadFile(keyPath); err == nil {
				if signer, err := ssh.ParsePrivateKey(keyBytes); err == nil {
					signers = append(signers, signer)
				}
			}
		}

		if len(signers) > 0 {
			methods = append(methods, ssh.PublicKeys(signers...))
		}
	}

	if len(methods) == 0 {
		return nil, cleanupAll, fmt.Errorf("không tìm thấy SSH Key hợp lệ trong ~/.ssh/ hoặc SSH Agent")
	}

	return methods, cleanupAll, nil
}

3. Xây dựng Module Khảo sát Server (internal/preflight/remote_check.go)

Thay vì mở 3 kết nối SSH liên tiếp cho 3 việc (kiểm tra thư mục, kiểm tra ổ cứng, kiểm tra service), chúng ta gom tất cả vào 1 câu lệnh Bash duy nhất để thực thi siêu tốc trên mỗi server:

  • mkdir -p '<deploy_path>' && test -w '<deploy_path>': Tự động tạo thư mục gốc nếu đây là lần deploy đầu tiên và kiểm tra quyền ghi (-w) của user SSH.

  • df -Pk '<deploy_path>' | awk 'NR==2 {print $4}': Lấy chính xác dung lượng ổ cứng còn trống (tính theo Kilobytes) của phân vùng chứa thư mục deploy.

  • systemctl is-active <service>: Kiểm tra trạng thái từng dịch vụ cốt lõi.

Nếu môi trường Production có 4 hay 10 web servers, chúng ta dùng sync.WaitGroup để bắn kiểm tra tới tất cả các server cùng một lúc.

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

Go

package preflight

import (
	"context"
	"fmt"
	"godeploy/internal/sshutil"
	"strconv"
	"strings"
	"sync"
)

type RemoteCheck struct {
	ConfigStep *ConfigCheck
}

type hostCheckResult struct {
	host       string
	freeDiskGB float64
	errMsg     string
	fixHint    string
}

func (c *RemoteCheck) Name() string {
	return "Remote Server Readiness (SSH, Disk, Path & Services)"
}

func (c *RemoteCheck) 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
	envCfg := cfg.Environments[c.ConfigStep.TargetEnv]
	minDiskGB := float64(cfg.Preflight.MinDiskFreeGB)

	// Chạy kiểm tra song song trên toàn bộ các hosts bằng Goroutines
	var wg sync.WaitGroup
	results := make([]hostCheckResult, len(envCfg.Hosts))

	for i, host := range envCfg.Hosts {
		wg.Add(1)
		go func(idx int, targetHost string) {
			defer wg.Done()
			results[idx] = c.checkSingleHost(ctx, envCfg.User, targetHost, envCfg.Port, envCfg.DeployPath, minDiskGB, envCfg.RequiredServices)
		}(i, host)
	}
	wg.Wait()

	// Tổng hợp kết quả từ tất cả các hosts
	var failedErrors []string
	var firstFixHint string
	var passedSummaries []string

	for _, r := range results {
		if r.errMsg != "" {
			failedErrors = append(failedErrors, fmt.Sprintf("[%s] %s", r.host, r.errMsg))
			if firstFixHint == "" && r.fixHint != "" {
				firstFixHint = r.fixHint
			}
		} else {
			passedSummaries = append(passedSummaries, fmt.Sprintf("%s (Trống %.1fGB)", r.host, r.freeDiskGB))
		}
	}

	if len(failedErrors) > 0 {
		return Result{
			Status:  StatusFail,
			Message: "Máy chủ đích chưa sẵn sàng:\n         • " + strings.Join(failedErrors, "\n         • "),
			FixHint: firstFixHint,
		}
	}

	return Result{
		Status:  StatusPass,
		Message: fmt.Sprintf("Tất cả %d host(s) đều sẵn sàng: %s.", len(envCfg.Hosts), strings.Join(passedSummaries, ", ")),
	}
}

func (c *RemoteCheck) checkSingleHost(
	ctx context.Context,
	user, host string,
	port int,
	deployPath string,
	minDiskGB float64,
	services []string,
) hostCheckResult {
	// Gộp kiểm tra quyền ghi thư mục, dung lượng ổ cứng (KB) và trạng thái services vào 1 lần gọi SSH
	var scriptBuilder strings.Builder
	scriptBuilder.WriteString(fmt.Sprintf("mkdir -p '%s' 2>/dev/null && test -w '%s' || { echo 'ERR_PERM'; exit 0; }; ", deployPath, deployPath))
	scriptBuilder.WriteString(fmt.Sprintf("FREE_KB=$(df -Pk '%s' | awk 'NR==2 {print $4}'); echo \"DISK_KB:$FREE_KB\"; ", deployPath))

	for _, svc := range services {
		svc = strings.TrimSpace(svc)
		if svc != "" {
			scriptBuilder.WriteString(fmt.Sprintf("systemctl is-active --quiet '%s' || echo 'ERR_SVC:%s'; ", svc, svc))
		}
	}

	output, err := sshutil.RunCommand(ctx, user, host, port, scriptBuilder.String())
	if err != nil {
		return hostCheckResult{
			host:    host,
			errMsg:  fmt.Sprintf("Lỗi kết nối SSH: %v", err),
			fixHint: fmt.Sprintf("Kiểm tra VPN/Firewall hoặc thử gõ `ssh -p %d %s@%s` trên terminal để kiểm tra SSH Key.", port, user, host),
		}
	}

	// Phân tích kết quả trả về từ kịch bản kiểm tra
	if strings.Contains(output, "ERR_PERM") {
		return hostCheckResult{
			host:    host,
			errMsg:  fmt.Sprintf("User '%s' không có quyền tạo hoặc ghi vào thư mục '%s'.", user, deployPath),
			fixHint: fmt.Sprintf("SSH vào server %s và chạy: `sudo mkdir -p %s && sudo chown -R %s:%s %s`", host, deployPath, user, user, deployPath),
		}
	}

	var freeGB float64
	var deadServices []string

	lines := strings.Split(output, "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "DISK_KB:") {
			kbStr := strings.TrimPrefix(line, "DISK_KB:")
			if kb, err := strconv.ParseFloat(kbStr, 64); err == nil {
				freeGB = kb / (1024 * 1024) // Đổi từ KB sang GB
			}
		} else if strings.HasPrefix(line, "ERR_SVC:") {
			svcName := strings.TrimPrefix(line, "ERR_SVC:")
			deadServices = append(deadServices, svcName)
		}
	}

	// Kiểm tra ngưỡng dung lượng ổ cứng
	if freeGB < minDiskGB {
		return hostCheckResult{
			host:    host,
			errMsg:  fmt.Sprintf("Dung lượng ổ cứng còn quá thấp: %.2f GB (Yêu cầu tối thiểu: %.0f GB).", freeGB, minDiskGB),
			fixHint: fmt.Sprintf("Dọn dẹp bớt file log cũ, bản release cũ hoặc chạy `docker system prune` trên host %s.", host),
		}
	}

	// Kiểm tra các dịch vụ bắt buộc
	if len(deadServices) > 0 {
		return hostCheckResult{
			host:    host,
			errMsg:  fmt.Sprintf("Các dịch vụ quan trọng đang ngừng hoạt động (inactive): %s", strings.Join(deadServices, ", ")),
			fixHint: fmt.Sprintf("SSH vào %s và khởi động lại dịch vụ: `sudo systemctl start %s`", host, strings.Join(deadServices, " ")),
		}
	}

	return hostCheckResult{
		host:       host,
		freeDiskGB: freeGB,
	}
}

4. Ghép nối vào Pipeline trong cmd/main.go

Mở file cmd/main.go và thêm remoteCheck vào bước thứ 5 của chuỗi kiểm tra:

Go

package main

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

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

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

	configCheck := &preflight.ConfigCheck{
		ConfigPath: ".deploy.yaml",
		TargetEnv:  targetEnv,
	}
	gitCheck := &preflight.GitCheck{
		ConfigStep: configCheck,
	}
	localCmdCheck := &preflight.LocalCmdCheck{
		ConfigStep: configCheck,
	}
	sonarCheck := &preflight.SonarCheck{
		ConfigStep: configCheck,
	}
	// [MỚI] Bước 5: Khảo sát mặt bằng Server đích qua SSH
	remoteCheck := &preflight.RemoteCheck{
		ConfigStep: configCheck,
	}

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

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

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

	cfg := configCheck.LoadedConfig
	fmt.Printf("📦 Sẵn sàng deploy dự án '%s' (Commit: %s) tới %v\n\n",
		cfg.Project.Name, gitCheck.CommitHash, cfg.Environments[targetEnv].Hosts)
}

5. Thực hành kiểm thử (Test Cases)

(Đừng quên chạy git add . && git commit -m "feat: add remote ssh readiness check" trước khi chạy thử để vượt qua chốt chặn Git Guard).

Kịch bản 1: Chạy thử với IP giả định 10.0.1.15 (Kiểm tra Timeout & Báo lỗi SSH)

Khi bạn chạy lệnh go run cmd/main.go staging với IP nội bộ chưa kết nối, tool sẽ chặn lại ở bước [5/5] và hướng dẫn lệnh debug rõ ràng:

Plaintext

[1/5] Đang kiểm tra: Đọc & Validate cấu hình (staging)...        [PASS] (1ms)
      └─ Dự án 'order-service' | Nhánh: 'develop' | Máy chủ đích: 1 host(s)
[2/5] Đang kiểm tra: Git Guard (Nhánh, Working Tree & Đồng bộ Remote)... [WARN] (19ms)
      └─ ⚠️  Nhánh 'develop' (@8a2f1c9) hợp lệ, nhưng chưa thể kết nối tới 'origin/develop' để kiểm tra đồng bộ.
[3/5] Đang kiểm tra: Local Quality Checks (Linter & Unit Tests)... [PASS] (380ms)
      └─ Đã vượt qua 2 bài kiểm tra chất lượng dưới local (go vet ./..., go test ./...).
[4/5] Đang kiểm tra: SonarQube Quality Gate...                   [PASS] (0ms)
      └─ Kiểm tra SonarQube đang tắt trong cấu hình (Bỏ qua).
[5/5] Đang kiểm tra: Remote Server Readiness (SSH, Disk, Path & Services)... [FAIL] (8002ms)
      ├─ ❌ Lỗi: Máy chủ đích chưa sẵn sàng:
         • [10.0.1.15] Lỗi kết nối SSH: không thể mở kết nối TCP tới 10.0.1.15:22: dial tcp 10.0.1.15:22: i/o timeout
      └─ 💡 Cách xử lý: Kiểm tra VPN/Firewall hoặc thử gõ `ssh -p 22 deploy@10.0.1.15` trên terminal để kiểm tra SSH Key.

Kịch bản 2: Mẹo Test thực tế ngay trên máy của bạn (hoặc Docker / VPS thật)

Nếu bạn đang có sẵn một VPS thật hoặc máy Linux/macOS bật SSH (localhost), hãy thử đổi thông số staging trong .deploy.yaml trỏ về máy thật của bạn (ví dụ: deploy_path: "/tmp/order-service-staging", hosts: ["127.0.0.1"], required_services: []).

Khi kết nối SSH thành công, kết quả bước [5/5] sẽ báo xanh mượt mà kèm số GB ổ cứng còn trống:

Plaintext

[5/5] Đang kiểm tra: Remote Server Readiness (SSH, Disk, Path & Services)... [PASS] (142ms)
      └─ Tất cả 1 host(s) đều sẵn sàng: 127.0.0.1 (Trống 48.5GB).
------------------------------------------------------------
✅ TẤT CẢ KIỂM TRA HOÀN TẤT (542ms) - SẴN SÀNG DEPLOY!

Chúng ta đã đi tới mảnh ghép cuối cùng của Series Pre-flight Checks! Ở Bài 6, chúng ta sẽ giải quyết bài toán State Locking (Chống đụng độ khi 2 người bấm deploy cùng lúc) và đóng gói toàn bộ thành 1 file binary hoàn chỉnh để bàn giao cho team.


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í