Trong hệ sinh thái DevOps hiện đại, CLI tool là “món ăn hàng ngày” của mọi kỹ sư. Hầu hết các công cụ bạn đang dùng mỗi ngày — kubectl, gh, docker, terraform — đều được viết bằng Go và sử dụng thư viện Cobra để quản lý command. Bài viết này sẽ hướng dẫn bạn xây dựng một CLI tool hoàn chỉnh bằng Go và Cobra: từ khởi tạo dự án, flag parsing, tích hợp cấu hình Viper, cho đến build binary tối ưu và đóng gói Docker multi-stage.
Vì sao nên dùng Go để viết CLI tool?
Trước khi đi vào code, cần hiểu vì sao Go là lựa chọn số một cho CLI tool. Thứ nhất, Go biên dịch ra single binary — không cần runtime, không cần dependency, chỉ cần copy file là chạy được. Điều này cực kỳ quan trọng khi bạn phải deploy tool lên nhiều server production không có internet. Thứ hai, Go hỗ trợ cross-compile cực kỳ đơn giản: từ một máy Mac, bạn có thể build ra binary cho Linux amd64, Linux arm64, Windows chỉ bằng cách set biến môi trường. Cuối cùng, thư viện spf13/cobra đã trở thành standard de facto cho CLI trong hệ sinh thái Go, cung cấp sẵn subcommand, flag parsing, shell completion và man page generation.
Trong quá trình triển khai CI/CD tự động cho các dự án WordPress tại Code Tốt, chúng tôi đã viết một CLI tool nội bộ bằng Go + Cobra để thay thế hàng loạt script bash rời rạc. Tool này xử lý các tác vụ lặp lại như kiểm tra trạng thái hàng loạt website, đồng bộ cấu hình giữa các server, và tự động rollback bản deploy lỗi. Kết quả là giảm thời gian maintenance từ 45 phút xuống còn khoảng 7 phút cho toàn bộ fleet — một minh chứng rõ ràng cho sức mạnh của CLI tool viết bằng Go.
Khởi tạo dự án với Cobra CLI
Bước đầu tiên là khởi tạo module Go và cài đặt cobra-cli — công cụ sinh code tự động giúp tạo cấu trúc command chuẩn chỉ trong vài giây:
mkdir gotool && cd gotool
go mod init github.com/codetot/gotool
# Cài cobra-cli (bộ sinh code)
go install github.com/spf13/cobra-cli@latest
# Khởi tạo project với support Viper
cobra-cli init --author "Khoi Pro" --viper
# Thêm subcommand
cobra-cli add check
cobra-cli add version
Sau khi chạy xong, cấu trúc thư mục sẽ như sau:
gotool/
├── cmd/
│ ├── check.go # subcommand check
│ ├── root.go # root command + persistent flags
│ └── version.go # subcommand version
├── main.go # entry point
├── go.mod
└── go.sum
Hãy chú ý: cobra-cli init đã tự động tạo sẵn main.go gọi cmd.Execute() và file root.go với persistent flag --config mặc định. Đây là điểm khác biệt lớn so với viết CLI bằng script — toàn bộ cấu trúc command được chuẩn hoá ngay từ đầu, dễ dàng thêm subcommand mới mà không phá vỡ command hiện có.
Cấu trúc command và flag trong Cobra
Trong Cobra, mỗi command là một struct cobra.Command. Root command là nơi định nghĩa tên tool, mô tả ngắn gọn và các persistent flag — flag dùng chung cho tất cả subcommand:
// cmd/root.go
var rootCmd = &cobra.Command{
Use: "gotool",
Short: "Công cụ DevOps nội bộ của Code Tốt",
Long: `gotool là CLI tool giúp tự động hoá các tác vụ
maintenance website hàng loạt: kiểm tra health, đồng bộ
cấu hình và deploy an toàn.`,
// RunE thay vì Run để trả về error đúng cách
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Persistent flag — dùng chung cho mọi subcommand
var cfgFile string
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Đường dẫn file config (mặc định: $HOME/.gotool.yaml)")
}
Có hai loại flag trong Cobra bạn cần phân biệt rõ:
- Persistent flag (
PersistentFlags()): flag kế thừa bởi tất cả subcommand. Ví dụ--confignên là persistent vì mọi subcommand đều cần đọc config. - Local flag (
Flags()): chỉ áp dụng cho command hiện tại. Ví dụ--urlchỉ có ý nghĩa với subcommandcheck, không nên “rò rỉ” sang command khác.
Quy tắc vàng: nếu flag chỉ phục vụ một subcommand, hãy dùng local flag. Persistent flag chỉ dành cho những thứ thực sự dùng chung (config path, log level, verbose). Dùng sai loại flag sẽ làm CLI của bạn rối rắm và gây khó hiểu cho người dùng.
Flag parsing nâng cao: required flag, shorthand và validation
Bây giờ chúng ta xây dựng subcommand check — một tool kiểm tra trạng thái HTTP của endpoint, rất hữu ích cho việc health check hàng loạt website. Đây là nơi thể hiện flag parsing nâng cao của Cobra:
// cmd/check.go
package cmd
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/spf13/cobra"
)
var (
checkURL string
checkTimeout time.Duration
checkVerbose bool
)
var checkCmd = &cobra.Command{
Use: "check",
Short: "Kiểm tra trạng thái HTTP của endpoint",
RunE: func(cmd *cobra.Command, args []string) error {
if checkURL == "" {
return errors.New("thiếu flag --url (bắt buộc)")
}
client := &http.Client{Timeout: checkTimeout}
start := time.Now()
resp, err := client.Get(checkURL)
if err != nil {
return fmt.Errorf("không thể kết nối %s: %w", checkURL, err)
}
defer resp.Body.Close()
latency := time.Since(start)
fmt.Printf("%s - status=%d latency=%s\n", checkURL, resp.StatusCode, latency)
if checkVerbose {
fmt.Printf("Headers: %v\n", resp.Header)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("endpoint trả về lỗi: %d", resp.StatusCode)
}
return nil
},
}
func init() {
rootCmd.AddCommand(checkCmd)
// Local flag — chỉ tồn tại trong command check
checkCmd.Flags().StringVar(&checkURL, "url", "", "URL cần kiểm tra (bắt buộc)")
checkCmd.Flags().DurationVar(&checkTimeout, "timeout", 5*time.Second, "Thời gian chờ tối đa")
checkCmd.Flags().BoolVarP(&checkVerbose, "verbose", "v", false, "In chi tiết headers")
// Cobra tự động validate flag bắt buộc
_ = checkCmd.MarkFlagRequired("url")
}
Một số kỹ thuật đáng chú ý trong đoạn code trên:
RunEthay vìRun: trả vềerrorthay vì tự gọios.Exit(). Khi đó lỗi được xử lý tập trung ởmain.go, giúp test dễ dàng hơn và đảm bảo exit code chính xác.MarkFlagRequired(): Cobra tự động kiểm tra và báo lỗi “required flag(s) url not set” khi người dùng quên — không cần tự viết logic kiểm tra thủ công.BoolVarPvới shorthand: chữPtrong tên hàm nghĩa là hỗ trợ shorthand flag, tức người dùng có thể gõ-vthay vì--verbose. Tương tựStringVarP,IntVarP,DurationVarP.- Wrap error với
%w: giữ nguyên error gốc để có thể dùngerrors.Is()/errors.As()ở tầng xử lý, phục vụ việc phân loại lỗi khi tự động hoá.
Chạy thử với lệnh sau — lưu ý cách Cobra tự sinh help text có cấu trúc chuẩn POSIX:
go run . check --url https://chuyendev.com --timeout 10s -v
# Kết quả: https://chuyendev.com - status=200 latency=312ms
# Headers: map[Content-Type:[text/html; charset=UTF-8] ...]
go run . check
# Lỗi: required flag(s) "url" not set
go run . check --help
# Hiển thị usage với đầy đủ flag, mô tả và shorthand
Quản lý cấu hình với Viper
Một CLI tool chuyên nghiệp không thể thiếu cấu hình linh hoạt. Thư viện spf13/viper — cũng từ tác giả của Cobra — cho phép đọc cấu hình từ nhiều nguồn với thứ tự ưu tiên: flag > biến môi trường > file config > default value. Điều này có nghĩa người dùng có thể override config file bằng env var, và override env var bằng flag trực tiếp:
// cmd/root.go — cấu hình Viper
func initConfig() {
if cfgFile != "" {
// Dùng file config được chỉ định qua flag --config
viper.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
viper.AddConfigPath(home)
viper.SetConfigName(".gotool")
}
// Đọc biến môi trường prefix GOTOOL_
viper.SetEnvPrefix("GOTOOL")
viper.AutomaticEnv()
// Gán default value
viper.SetDefault("check.timeout", "5s")
viper.SetDefault("check.retries", 3)
if err := viper.ReadInConfig(); err != nil {
// File config không bắt buộc phải tồn tại
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintln(os.Stderr, "Lỗi đọc config:", err)
os.Exit(1)
}
}
}
File config .gotool.yaml ví dụ:
# ~/.gotool.yaml
check:
timeout: 10s
retries: 5
notify_slack: true
deploy:
backup_before: true
healthcheck_url: https://chuyendev.com/wp-json/health
Với Viper, trong subcommand check bạn chỉ cần gọi viper.GetDuration("check.timeout") để lấy giá trị đã được merge từ mọi nguồn. Nếu muốn override, người dùng chỉ cần chạy GOTOOL_CHECK_TIMEOUT=30s gotool check --url ... mà không cần sửa file config — pattern này đặc biệt hữu ích khi chạy tool trong CI/CD pipeline với các giá trị khác nhau giữa các môi trường staging và production.
Build binary tối ưu: ldflags, version injection và cross-compile
Một CLI tool đáng tin cậy phải hiển thị được version và commit hash — điều này cực kỳ quan trọng khi debug sự cố trên production. Kỹ thuật phổ biến là inject thông tin vào lúc build bằng ldflags. Đầu tiên, khai báo biến version trong main.go:
// main.go
package main
import (
"fmt"
"os"
"github.com/codetot/gotool/cmd"
)
// Các biến này được inject lúc build qua -ldflags
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
cmd.SetVersion(version, commit, date)
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "Lỗi:", err)
os.Exit(1)
}
}
// cmd/root.go — hiển thị version qua lệnh --version
func SetVersion(v, c, d string) {
rootCmd.Version = v
rootCmd.SetVersionTemplate(fmt.Sprintf("gotool version %s (commit %s, built %s)\n", v, c, d))
}
Bây giờ tạo Makefile để build binary với đầy đủ thông tin và hỗ trợ cross-compile — đây là phần “real automation” giúp CI/CD pipeline của bạn build một lần, chạy mọi nơi:
# Makefile
VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w \
-X main.version=$(VERSION) \
-X main.commit=$(COMMIT) \
-X main.date=$(DATE)
BINARY := gotool
.PHONY: build build-linux build-linux-arm build-all clean
build: ## Build binary cho OS hiện tại
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) .
build-linux: ## Cross-compile cho Linux amd64 (server production)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 .
build-linux-arm: ## Cross-compile cho Linux arm64 (Raspberry Pi, ARM server)
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-arm64 .
build-all: build build-linux build-linux-arm ## Build toàn bộ
clean:
rm -rf bin/
Giải thích các flag quan trọng:
-s -w: strip symbol table và DWARF debug info, giảm kích thước binary đáng kể (thường từ ~10MB xuống ~7MB). Chỉ bỏ qua nếu bạn cần debug binary production bằngdlv.CGO_ENABLED=0: tắt CGO để tạo binary tĩnh hoàn toàn — không phụ thuộc glibc, chạy được trên mọi bản phân phối Linux. Đây là yêu cầu bắt buộc nếu bạn muốn chạy binary trên Alpine hoặc container distroless.GOOS/GOARCH: target platform.GOOS=linux GOARCH=amd64là chuẩn cho server production;arm64dành cho các VPS ARM giá rẻ hoặc Raspberry Pi self-host.
Kiểm tra kết quả build:
make build-linux
file bin/gotool-linux-amd64
# bin/gotool-linux-amd64: ELF 64-bit LSB executable, x86-64, statically linked
./bin/gotool-linux-amd64 --version
# gotool version v1.2.3 (commit a1b2c3d, built 2026-08-10T09:00:00Z)
Đóng gói CLI tool với Docker multi-stage
Khi CLI tool của bạn đã ổn định, bước tiếp theo là đóng gói thành container để chạy trong CI/CD pipeline hoặc cron job trên server. Nhờ binary tĩnh của Go, Docker image có thể cực kỳ nhỏ gọn — chỉ vài MB thay vì hàng trăm MB như image chứa runtime:
# Dockerfile
# Stage 1: build binary
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Cache layer dependencies riêng — build nhanh hơn khi code thay đổi
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o /gotool .
# Stage 2: runtime tối giản với distroless
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /gotool /usr/local/bin/gotool
ENTRYPOINT ["gotool"]
Điểm mạnh của multi-stage build nằm ở chỗ image cuối chỉ chứa binary và không có trình biên dịch, không có source code, không có package manager. Base image distroless chạy với user nonroot (không phải root) và không có shell — bề mặt tấn công gần như bằng không. Kết quả là image thường chỉ khoảng 10-15MB so với ~1GB nếu bạn build theo cách thông thường.
Sau khi đóng gói container, đừng quên định nghĩa health check cho container. Bạn có thể tận dụng chính subcommand check của tool làm healthcheck — vừa kiểm tra container sống, vừa kiểm tra nghiệp vụ bên trong. Xem thêm cách triển khai chi tiết trong bài Docker HEALTHCHECK: Hướng dẫn kiểm tra sức khỏe container và tự động phục hồi.
# docker-compose.yml
services:
healthbot:
build: .
command: ["check", "--url", "https://chuyendev.com/wp-json/health", "--timeout", "10s"]
restart: unless-stopped
environment:
GOTOOL_CHECK_TIMEOUT: 10s
GOTOOL_CHECK_RETRIES: 5
Shell completion miễn phí
Một lợi ích lớn khi dùng Cobra mà ít người biết: shell completion được sinh tự động cho bash, zsh, fish và PowerShell — không cần viết một dòng code nào. Người dùng chỉ cần chạy:
# Bash
gotool completion bash | sudo tee /etc/bash_completion.d/gotool
# Zsh
gotool completion zsh | sudo tee /usr/local/share/zsh/site-functions/_gotool
# Fish
gotool completion fish | sudo tee /etc/fish/completions/gotool.fish
Sau khi kích hoạt, người dùng có thể gõ gotool ch[Tab] để tự động hoàn thành check, hoặc gotool check --[Tab] để xem danh sách flag. Trải nghiệm này khiến CLI tool của bạn “cảm giác” chuyên nghiệp như kubectl hay gh ngay từ ngày đầu ra mắt.
Kết luận
Bài viết đã trình bày toàn bộ quy trình xây dựng một CLI tool production-ready bằng Go và Cobra: khởi tạo dự án với cobra-cli, phân biệt persistent flag và local flag, flag parsing nâng cao với MarkFlagRequired, tích hợp cấu hình Viper theo thứ tự ưu tiên flag > env > file, build binary tối ưu với ldflags và cross-compile, đóng gói Docker multi-stage với image chỉ ~10MB, và shell completion miễn phí.
Theo kinh nghiệm từ dự án DevOps tại Code Tốt, việc chuẩn hoá các tác vụ maintenance thành CLI tool không chỉ giảm thời gian thao tác thủ công mà còn giúp toàn bộ quy trình trở nên tái sử dụng được, test được và an toàn hơn — mọi thao tác nguy hiểm đều phải đi qua code review thay vì gõ tay trên server. Nếu bạn quan tâm đến việc tự động hoá quy trình deploy WordPress, đừng bỏ qua bài Xây dựng CI/CD Pipeline cho WordPress với GitHub Actions và RunCloud — kết hợp với CLI tool Go, bạn sẽ có một pipeline hoàn chỉnh từ commit đến production.