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 để:
-
Tự động xác thực SSH qua SSH Agent hoặc Private Key (
~/.ssh/id_ed25519,~/.ssh/id_rsa). -
Quét song song (concurrently) toàn bộ danh sách
hostscủa môi trường đích. -
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