Skip to content

容器化 Go 应用最佳实践

本篇是 Docker + Go 系列教程的第四篇。前三篇我们解决了「怎么把 Go 应用容器化」和「怎么编排多服务」的问题,本篇聚焦于「怎么把容器化做对、做好」。我们将围绕十二要素应用原则、配置管理、日志处理、信号处理与优雅关停、健康检查、安全加固等生产级话题展开,最终给出一个综合最佳实践的生产级示例。这些实践是区分「能跑」和「能在生产稳定跑」的关键。

一、容器化十二要素应用原则

十二要素应用(The Twelve-Factor App)是 Heroku 团队总结的 SaaS 应用方法论,是云原生应用的事实标准。容器化应用应当遵循这些原则。

1. 与容器化相关的核心要素

要素原则容器化体现
配置(Config)配置存在环境变量中,不入代码-e / env_file 注入
依赖(Deps)显式声明依赖Dockerfile 中声明
后端服务把外部服务(DB、MQ)视为可替换资源通过环境变量连接,不绑死
进程应用是无状态进程状态放外部(DB、Redis)
端口绑定应用自身监听端口提供服务EXPOSE + 端口映射
并发通过进程/线程/协程并发水平扩展容器实例
易处理可快速启动、优雅停止毫秒级启动,捕获 SIGTERM
日志输出到 stdout/stderr不写文件,由 Docker 收集
管理任务一次性管理任务用相同环境运行docker exec 执行迁移

2. 容器化最容易踩的坑

很多开发者把传统部署的思维带到容器化,常见错误:

  • 配置写死在代码或镜像里:导致不同环境要构建不同镜像。
  • 日志写到容器内文件:容器删除后日志丢失,且 Docker 无法收集。
  • 不处理信号:容器停止时应用被 SIGKILL 强杀,请求中断、数据丢失。
  • 状态存容器内:容器是无状态的,重启就没了。
  • 用 root 运行:安全风险,一旦逃逸直接是 root 权限。
  • 依赖宿主机路径/data/app/config.json 这种路径在容器里不存在。

本篇后续小节会逐一解决这些问题。

二、配置管理

1. 配置从环境变量读取(12-Factor 第一原则)

错误做法:把数据库连接、密钥写死在代码或配置文件里,然后打进镜像。

go
// 错误:硬编码
const dbDSN = "root:password@tcp(production-db:3306)/app"

这样每个环境都要构建一个镜像,违背了「一次构建,到处部署」。

正确做法:从环境变量读取。

go
package main

import (
	"fmt"
	"os"
)

type Config struct {
	Port      string
	DBHost    string
	DBPort    string
	DBUser    string
	DBPass    string
	DBName    string
	RedisHost string
	RedisPort string
	JWTSecret string
	LogLevel  string
}

func LoadConfig() *Config {
	return &Config{
		Port:      getEnv("APP_PORT", "8080"),
		DBHost:    getEnv("DB_HOST", "localhost"),
		DBPort:    getEnv("DB_PORT", "3306"),
		DBUser:    getEnv("DB_USER", "root"),
		DBPass:    getEnv("DB_PASSWORD", ""),
		DBName:    getEnv("DB_NAME", "app"),
		RedisHost: getEnv("REDIS_HOST", "localhost"),
		RedisPort: getEnv("REDIS_PORT", "6379"),
		JWTSecret: getEnv("JWT_SECRET", ""),  // 必须注入,不应有默认值
		LogLevel:  getEnv("LOG_LEVEL", "info"),
	}
}

func getEnv(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func main() {
	cfg := LoadConfig()
	fmt.Printf("Starting on port %s, db=%s:%s\n", cfg.Port, cfg.DBHost, cfg.Port)
}

运行时通过 -e 注入:

bash
docker run -e DB_HOST=mysql -e DB_PASSWORD=secret -e JWT_SECRET=xxx myapp

2. 使用 Viper 从环境读取配置

复杂项目用 Viper 统一管理配置,支持环境变量、配置文件、命令行参数多种来源。

go
package main

import (
	"fmt"
	"log"

	"github.com/spf13/viper"
)

type Config struct {
	App struct {
		Port string `mapstructure:"port"`
		Name string `mapstructure:"name"`
	} `mapstructure:"app"`
	DB struct {
		Host     string `mapstructure:"host"`
		Port     int    `mapstructure:"port"`
		User     string `mapstructure:"user"`
		Password string `mapstructure:"password"`
		Name     string `mapstructure:"name"`
	} `mapstructure:"db"`
	Redis struct {
		Host string `mapstructure:"host"`
		Port int    `mapstructure:"port"`
	} `mapstructure:"redis"`
}

func LoadConfig() (*Config, error) {
	v := viper.New()

	// 默认值
	v.SetDefault("app.port", "8080")
	v.SetDefault("app.name", "myapp")
	v.SetDefault("db.host", "localhost")
	v.SetDefault("db.port", 3306)

	// 从配置文件读取(如果存在)
	v.SetConfigName("config")
	v.SetConfigType("yaml")
	v.AddConfigPath(".")
	v.AddConfigPath("/app/config")
	if err := v.ReadInConfig(); err != nil {
		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
			return nil, fmt.Errorf("read config: %w", err)
		}
		// 配置文件不存在是正常的,继续用环境变量
	}

	// 环境变量覆盖(优先级最高)
	v.AutomaticEnv()
	v.SetEnvPrefix("APP")
	v.SetEnvKeyReplacer(nil)

	// 显式绑定关键变量
	_ = v.BindEnv("db.host", "DB_HOST")
	_ = v.BindEnv("db.port", "DB_PORT")
	_ = v.BindEnv("db.user", "DB_USER")
	_ = v.BindEnv("db.password", "DB_PASSWORD")
	_ = v.BindEnv("db.name", "DB_NAME")
	_ = v.BindEnv("redis.host", "REDIS_HOST")
	_ = v.BindEnv("redis.port", "REDIS_PORT")
	_ = v.BindEnv("app.port", "APP_PORT")

	var cfg Config
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, fmt.Errorf("unmarshal: %w", err)
	}
	return &cfg, nil
}

func main() {
	cfg, err := LoadConfig()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("App: %s on port %s\n", cfg.App.Name, cfg.App.Port)
	fmt.Printf("DB: %s:%d\n", cfg.DB.Host, cfg.DB.Port)
}

3. 配置优先级建议

推荐的优先级(从高到低):

  1. 命令行参数(--port=9090
  2. 环境变量(PORT=9090
  3. 配置文件(config.yaml
  4. 默认值

环境变量是容器化场景的主战场,因为它最适合 Docker/Kubernetes 注入。

三、日志处理

1. 输出到 stdout/stderr

核心原则:应用只管把日志写到 stdout/stderr,不负责日志的存储、轮转、聚合。这些工作交给 Docker 或外部日志系统(ELK、Loki、Fluentd)。

错误做法

go
// 错误:写文件
file, _ := os.OpenFile("/var/log/app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
log.SetOutput(file)
log.Println("something happened")

为什么错?

  • 容器删除后日志丢失。
  • 日志文件会无限增长,撑爆容器存储。
  • Docker 看不到这些日志,docker logs 命令失效。
  • 无法被日志收集系统统一处理。

正确做法

go
log.SetOutput(os.Stdout)  // 默认就是 stdout,这里只是强调
log.Println("something happened")

2. JSON 结构化日志

生产环境推荐 JSON 格式日志,便于机器解析和查询。用 zapslog(Go 1.21+ 标准库)。

下面是用 Go 1.21+ 标准库 slog 的例子:

go
package main

import (
	"log/slog"
	"net/http"
	"os"
	"time"
)

func main() {
	// JSON 格式输出到 stdout
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
	slog.SetDefault(logger)

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		// 业务逻辑
		w.Write([]byte("hello"))
		// 结构化日志
		slog.Info("request handled",
			"method", r.Method,
			"path", r.URL.Path,
			"remote", r.RemoteAddr,
			"duration_ms", time.Since(start).Milliseconds(),
		)
	})

	slog.Info("server starting", "port", "8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		slog.Error("server failed", "error", err)
		os.Exit(1)
	}
}

日志输出示例(每行一个 JSON):

json
{"time":"2026-08-01T10:00:00.123Z","level":"INFO","msg":"server starting","port":"8080"}
{"time":"2026-08-01T10:00:01.456Z","level":"INFO","msg":"request handled","method":"GET","path":"/","remote":"127.0.0.1:54321","duration_ms":2}

Docker 会自动收集 stdout/stderr 的输出,docker logs 即可查看:

bash
docker logs -f myapp

3. 日志不要包含敏感信息

注意不要把密码、token、身份证号等输出到日志。日志最终会进日志系统,可能被很多人看到。

go
// 错误
slog.Info("user login", "password", password)

// 正确
slog.Info("user login", "user_id", userID)

四、信号处理与优雅关停

这是容器化应用最容易被忽视、又最重要的实践之一。

1. 容器停止时发生了什么

当执行 docker stop 或编排系统(Kubernetes、Docker Swarm)要停止容器时,流程是:

  1. Docker 向容器主进程(PID 1)发送 SIGTERM 信号。
  2. 等待一段时间(默认 10 秒,可用 --stop-grace-period 调整)。
  3. 如果进程还没退出,发送 SIGKILL 强制杀死。

如果应用不处理 SIGTERM,会发生什么?

  • 正在处理的请求被中断,用户看到 502。
  • 数据库连接没正常关闭,可能留下脏数据。
  • 文件没 flush,写入丢失。
  • 与外部服务的会话没清理。

优雅关停的目标:收到 SIGTERM 后,停止接收新请求,等已有请求处理完,清理资源,然后退出。

2. Go 1.22 优雅关停实现

下面是一个完整的 HTTP 服务优雅关停实现:

go
package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	slog.SetDefault(logger)

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// 模拟慢请求
		time.Sleep(2 * time.Second)
		w.Write([]byte("hello"))
	})
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	// 在 goroutine 中启动服务器
	go func() {
		slog.Info("server starting", "addr", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server failed", "error", err)
			os.Exit(1)
		}
	}()

	// 等待中断信号
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	sig := <-quit
	slog.Info("received shutdown signal", "signal", sig.String())

	// 给现有请求 30 秒处理时间
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// 优雅关停
	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("server shutdown error", "error", err)
	}

	// 这里可以继续清理其他资源:关闭 DB 连接、flush 缓存等
	slog.Info("server exited gracefully")
}

关键点:

  • signal.Notify 注册 SIGINTSIGTERM 处理。
  • 收到信号后调用 srv.Shutdown(ctx),它会:
    • 关闭监听 socket,不再接受新连接。
    • 等待已有请求处理完。
    • 超过 ctx 的 deadline 强制关闭。
  • Shutdown 之前应该先把服务从负载均衡摘除(通过健康检查端点返回失败实现)。

3. PID 1 问题

容器内主进程是 PID 1,Linux 内核对 PID 1 有特殊处理:它不会响应默认的信号处理。如果直接用裸二进制作为 PID 1,某些信号可能不会被转发给子进程。

Go 编译的二进制作为 PID 1 通常没问题(Go runtime 会正确处理信号),但如果你的容器里有多个进程(不推荐),或用 shell 脚本包装启动,可能需要用 tini 作为 init 进程:

dockerfile
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/server"]

最佳实践是:容器里只跑一个进程,让 Go 二进制直接作为 PID 1。

4. 健康检查端点配合优雅关停

优雅关停的完整流程需要健康检查配合:

  1. 收到 SIGTERM
  2. 立即让 /health 返回失败(让负载均衡摘除流量)。
  3. 等几秒让负载均衡感知。
  4. 停止接受新请求。
  5. 等已有请求完成。
  6. 清理资源退出。
go
package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"
)

var healthy atomic.Bool

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	slog.SetDefault(logger)

	healthy.Store(true)

	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		if healthy.Load() {
			w.WriteHeader(http.StatusOK)
			w.Write([]byte("ok"))
		} else {
			w.WriteHeader(http.StatusServiceUnavailable)
			w.Write([]byte("shutting down"))
		}
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(2 * time.Second)
		w.Write([]byte("hello"))
	})

	srv := &http.Server{Addr: ":8080", Handler: mux}

	go func() {
		slog.Info("server starting")
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server failed", "error", err)
			os.Exit(1)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	slog.Info("shutting down, marking unhealthy")
	healthy.Store(false)

	// 给负载均衡感知的时间
	time.Sleep(5 * time.Second)

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("shutdown error", "error", err)
	}
	slog.Info("server exited")
}

五、健康检查:HEALTHCHECK 指令

1. Dockerfile 中的 HEALTHCHECK

HEALTHCHECK 让 Docker 知道容器是否健康,编排系统会根据健康状态决定是否重启或路由流量。

dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost:8080/health || exit 1

参数:

  • --interval:检查间隔,默认 30s。
  • --timeout:单次检查超时,默认 30s。
  • --start-period:启动宽限期,这段时间内失败不计入 retries,默认 0。
  • --retries:连续失败多少次算 unhealthy,默认 3。

健康状态有三种:startinghealthy / unhealthy

bash
docker ps
# STATUS 列会显示 (healthy) 或 (unhealthy)

2. 在 docker-compose.yml 中配置

yaml
services:
  app:
    build: .
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s

3. 用 curl 替代 wget

alpine 默认没有 curl,但 wget 是 busybox 自带的。如果用 distroless(既没 wget 也没 curl),可以编译一个小程序做健康检查,或用 Go 二进制内的命令:

dockerfile
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["/app/server", "healthcheck"]

在 Go 程序里加一个子命令:

go
package main

import (
	"net/http"
	"os"
)

func main() {
	if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
		resp, err := http.Get("http://localhost:8080/health")
		if err != nil || resp.StatusCode != 200 {
			os.Exit(1)
		}
		os.Exit(0)
	}
	// 正常启动服务...
}

六、非 root 用户运行

默认容器以 root 运行,这有安全风险。如果攻击者利用漏洞逃逸到宿主机,直接是 root 权限。

1. Dockerfile 中创建用户

dockerfile
FROM alpine:3.19

# 创建非 root 用户
RUN addgroup -S app && adduser -S app -G app

WORKDIR /app
COPY --from=builder /build/server /app/server

# 切换用户
USER app

ENTRYPOINT ["/app/server"]

注意:USER app 后,应用对文件系统的写权限受限。如果应用需要写临时文件,应该用专用目录或 tmpfs。

2. 端口问题

Linux 上非 root 用户不能监听 1024 以下端口。所以应用应监听 8080 这类高端口,而不是 80。如果必须用 80,可以用 setcap 给二进制赋能:

dockerfile
RUN setcap cap_net_bind_service=+ep /app/server

3. distroless 的 nonroot 变体

distroless 提供了 :nonroot 标签,内置非 root 用户:

dockerfile
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

七、时区处理

容器默认时区是 UTC。如果你的业务涉及「今天」「每天 0 点」这种逻辑,时区不对会导致严重 bug。

1. alpine 镜像

dockerfile
RUN apk --no-cache add tzdata
ENV TZ=Asia/Shanghai

2. distroless 镜像

distroless 的 -debian12 变体自带 tzdata:

dockerfile
ENV TZ=Asia/Shanghai

3. scratch 镜像

scratch 没有时区数据,需要从 builder 拷贝:

dockerfile
FROM golang:1.22 AS builder
# ... 编译 ...

FROM scratch
COPY --from=builder /build/server /server
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
ENV TZ=Asia/Shanghai
ENTRYPOINT ["/server"]

4. Go 代码中处理时区

Go 1.15+ 可以通过 import _ "time/tzdata" 把时区数据嵌入二进制,这样即使镜像里没有 tzdata 也能用:

go
package main

import (
	"fmt"
	"time"
	_ "time/tzdata"  // 嵌入时区数据
)

func main() {
	loc, _ := time.LoadLocation("Asia/Shanghai")
	fmt.Println(time.Now().In(loc))
}

这样会增加约 450KB 的二进制体积,但换来时区无关的部署,推荐使用。

八、资源限制

1. docker run 限制

bash
docker run \
  --memory=512m \
  --memory-swap=512m \
  --cpus=1 \
  --pids-limit=100 \
  myapp
  • --memory:内存上限。
  • --memory-swap:交换空间上限(设为与 memory 相同表示禁用 swap)。
  • --cpus:CPU 核数上限。
  • --pids-limit:进程数上限,防 fork 炸弹。

2. docker-compose.yml 限制

yaml
services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

limits 是硬上限,reservations 是软保证。

3. Go 应用的内存建议

Go 应用的内存包括:

  • Go runtime 自身(几十 MB)
  • 堆内存(业务数据)
  • goroutine 栈
  • GC 需要的额外空间

设置内存限制时,必须同时设置 GOMEMLIMIT(Go 1.19+),让 Go GC 感知限制,主动回收:

dockerfile
ENV GOMEMLIMIT=450MiB

GOMEMLIMIT 应设置为容器内存限制的 80~90%,留一些给 runtime 和操作系统。

bash
docker run --memory=512m -e GOMEMLIMIT=450MiB myapp

否则 Go 看不到容器内存限制,会按宿主机内存设定 GC 阈值,导致容器 OOM 被杀。

九、容器内文件系统:只读

1. 只读文件系统

bash
docker run --read-only myapp

--read-only 让容器根文件系统只读,攻击者无法植入恶意文件。应用需要写的目录(如 /tmp)用 --tmpfs 挂载:

bash
docker run --read-only --tmpfs /tmp myapp

2. docker-compose.yml

yaml
services:
  app:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp
      - /app/cache

3. Go 应用的注意事项

如果应用需要写文件(日志、缓存、上传),改成:

  • 日志:写 stdout。
  • 缓存:用 Redis 或内存。
  • 上传:写到对象存储(S3、OSS)。
  • 临时文件:用 /tmp(tmpfs 挂载)。

如果某些场景必须写本地文件,单独挂载一个数据卷到那个目录,其他部分保持只读。

十、安全加固

1. no-new-privileges

防止容器内进程通过 setuid 等机制提权:

bash
docker run --security-opt no-new-privileges myapp

2. seccomp

seccomp 限制容器可用的系统调用。Docker 默认应用一个 seccomp profile,可以进一步收紧:

bash
docker run --security-opt seccomp=custom-profile.json myapp

3. capability 裁剪

Docker 默认给容器少量 capability,可以全部丢弃再按需添加:

bash
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myapp

4. 综合安全配置

docker-compose.yml 的安全加固示例:

yaml
services:
  app:
    image: myapp
    user: "1000:1000"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

5. 镜像层面的安全

  • 用非 root 用户(前面讲过)。
  • 用 distroless 或 alpine 减小攻击面。
  • 定期用 trivy 扫描漏洞。
  • 及时更新基础镜像和依赖。
  • 不要在镜像里塞密钥(用环境变量或 secret 管理)。

十一、完整示例:生产级容器化 Go 应用

下面给出一个综合所有最佳实践的生产级示例。

1. main.go(完整可运行)

go
package main

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"
	_ "time/tzdata"
)

var (
	version   = "dev"
	commit    = "none"
	buildTime = "unknown"
)

var healthy atomic.Bool

type Response struct {
	Message string `json:"message"`
	Version string `json:"version"`
}

func newLogger() *slog.Logger {
	level := slog.LevelInfo
	if l := os.Getenv("LOG_LEVEL"); l == "debug" {
		level = slog.LevelDebug
	}
	return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
}

func main() {
	logger := newLogger()
	slog.SetDefault(logger)

	port := os.Getenv("APP_PORT")
	if port == "" {
		port = "8080"
	}

	healthy.Store(true)

	mux := http.NewServeMux()

	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		if !healthy.Load() {
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
	})

	mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]string{
			"version":    version,
			"commit":     commit,
			"build_time": buildTime,
			"go_version": "go1.22",
		})
	})

	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		time.Sleep(100 * time.Millisecond) // 模拟业务
		json.NewEncoder(w).Encode(Response{
			Message: "Hello",
			Version: version,
		})
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"duration_ms", time.Since(start).Milliseconds(),
		)
	})

	srv := &http.Server{
		Addr:         ":" + port,
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	go func() {
		slog.Info("server starting",
			"port", port,
			"version", version,
			"commit", commit,
		)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server failed", "error", err)
			os.Exit(1)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	sig := <-quit
	slog.Info("shutdown signal received", "signal", sig.String())

	healthy.Store(false)
	time.Sleep(3 * time.Second) // 等负载均衡感知

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("shutdown error", "error", err)
	}
	slog.Info("server exited gracefully")
}

2. Dockerfile

dockerfile
# syntax=docker/dockerfile:1

FROM golang:1.22-alpine AS builder

ENV CGO_ENABLED=0 GOOS=linux
ENV GOPROXY=https://goproxy.cn,direct

WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download && go mod verify

COPY . .

ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_TIME=unknown

RUN go build \
    -ldflags="-s -w \
      -X main.version=${VERSION} \
      -X main.commit=${COMMIT} \
      -X main.buildTime=${BUILD_TIME}" \
    -o server \
    .

FROM alpine:3.19

RUN apk --no-cache add ca-certificates tzdata wget && \
    addgroup -S app && adduser -S app -G app && \
    rm -rf /var/cache/apk/*

WORKDIR /app

COPY --from=builder /build/server /app/server

ENV TZ=Asia/Shanghai \
    GOMEMLIMIT=450MiB

USER app

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:8080/health || exit 1

ENTRYPOINT ["/app/server"]

3. docker-compose.yml

yaml
services:
  app:
    build:
      context: .
      args:
        VERSION: ${VERSION:-dev}
        COMMIT: ${COMMIT:-none}
        BUILD_TIME: ${BUILD_TIME:-unknown}
    image: myapp:${VERSION:-latest}
    ports:
      - "${APP_PORT:-8080}:8080"
    environment:
      APP_PORT: "8080"
      LOG_LEVEL: ${LOG_LEVEL:-info}
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
    stop_grace_period: 40s
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 15s
      timeout: 3s
      retries: 3
      start_period: 10s

4. 构建与验证

bash
# 构建
VERSION=v1.0.0 COMMIT=$(git rev-parse --short HEAD) \
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
docker compose build

# 启动
docker compose up -d

# 验证健康
docker compose ps
curl http://localhost:8080/health

# 验证版本
curl http://localhost:8080/version

# 测试优雅关停
time docker compose stop app
# 应该看到日志: "shutdown signal received" -> "server exited gracefully"
docker compose logs app | tail -5

5. 验证安全配置

bash
# 验证非 root 用户
docker compose exec app id
# uid=100(app) gid=101(app)

# 验证只读文件系统
docker compose exec app sh -c "touch /app/test"
# touch: /app/test: Read-only file system

# 验证 capability 已丢弃
docker compose exec app cat /proc/1/status | grep CapEff
# CapEff: 0000000000000000  (空,没有任何 capability)

十二、小结

本篇是容器化 Go 应用最核心的实践篇。

关键要点:

  1. 十二要素原则:配置走环境变量、日志走 stdout、无状态进程。
  2. 配置管理os.Getenv 或 Viper,优先级:命令行 > 环境变量 > 配置文件 > 默认值。
  3. 日志处理:输出到 stdout/stderr,JSON 结构化,不含敏感信息。
  4. 优雅关停:捕获 SIGTERM,标记不健康,等流量摘除,Shutdown 等请求完成。
  5. 健康检查:Dockerfile HEALTHCHECK + 应用 /health 端点。
  6. 非 root 运行:创建专用用户,USER 切换。
  7. 时区:安装 tzdata 或 import _ "time/tzdata"
  8. 资源限制:memory + cpus limits,配合 GOMEMLIMIT
  9. 只读文件系统read_only: true + tmpfs。
  10. 安全加固:no-new-privileges、cap_drop ALL、最小镜像、漏洞扫描。

下一篇也是本系列最后一篇,我们将进入 CI/CD 流水线与镜像发布,把前面构建的镜像通过 GitHub Actions、GitLab CI 自动化构建、扫描、签名、发布到 Registry,并介绍部署策略。