Docker 部署:让你的应用随处运行
“在我的机器上能跑啊!"——这大概是开发者说过最多的一句话了。
不同的开发环境、不同的依赖版本、不同的操作系统……这些问题让应用的部署变得复杂而痛苦。Docker 的出现彻底改变了这一局面:把你的应用和它的所有依赖打包成一个容器,在任何地方都能以相同的方式运行。
Go 语言和 Docker 是天作之合。Go 编译出的静态二进制文件非常小,而且不需要运行时依赖(如 JVM、Python 解释器),这让 Go 的 Docker 镜像可以做得极其精简——从几 MB 到几十 MB 不等,而 Java 或 Python 的镜像通常动辄几百 MB 甚至几 GB。
今天我们就来学习如何用 Docker 部署 Go 应用,从入门到生产级的完整实践。
为什么 Go 特别适合 Docker
Go 语言在容器化方面有几个天然的优势:
- 静态链接:Go 编译的静态二进制文件不依赖系统库(关闭 CGO 时),可以直接在 scratch 镜像中运行
- 无运行时依赖:不需要 JVM、Python 解释器等额外运行时
- 自包含:单个二进制文件就是整个应用,复制到任何 Linux 系统都能直接运行
- 启动速度快:Go 程序启动通常在毫秒级,非常适合容器的快速启动和弹性伸缩
- 内存占用小: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.mod 和 go.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: 主要策略:
- 使用多阶段构建,只复制最终二进制文件
- 使用
scratch、distroless或alpine作为运行阶段 - 使用
-ldflags="-w -s"去掉调试符号 - 清理构建缓存和临时文件
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 镜像,需要把调试信息输出到标准输出/标准错误。
延伸阅读
- Go Modules:现代化的依赖管理 — Docker 构建中的 Go 依赖下载与缓存优化
- go:embed:把文件打包进二进制 — 配合 Docker 构建单文件 Go 应用
- CGO 入门:Go 与 C 的桥梁 — Dockerfile 中 CGO 交叉编译的特殊配置
- 项目架构:如何组织 Go 项目 — 容器化项目的目录结构建议
- 性能优化:pprof 和 trace 工具 — 容器内 Go 应用的性能分析方法
- Go 最佳实践:写出优雅的 Go 代码 — 12-Factor App 与云原生开发规范
- 安全最佳实践:Go 安全编程指南 — Docker 镜像安全扫描与加固
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。