Docker 部署:让你的应用随处运行

全面讲解 Go 应用的 Docker 化部署,涵盖 Dockerfile 基础编写、多阶段构建优化、scratch 极简镜像、构建缓存技巧、Docker Compose 多服务编排、健康检查、生产级最佳实践(非 root 用户、资源限制、日志轮转),以及企业级部署架构、GitHub Actions CI/CD 集成和 Kubernetes 迁移指南。

Docker 部署:让你的应用随处运行

“在我的机器上能跑啊!"——这大概是开发者说过最多的一句话了。

不同的开发环境、不同的依赖版本、不同的操作系统……这些问题让应用的部署变得复杂而痛苦。Docker 的出现彻底改变了这一局面:把你的应用和它的所有依赖打包成一个容器,在任何地方都能以相同的方式运行。

Go 语言和 Docker 是天作之合。Go 编译出的静态二进制文件非常小,而且不需要运行时依赖(如 JVM、Python 解释器),这让 Go 的 Docker 镜像可以做得极其精简——从几 MB 到几十 MB 不等,而 Java 或 Python 的镜像通常动辄几百 MB 甚至几 GB。

今天我们就来学习如何用 Docker 部署 Go 应用,从入门到生产级的完整实践。

为什么 Go 特别适合 Docker

Go 语言在容器化方面有几个天然的优势:

  1. 静态链接:Go 编译的静态二进制文件不依赖系统库(关闭 CGO 时),可以直接在 scratch 镜像中运行
  2. 无运行时依赖:不需要 JVM、Python 解释器等额外运行时
  3. 自包含:单个二进制文件就是整个应用,复制到任何 Linux 系统都能直接运行
  4. 启动速度快:Go 程序启动通常在毫秒级,非常适合容器的快速启动和弹性伸缩
  5. 内存占用小:Go 的内存管理高效,单个容器通常只需几十到几百 MB 内存

这些特性让 Go 应用成为云原生时代的首选语言之一。

Dockerfile 基础

最简 Dockerfile

# 使用官方 Go 镜像
FROM golang:1.22-alpine

# 设置工作目录
WORKDIR /app

# 复制代码
COPY . .

# 构建
RUN go build -o myapp

# 暴露端口
EXPOSE 8080

# 运行
CMD ["./myapp"]

构建和运行:

# 构建镜像
docker build -t myapp .

# 运行容器
docker run -p 8080:8080 myapp

这个 Dockerfile 虽然能用,但生成的镜像非常大(约 350MB),因为包含了完整的 Go 工具链。对于生产环境,我们需要优化。

多阶段构建(强烈推荐)

多阶段构建是 Docker 17.05 引入的特性,可以生成更小的镜像:

# ========== 阶段 1:构建 ==========
FROM golang:1.22-alpine AS builder

# 安装 git(go mod 需要)
RUN apk add --no-cache git

WORKDIR /app

# 先复制 go.mod 和 go.sum,利用 Docker 层缓存
COPY go.mod go.sum ./
RUN go mod download

# 复制源代码并构建
COPY . .

# 构建静态二进制文件
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags='-w -s -extldflags "-static"' \
    -a -installsuffix cgo \
    -o myapp .

# ========== 阶段 2:运行 ==========
FROM alpine:latest

# 安装 CA 证书(用于 HTTPS)
RUN apk --no-cache add ca-certificates tzdata

# 设置时区
ENV TZ=Asia/Shanghai

WORKDIR /app

# 从构建阶段复制二进制文件(只复制最终产物)
COPY --from=builder /app/myapp .

# 创建非 root 用户
RUN adduser -D -u 1000 appuser
USER appuser

EXPOSE 8080

CMD ["./myapp"]

构建后的镜像只有约 15-25 MB!相比原来的 350MB,体积减少了 90% 以上。这是因为运行阶段只包含最终编译的二进制文件和基本系统工具。

关键点:

  • CGO_ENABLED=0:禁用 CGO,生成完全静态链接的二进制文件
  • GOOS=linux GOARCH=amd64:明确指定目标平台
  • -ldflags='-w -s':去掉调试信息和符号表,进一步减小体积
  • --from=builder:从构建阶段复制文件
  • 构建阶段的中间层不会被包含在最终镜像中

极简镜像(scratch)

如果想要更小的镜像,可以使用 scratch(空镜像):

# ========== 构建阶段 ==========
FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags='-w -s' \
    -o myapp .

# ========== 运行阶段:使用空镜像 ==========
FROM scratch

# 复制 CA 证书(如果需要 HTTPS)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# 复制时区数据
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
ENV TZ=Asia/Shanghai

WORKDIR /app

# 复制二进制文件
COPY --from=builder /app/myapp .

# 注意:scratch 中没有 shell,不能用 USER adduser
# 如果需要非 root 用户,需要使用 distroless 或 busybox

EXPOSE 8080

# HEALTHCHECK 在 scratch 中也不可用,因为没有 shell
CMD ["/app/myapp"]

生成的镜像只有你的二进制文件大小 + CA 证书大小(通常 5-15 MB)!

中间方案:distroless

Google 提供了一个折中方案——distroless 镜像:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp .

FROM gcr.io/distroless/static-debian12:latest
COPY --from=builder /app/myapp /myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]

distroless 镜像包含了 glibc、OpenSSL、tzdata 等最基本的运行时组件,但没有包管理器、shell 或其他工具。镜像大小约 20-30 MB,比 scratch 更安全、比 alpine 更不易遇到 musl libc 的兼容性问题。

优化 Docker 构建

1. 利用构建缓存

# ❌ 不好:任何文件变化都会导致重新下载依赖
COPY . .
RUN go mod download
RUN go build -o myapp

# ✅ 好:先复制依赖文件,利用缓存层
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

Docker 的层缓存机制是优化构建速度的核心。go.modgo.sum 不经常变化,所以 go mod download 层会被缓存。只有当依赖发生变化时,才会重新执行 go mod download

2. 使用 .dockerignore

创建 .dockerignore 文件,排除不需要的文件,减小 build context:

.git
.gitignore
README.md
Dockerfile
docker-compose.yml
.dockerignore
.vscode
.idea
*.log
vendor/
tmp/
temp/
dist/
node_modules/

这对于使用 Git 的大型项目尤为重要。小的 build context 意味着更快的文件复制和更少的缓存失效。

3. 构建多平台镜像

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o myapp .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /usr/local/bin/myapp
EXPOSE 8080
ENTRYPOINT ["myapp"]

使用 Docker Buildx 构建多平台镜像:

docker buildx create --use
docker buildx build \
    --platform linux/amd64,linux/arm64 \
    -t yourname/myapp:latest \
    --push .

4. 安全扫描

# 使用 Trivy 扫描镜像漏洞
docker run --rm aquasec/trivy image yourname/myapp:latest

# 或本地安装
trivy image yourname/myapp:latest

Docker Compose 多服务编排

对于真实的多服务应用,使用 Docker Compose 更方便:

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - DB_HOST=db
      - DB_PORT=3306
      - DB_USER=myapp
      - DB_PASSWORD=secret
      - DB_NAME=myapp
      - REDIS_ADDR=redis:6379
      - LOG_LEVEL=info
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped
    networks:
      - myapp-network
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M

  db:
    image: mysql:8.0
    environment:
      - MYSQL_ROOT_PASSWORD=rootpassword
      - MYSQL_DATABASE=myapp
      - MYSQL_USER=myapp
      - MYSQL_PASSWORD=secret
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "3306:3306"
    networks:
      - myapp-network
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    networks:
      - myapp-network
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - myapp-network
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    networks:
      - myapp-network
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana
    networks:
      - myapp-network
    depends_on:
      - prometheus

volumes:
  mysql-data:
  redis-data:
  prometheus-data:
  grafana-data:

networks:
  myapp-network:
    driver: bridge

启动所有服务:

# 后台启动
docker-compose up -d

# 查看日志
docker-compose logs -f app

# 扩容应用实例
docker-compose up -d --scale app=3

# 停止
docker-compose down

# 停止并删除数据卷
docker-compose down -v

实战:完整的 Web 应用部署

让我们部署一个完整的 Go Web 应用:

应用代码

// main.go
package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
    
    _ "github.com/go-sql-driver/mysql"
    "github.com/redis/go-redis/v9"
    "context"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    db    *sql.DB
    rdb   *redis.Client
    
    // 自定义 Prometheus 指标
    requestCount = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests",
        },
        []string{"method", "endpoint", "status"},
    )
    
    requestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name: "http_request_duration_seconds",
            Help: "HTTP request duration in seconds",
        },
        []string{"method", "endpoint"},
    )
)

func init() {
    prometheus.MustRegister(requestCount)
    prometheus.MustRegister(requestDuration)
}

func main() {
    // 连接数据库(带重试)
    var err error
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
        os.Getenv("DB_USER"),
        os.Getenv("DB_PASSWORD"),
        os.Getenv("DB_HOST"),
        os.Getenv("DB_PORT"),
        os.Getenv("DB_NAME"),
    )
    
    for i := 0; i < 30; i++ {
        db, err = sql.Open("mysql", dsn)
        if err == nil {
            err = db.Ping()
            if err == nil {
                log.Println("✅ 数据库连接成功")
                break
            }
        }
        log.Printf("等待数据库连接... (%d/30)", i+1)
        time.Sleep(2 * time.Second)
    }
    
    if err != nil {
        log.Fatalf("数据库连接失败: %v", err)
    }
    defer db.Close()
    
    // 连接 Redis
    rdb = redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_ADDR"),
    })
    
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    if err := rdb.Ping(ctx).Err(); err != nil {
        log.Fatalf("Redis 连接失败: %v", err)
    }
    log.Println("✅ Redis 连接成功")
    
    // HTTP 路由
    http.HandleFunc("/", metricsMiddleware(homeHandler))
    http.HandleFunc("/health", healthHandler)
    http.Handle("/metrics", promhttp.Handler())
    
    // 启动服务器
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    
    log.Printf("🚀 服务器启动在 :%s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

func metricsMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // 记录响应状态码的包装器
        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
        next(wrapped, r)
        
        duration := time.Since(start).Seconds()
        requestCount.WithLabelValues(r.Method, r.URL.Path, fmt.Sprintf("%d", wrapped.statusCode)).Inc()
        requestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
    }
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello from Docker!")
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    // 检查数据库
    if err := db.Ping(); err != nil {
        http.Error(w, `{"status":"unhealthy","reason":"database"}`, http.StatusServiceUnavailable)
        return
    }
    
    // 检查 Redis
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    if err := rdb.Ping(ctx).Err(); err != nil {
        http.Error(w, `{"status":"unhealthy","reason":"redis"}`, http.StatusServiceUnavailable)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, `{"status":"healthy"}`)
}

Dockerfile

# ========== 构建阶段 ==========
FROM golang:1.22-alpine AS builder

RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /app

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags='-w -s -extldflags "-static"' \
    -a \
    -o myapp .

# ========== 运行阶段 ==========
FROM alpine:latest

RUN apk --no-cache add ca-certificates tzdata curl

ENV TZ=Asia/Shanghai

WORKDIR /app

COPY --from=builder /app/myapp .
COPY --from=builder /app/config.yaml .

# 创建非 root 用户
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8080

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1

CMD ["./myapp"]

常用 Docker 命令速查

# 构建镜像
docker build -t myapp:latest .
docker build -t myapp:latest --no-cache .

# 运行容器
docker run -d -p 8080:8080 --name myapp myapp:latest
docker run -d -p 8080:8080 -e PORT=8080 myapp:latest

# 查看日志
docker logs -f myapp
docker logs --tail 100 myapp

# 进入容器
docker exec -it myapp sh

# 停止和删除
docker stop myapp
docker rm myapp
docker rm -f myapp

# 查看资源使用
docker stats

# 镜像管理
docker images
docker rmi myapp:latest
docker image prune -a

# 网络和数据卷
docker network ls
docker volume ls
docker volume rm <volume_name>

生产环境最佳实践

1. 使用非 root 用户

# 在 Dockerfile 中创建非 root 用户
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

这是安全基线要求。容器逃逸时,非 root 用户能限制攻击影响范围。

2. 设置资源限制

deploy:
  resources:
    limits:
      cpus: '1.0'
      memory: 1G
    reservations:
      cpus: '0.5'
      memory: 512M

在 Kubernetes 中:

resources:
  limits:
    cpu: "1000m"
    memory: "1Gi"
  requests:
    cpu: "500m"
    memory: "512Mi"

3. 配置日志轮转

logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

避免容器日志撑满磁盘。Go 应用应该输出到 stdout/stderr(符合 12-Factor App 原则),由 Docker 收集。

4. 使用 secrets 管理敏感信息

services:
  app:
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
      - api_key

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt

在生产环境中,应该配合 Docker Swarm 或 Kubernetes 的 secrets 机制。

5. 使用只读根文件系统

services:
  app:
    read_only: true
    tmpfs:
      - /tmp

6. 禁止特权模式

services:
  app:
    security_opt:
      - no-new-privileges:true

CI/CD 集成(GitHub Actions)

# .github/workflows/docker.yml
name: Docker Build & Push

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Run tests
        run: go test -race -coverprofile=coverage.out ./...

      - name: Run security scan (govulncheck)
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ secrets.DOCKER_USERNAME }}/myapp
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

常见问题(FAQ)

Q1: 为什么我的 Go 应用在容器里无法连接宿主机数据库?

A: Docker 容器有独立的网络命名空间。连接宿主机服务时,不要用 localhost(那是容器自己),应该用主机的 IP 地址或使用 Docker 的 host 网络模式(仅开发环境)。在 docker-compose 中,服务名就是主机名。

Q2: 如何减小最终镜像大小?

A: 主要策略:

  1. 使用多阶段构建,只复制最终二进制文件
  2. 使用 scratchdistrolessalpine 作为运行阶段
  3. 使用 -ldflags="-w -s" 去掉调试符号
  4. 清理构建缓存和临时文件

Q3: CGO_ENABLED=0 是什么意思?

A: 禁用 CGO,生成完全静态链接的二进制文件。这样不需要依赖系统的 glibc 或 musl,可以直接在 scratch 镜像中运行。但如果你的项目依赖必须用 CGO(如某些数据库驱动),就不能关闭 CGO,运行阶段需要包含 C 库的镜像(如 alpine 或自定义的 debian)。

Q4: Dockerfile 中的 HEALTHCHECK 在 scratch 镜像中为什么不能用?

A: scratch 镜像没有 shell 和任何二进制文件(包括 wget/curl),无法执行 HEALTHCHECK 命令。如果使用 scratch,需要在 Go 应用中实现 TCP/HTTP 健康检查端点,在外部用其他工具检测。

Q5: 如何调试运行中的容器?

A: 如果使用的是 alpine 或 debian 基础镜像:

docker exec -it myapp sh
# 在容器内安装调试工具
apk add curl strace    # alpine
apt-get update && apt-get install curl   # debian

对于 scratch 镜像,需要把调试信息输出到标准输出/标准错误。

延伸阅读


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南