Appearance
容器化 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 myapp2. 使用 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. 配置优先级建议
推荐的优先级(从高到低):
- 命令行参数(
--port=9090) - 环境变量(
PORT=9090) - 配置文件(
config.yaml) - 默认值
环境变量是容器化场景的主战场,因为它最适合 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 格式日志,便于机器解析和查询。用 zap 或 slog(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 myapp3. 日志不要包含敏感信息
注意不要把密码、token、身份证号等输出到日志。日志最终会进日志系统,可能被很多人看到。
go
// 错误
slog.Info("user login", "password", password)
// 正确
slog.Info("user login", "user_id", userID)四、信号处理与优雅关停
这是容器化应用最容易被忽视、又最重要的实践之一。
1. 容器停止时发生了什么
当执行 docker stop 或编排系统(Kubernetes、Docker Swarm)要停止容器时,流程是:
- Docker 向容器主进程(PID 1)发送
SIGTERM信号。 - 等待一段时间(默认 10 秒,可用
--stop-grace-period调整)。 - 如果进程还没退出,发送
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注册SIGINT和SIGTERM处理。- 收到信号后调用
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. 健康检查端点配合优雅关停
优雅关停的完整流程需要健康检查配合:
- 收到
SIGTERM。 - 立即让
/health返回失败(让负载均衡摘除流量)。 - 等几秒让负载均衡感知。
- 停止接受新请求。
- 等已有请求完成。
- 清理资源退出。
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。
健康状态有三种:starting → healthy / 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: 10s3. 用 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/server3. 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/Shanghai2. distroless 镜像
distroless 的 -debian12 变体自带 tzdata:
dockerfile
ENV TZ=Asia/Shanghai3. 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: 128Mlimits 是硬上限,reservations 是软保证。
3. Go 应用的内存建议
Go 应用的内存包括:
- Go runtime 自身(几十 MB)
- 堆内存(业务数据)
- goroutine 栈
- GC 需要的额外空间
设置内存限制时,必须同时设置 GOMEMLIMIT(Go 1.19+),让 Go GC 感知限制,主动回收:
dockerfile
ENV GOMEMLIMIT=450MiBGOMEMLIMIT 应设置为容器内存限制的 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 myapp2. docker-compose.yml
yaml
services:
app:
image: myapp
read_only: true
tmpfs:
- /tmp
- /app/cache3. Go 应用的注意事项
如果应用需要写文件(日志、缓存、上传),改成:
- 日志:写 stdout。
- 缓存:用 Redis 或内存。
- 上传:写到对象存储(S3、OSS)。
- 临时文件:用
/tmp(tmpfs 挂载)。
如果某些场景必须写本地文件,单独挂载一个数据卷到那个目录,其他部分保持只读。
十、安全加固
1. no-new-privileges
防止容器内进程通过 setuid 等机制提权:
bash
docker run --security-opt no-new-privileges myapp2. seccomp
seccomp 限制容器可用的系统调用。Docker 默认应用一个 seccomp profile,可以进一步收紧:
bash
docker run --security-opt seccomp=custom-profile.json myapp3. capability 裁剪
Docker 默认给容器少量 capability,可以全部丢弃再按需添加:
bash
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE myapp4. 综合安全配置
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: 512M5. 镜像层面的安全
- 用非 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: 10s4. 构建与验证
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 -55. 验证安全配置
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 应用最核心的实践篇。
关键要点:
- 十二要素原则:配置走环境变量、日志走 stdout、无状态进程。
- 配置管理:
os.Getenv或 Viper,优先级:命令行 > 环境变量 > 配置文件 > 默认值。 - 日志处理:输出到 stdout/stderr,JSON 结构化,不含敏感信息。
- 优雅关停:捕获 SIGTERM,标记不健康,等流量摘除,Shutdown 等请求完成。
- 健康检查:Dockerfile HEALTHCHECK + 应用 /health 端点。
- 非 root 运行:创建专用用户,USER 切换。
- 时区:安装 tzdata 或
import _ "time/tzdata"。 - 资源限制:memory + cpus limits,配合
GOMEMLIMIT。 - 只读文件系统:
read_only: true+ tmpfs。 - 安全加固:no-new-privileges、cap_drop ALL、最小镜像、漏洞扫描。
下一篇也是本系列最后一篇,我们将进入 CI/CD 流水线与镜像发布,把前面构建的镜像通过 GitHub Actions、GitLab CI 自动化构建、扫描、签名、发布到 Registry,并介绍部署策略。