现在开始主项目。应用提供四个接口:GET / 返回服务信息,GET /health 检查 Redis,GET /tasks 列出任务,POST /tasks 创建任务。
这一节的目标不是学习 Go,而是理解 Docker 怎样把源码变成镜像。源码已经准备好,你只需要看清文件如何进入构建过程。

执行 docker build ... . 时,最后的 . 表示构建上下文。Dockerfile 中的 COPY 只能读取上下文里的文件,因此应在项目根目录执行构建。
多阶段构建使用多个 FROM:第一阶段包含编译工具,第二阶段只接收编译结果。Go 工具链不会进入最终镜像,这能减少体积和攻击面。
本项目的构建流程是:
go.mod + go.sum ─► 下载依赖 ─┐
├─► 编译 task-api ─► Alpine 运行镜像
main.go ──────────────────────┘Dockerfile 不是启动脚本,它描述的是“怎样得到一份镜像”。构建器逐条读取指令,在临时构建环境中执行需要的步骤,最后输出镜像层和镜像配置。运行容器时不会重新执行 RUN go mod download 或 RUN go build;那些动作已经发生在构建阶段。
可以把一次构建分成四类输入:
COPY、ADD 等指令能够读取的文件集合。FROM 引用的已有文件系统和默认配置。输出不是“正在运行的程序”,而是一份可以反复创建容器的静态模板。
命令末尾的 . 恰好选择当前目录作为上下文,但上下文本身是一个明确的输入边界。Dockerfile 位于哪里、命令从哪里执行、上下文指向哪里可以是三件不同的事,例如:
$ docker build -f deploy/Dockerfile -t example/app:1.0 .这里 Dockerfile 在 deploy/,上下文仍然是当前目录。Dockerfile 中的 COPY main.go ./ 从上下文根查找 main.go,不是从 deploy/ 查找。理解这点就能解释许多“文件明明存在却 COPY 不到”的问题。
构建器需要读取上下文里的相关文件来计算构建结果与缓存。上下文越大,传输、校验和误包含敏感文件的风险越高,因此 .dockerignore 既是性能配置,也是交付边界。
容器化之前要先回答“应用正常运行依赖什么”,否则 Dockerfile 只能碰巧启动程序。本项目的契约是:
这张表比“能不能在设备上启动”更重要。它把程序依赖翻译成 Docker 后续要管理的端口、环境变量、网络、日志和卷。
API 容器只负责无状态的请求处理,数据交给 Redis。这样同一镜像创建出的任意 API 容器都可以被替换,而不会携带独有数据。将计算与状态分开,是容器应用易于重建和扩展的基础。
/health 为什么要检查 Redis首页能返回文字,只能证明 HTTP 进程还活着;任务接口还依赖 Redis。如果健康接口向 Redis 发送 PING 并设置超时,它反映的是服务完成核心工作的能力。后面会进一步区分“进程存活”“服务就绪”和“依赖长期可用”。
先创建目录并进入:
$ mkdir docker-task-api
$ cd docker-task-api创建 go.mod:
module example.com/docker-task-api
go 1.25.0
require github.com/redis/go-redis/v9 v9.7.0创建 main.go:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
type application struct {
redis *redis.Client
项目依赖由 go.sum 校验。即使没有安装 Go,也可以用一次性容器生成它:
$ docker run --rm -v "$PWD":/src -w /src \
golang:1.25-alpine go mod tidy$ ls
go.mod go.sum main.go--rm 会在命令结束后删除工具容器;-v "$PWD":/src 把当前项目目录挂载到容器的 /src,所以生成的 go.sum 会留在项目目录中。
这条工具命令值得拆开理解:
golang:1.25-alpine 提供固定版本的 Go 工具链。-w /src 让进程把 /src 当作当前工作目录。-v "$PWD":/src 是绑定挂载,工具容器直接修改项目目录中的文件。go mod tidy 是容器主进程,执行完后容器自然退出。这里使用容器的价值不只是“免安装 Go”,还把依赖整理所用的工具版本显式固定下来。代价是绑定挂载涉及文件权限;在 Linux 上,如果工具以 root 身份写文件,生成文件的所有者可能需要额外处理。
创建名为 Dockerfile 的文件:
# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags="-s -w" -o /out/task-api .
FROM alpine:3.22
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/task-api /usr/local/bin/task-api
USER app
EXPOSE 8080
ENTRYPOINT ["task-api"]逐行看它做了什么:
FROM ... AS build 创建编译阶段。--mount=type=cache 保存 Go 模块缓存,但不把缓存写入镜像层。FROM 开始运行阶段,只复制一个二进制文件。USER app 让应用不以 root 身份运行。ENTRYPOINT 指定容器启动时的主进程。下面把几个最容易只知语法、不知边界的指令再展开。
FROM:选择的是用户空间起点golang:1.25-alpine 提供编译器、标准库和 Alpine 用户空间;alpine:3.22 只作为运行阶段的基础。基础镜像不会给容器带来独立内核,但会决定可用命令、系统库、证书和包管理器。
标签可移动,因此 FROM alpine:3.22 表示跟随该标签之后的兼容更新。如果发布过程要求完全可复现,可以在验证更新后使用摘要锁定基础镜像,同时配套依赖更新流程,避免永远停留在旧漏洞版本。
WORKDIR:不只是执行一次 cd它会为后续 RUN、COPY、ENTRYPOINT 等指令设置工作目录,并作为镜像配置保留。如果目录不存在,构建器会创建它。相比在每条 RUN 中重复 cd /src && ...,WORKDIR 更清楚,也不依赖 shell 状态跨层保存。
RUN 与容器启动命令属于不同时间RUN 在构建镜像时执行并产出层;ENTRYPOINT 和 CMD 写入镜像配置,在创建容器时才决定主进程。把数据库迁移、动态配置或密钥读取放进 RUN,意味着它们发生得太早,甚至可能把敏感结果写进镜像。
ENTRYPOINT 与 CMD 怎样配合exec 形式 ENTRYPOINT ["task-api"] 直接指定可执行文件,信号更容易到达应用。CMD 常用于提供可覆盖的默认参数。例如:
ENTRYPOINT ["task-api"]
CMD ["serve"]默认启动的是 task-api serve;镜像名后的参数可以替换 CMD。本项目不需要子命令,所以只设置 ENTRYPOINT。
EXPOSE 为什么不能替代 -pEXPOSE 8080 记录镜像作者预期服务监听的端口,便于人和工具理解,但它不修改防火墙,也不创建端口转发。外部访问仍由创建容器时的 -p 或 Compose ports 决定。镜像描述能力,运行配置决定暴露范围。
再创建 .dockerignore:
.git
.env
*.md它能阻止无关文件进入构建上下文,也能降低误把密钥打进镜像的风险。
.dockerignore 是预防措施,不是密钥管理系统。即使文件后来从某一层删除,它也可能仍存在于更早的镜像层或构建缓存中。构建需要秘密时,应使用 BuildKit secret mount,而不是先 COPY 再删除。
单阶段镜像如果保留 Go 编译器、模块缓存和源代码,会带来三类多余成本:分发更慢、可被攻击的工具更多、运行容器里可被读取的项目内容更多。多阶段构建把“能制造程序的环境”和“只需运行程序的环境”分开。
COPY --from=build /out/task-api ... 是两个阶段之间明确的交付接口。运行阶段不会继承 build 阶段的整个文件系统,只接收指定二进制。这种做法也让审查更简单:最终镜像应该包含哪些文件,可以从最后一个 FROM 之后的指令直接推导。
不过“小”不等于自动安全。运行镜像仍需要及时更新基础镜像、设置非 root 用户、限制运行权限,并确认应用不依赖被删掉的动态库或证书。Go 这里设置 CGO_ENABLED=0,得到不依赖 C 运行库的二进制,减少跨阶段运行时依赖。
-t 给结果添加仓库名和标签,最后的 . 是构建上下文:
$ docker build -t docker-task-api:1.0 .
...
=> [build 6/6] RUN CGO_ENABLED=0 GOOS=linux go build ...
=> naming to docker.io/library/docker-task-api:1.0$ docker image ls docker-task-api:1.0 \
--format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'
REPOSITORY TAG SIZE
docker-task-api 1.0 21.9MB镜像大小会随基础镜像更新略有变化。关键结果是镜像名与标签正确,并且最终镜像没有 Go 编译器。
不要只用大小验收镜像。继续检查镜像配置和文件是否符合运行契约:
$ docker image inspect docker-task-api:1.0 \
--format 'user={{.Config.User}} workdir={{.Config.WorkingDir}} entrypoint={{json .Config.Entrypoint}}'
user=app workdir= entrypoint=["task-api"]
$ docker run --rm --entrypoint sh docker-task-api:1.0 \
-c 'id; test -x /usr/local/bin/task-api && echo binary=ok; command -v go || echo go=absent'
uid=100(app) gid=101(app) groups=101
第一条读取静态配置;第二条临时覆盖入口,检查运行用户、二进制和 Go 工具链。用户数字可能不同,但用户应为 app,二进制可执行,go 不应存在。
/health镜像已经构建成功,但健康接口依赖地址为 redis:6379 的 Redis。现在还没有对应网络和服务,因此直接启动 API 后健康检查失败是符合运行契约的结果,不是镜像构建失败。优秀的验收要区分“镜像内容正确”和“完整应用依赖齐备”。
我们已经把源码变成了第一张应用镜像。它暂时无法通过健康检查,因为 Redis 还没有启动。下一节先理解这张镜像为什么能快速重建,再接入数据和网络。