Skip to content

容器与 Docker ​

把 Go 程序编成 Linux 可执行文件,再放进镜像,只解决了部署的一半。另一半是:这个进程能看见哪些文件、能用多少资源、以谁的身份运行,以及出了问题怎样检查。本篇沿着“构建一个镜像,再启动一个进程”解释 Docker,最后给出可以自行复现的多阶段构建配置。

本次实际运行环境为 Go 1.27.2、Linux/amd64。完成了 Go 测试、race、vet、静态二进制检查及分层删除的教学实验;环境没有 Docker daemon,所以没有声称镜像已经构建、运行,或测出镜像大小。Docker、BuildKit 的配置以 2026-10-13 查阅的官方文档为依据,部署前仍须核对所用版本。

先分清四个对象 ​

对象包含什么、负责什么常用动作容易混淆的地方
Dockerfile构建步骤及镜像默认配置docker build / docker buildx build它是配方,执行结果才是镜像
镜像 image文件系统层、入口、环境变量等配置pull、inspect、pushtag 可以移动;digest 标识内容
容器 container由镜像启动的进程,加上运行时配置与可写层run、logs、stop、inspect删除容器会丢掉未持久化的可写层内容
Registry保存、分发镜像内容推送或按 digest 拉取仓库登录权限不等于容器运行权限

同一个镜像可以启动多个容器,每个容器都有自己的进程状态和可写层。进程退出以后镜像不会改变;容器中的配置文件被人临时修改,也不会自动变成下次部署使用的新镜像。Docker 对象与架构

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    S[源码与构建输入] --> B[BuildKit 执行 Dockerfile]
    B --> I[镜像 manifest、配置、只读层]
    I --> R[Registry 按内容分发]
    R --> C1[容器 A:进程与可写层]
    R --> C2[容器 B:进程与可写层]
    C1 --> K[宿主 Linux 内核]
    C2 --> K
    C1 --> V[独立持久卷]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    S[源码与构建输入] --> B[BuildKit 执行 Dockerfile]
    B --> I[镜像 manifest、配置、只读层]
    I --> R[Registry 按内容分发]
    R --> C1[容器 A:进程与可写层]
    R --> C2[容器 B:进程与可写层]
    C1 --> K[宿主 Linux 内核]
    C2 --> K
    C1 --> V[独立持久卷]

图中的两只容器共享的是宿主内核和可复用的镜像内容,不共享一个 Go 堆。持久卷单独画出来,是因为数据库文件、用户上传文件不能依赖一个随容器删除而消失的可写层。

一个普通进程,怎样获得“自己的环境” ​

Linux 容器不是缩小的虚拟机。虚拟机通常有自己的客户机内核;Linux 容器仍由宿主 Linux 内核执行系统调用。Docker Desktop 可以先在虚拟机里运行 Linux,再在这个 Linux 里运行容器,这不改变容器本身的进程模型。

机制控制的事情不负责的事情
PID namespace进程看到的 PID 集合及编号不限制 CPU 或内存
Mount namespace进程看到哪些挂载点不自动让文件只读
Network namespace接口、路由、端口等网络视图不等于默认拒绝全部网络流量
User namespace容器内外 UID/GID 的映射不代替所有访问控制
cgroup对进程组计量、限制、分配资源不隐藏进程或文件
capabilities、seccomp、LSM缩小特权、系统调用及安全访问范围不能消除共享内核的风险

namespace 解决“看见什么”;cgroup 解决“用多少、如何分配”。例如 cgroup v2 的 cpu.max 用 quota 和 period 限制平均 CPU 时间,memory.max 是内存控制边界。CPU 超过配额可能被节流,内存超过约束可能触发回收与 OOM;两者的失败形式不同。Docker 底层技术、内核 cgroup v2 文档

USER 65532:65532 让应用不以容器内 root 身份运行。rootless Docker 则进一步让 daemon 和容器在非 root 用户及 user namespace 中运行,两者不是同一个设置。rootless 可以减少 daemon 漏洞的影响,但仍受内核、网络实现、资源控制配置等条件约束。将 Docker socket 挂进容器、启用 privileged 或任意挂载宿主目录,会显著扩大权限;一个 nonroot 标签不能抵消这些授权。rootless、运行权限

镜像分层:删除文件为什么不等于删除旧内容 ​

镜像可以由多层文件变更组成。启动时,存储实现把这些只读内容和容器的可写层组成一个可见文件系统。以经典 overlay2 为例,读文件时上层覆盖下层;修改下层文件时可能先发生 copy-up,再修改上层副本。第一次修改一个大文件的成本,因此可能大于只修改几个字节的直觉。当前 Docker 还可以使用 containerd image store 的 snapshotter,不能把 overlay2 的目录布局当成所有安装的共同事实。OverlayFS 与 overlay2

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TB
    U[容器可写层:修改 config,隐藏 token] --> M[合并后的文件视图]
    L2[镜像第 2 层:应用二进制] --> M
    L1[镜像第 1 层:基础文件与 token] --> M
    M --> P[进程只看到合并结果]
    L1 --> E[读取旧层仍可得到 token]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TB
    U[容器可写层:修改 config,隐藏 token] --> M[合并后的文件视图]
    L2[镜像第 2 层:应用二进制] --> M
    L1[镜像第 1 层:基础文件与 token] --> M
    M --> P[进程只看到合并结果]
    L1 --> E[读取旧层仍可得到 token]

OCI 的层格式用 whiteout 表示下层文件被删除。例如 .wh.token.txt 表示在应用这一层时隐藏 token.txt,并不改写旧层的 tar 内容。OCI Image Spec v1.1.1

下面是本文实际运行的极小模型的关键动作,完整 Python 在文末。它只实现根目录普通文件的 whiteout,未实现全部 OCI 层语义,更不是 Docker 或 OverlayFS 测试。

python
if h.name.startswith(".wh."):
    view.pop(h.name[4:], None)
else:
    view[h.name] = t.extractfile(h).read()
text
merged view contains token: False
old layer still contains bytes: True

这解释了为什么不能 COPY 密钥,再在后面的 RUN 删除。构建凭据应通过 BuildKit secret mount 或 SSH mount 临时提供,且构建命令不能把凭据抄进产物、日志或缓存。ARG TOKEN、ENV TOKEN 也不适合承载秘密。更换 secret 内容本身不会自动令 BuildKit 的构建缓存失效,需要时用一个不含秘密的缓存版本参数显式失效。构建秘密、secret 缓存规则

多阶段构建:最终运行时不需要编译器 ​

Go 的构建环境需要工具链、模块缓存,有时还需要 C 编译器;运行一个已编译的纯 Go 程序不需要这些工具。多阶段构建允许第一阶段做编译,第二阶段只取必要文件。多阶段构建

dockerfile
ARG BUILDER_IMAGE
FROM ${BUILDER_IMAGE} AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api .
FROM scratch
COPY --from=build /out/api /api
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65532:65532
ENTRYPOINT ["/api"]

这里不填一个看起来真实、实际上没有核验过的 digest。BUILDER_IMAGE 必须由读者提供:一个含 Go 1.27.2、带上述 CA 路径的 Debian 系官方 Go 构建镜像,并固定到查验过的 sha256 摘要。本次没有 Registry 拉取,因此也没有确认某个具体 tag 存在。

COPY --from=build 明确了产物来源;最终阶段没有继承编译器。JSON 数组形式的 ENTRYPOINT 直接执行二进制,不依赖 shell。shell 形式还会引入 PID 1、信号转发的问题,而 scratch 本来就没有 /bin/sh。Dockerfile ENTRYPOINT

本地先检查这个二进制是否满足预期,比等容器启动时报错更容易定位问题:

sh
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o api .
./api --selftest
readelf -l api

本次 --selftest 的实际输出和脚本判断为:

text
health=200 body="ok\n" timezone=Asia/Shanghai
ELF interpreter: absent

最后一行是验证脚本检查 ELF 没有 INTERP 段后打印的结果,不是 readelf 原始输出。它证明这次构建没有声明动态加载器;它不能证明所有 CGO_ENABLED=0 项目都能编译,也不能证明程序不需要外部数据文件。本文 HTTP handler 是用 httptest 运行的,没有冒充容器网络实测。

scratch、distroless 和普通发行版怎么选 ​

scratch 是空基础,几乎所有文件都要自己准备;distroless 提供经过筛选的运行时文件,通常没有 shell 和包管理器;普通发行版镜像保留较完整的用户空间。distroless 有 static、base、cc 等不同系列,不能把一个系列的能力套到另一个系列上。distroless 官方说明

运行需求scratch合适系列的 distroless普通发行版精简镜像
纯 Go 静态程序可用,自己准备数据通常较省准备工作可用,额外文件较多
动态 libc / cgo 依赖不能只复制可执行文件必须选匹配 ABI 的系列需匹配 libc 与动态库
HTTPS 出站自备受信 CA核对具体镜像的 CA 内容也应核对 CA 是否安装、更新
IANA 时区拷贝 zoneinfo 或嵌入 tzdata核对所选镜像内容通常可安装数据包
现场调试无 shell、curl、ps默认无 shell;另有调试变体常见工具更容易取得
更新责任自己追踪二进制与拷入文件追踪基础镜像与程序追踪更多用户空间包

本文通过空白导入 time/tzdata 把时区数据库嵌入二进制,因此 Asia/Shanghai 不依赖运行镜像中的 zoneinfo。代价是二进制增大,而且时区规则更新需要重新构建。CA 采用从构建阶段复制的方式;这也使 CA 更新成为构建与发布的责任。程序如果要写临时文件,还须提供可写目录或 tmpfs,不能指望空镜像天然有完整的 /tmp 权限安排。Go tzdata、Go x509 系统根证书

生产排错应提前准备:结构化 stdout/stderr 日志、指标、受保护的诊断入口,以及受控的调试镜像或临时调试容器。镜像小通常减少分发数据与无关软件,但“小”本身不证明安全,也不能代替补丁、身份、权限和网络隔离。

缓存应该节省什么,不能偷偷改变什么 ​

构建缓存命中取决于指令及其输入。对 COPY、ADD 等,输入文件的内容与有关元数据参与判断;仅修改 mtime 不一定令缓存失效。RUN 的远端下载结果不会因为网站上的文件变了就自动重新获取。缓存失效规则

实际项目有第三方模块时,常把 COPY go.mod go.sum ./ 和 RUN go mod download 放在复制业务源码之前:改业务代码不会迫使相同依赖重新下载。本文没有外部模块,也没有 go.sum,所以没有照抄一个会因不存在的 go.sum 而失败的指令。BuildKit cache mount 可以缓存 /go/pkg/mod 与 Go 构建缓存,但缓存是可丢弃的加速数据;构建正确性不能依赖“上一台机器正好留下了什么”。

变化期望行为要检查的风险
只改业务源码复用依赖步骤,重新编译是否过早 COPY . . 扩大失效范围
改模块版本重新解析相应依赖go.mod/go.sum 是否一起审查
更新基础镜像新 digest 进入构建固定 tag 仍可能指向新内容
轮换构建 secret凭据替换,必要步骤显式失效secret 内容变化不会自动破缓存
安全补丁发布受控更新后重新测试、发布固定 digest 也意味着不会自动得到补丁

部署时固定 digest,可以回答“这次到底运行了哪些字节”。需要多平台镜像时还要区分 image index 摘要与某个平台 manifest 摘要,记录目标平台。固定版本是可追踪性的起点,配合持续更新才能兼顾安全;永久冻结不更新同样有风险。固定基础镜像

验证记录与尚未验证的部分 ​

2026-10-13,在 Go 1.27.2、Linux/amd64 上,go test ./...、go test -race ./...、go vet ./... 通过。测试覆盖 health 路径和不存在路径,局部输出及 ELF 检查见上文。Python 模型确认 whiteout 隐藏不等于原层字节清除。

没有运行 Docker build/run,没有测量镜像大小、缓存命中耗时、容器隔离或 cgroup 限制,也没有在 scratch 内验证 HTTPS。文末 Dockerfile 是待目标环境验证的完整配置,需先填写真实构建镜像摘要。当前 HTTP 示例的退出只展示基础 Shutdown;负载均衡传播等待、业务队列、WebSocket 等退出责任见《Go 在容器中》。

依据 ​

完整复现文件 ​

在同一目录保存以下文件。本地验证需要 Go 1.27.2、Python 3、GNU readelf;运行 sh verify.sh。Dockerfile 未执行,构建前需在自己的授权测试环境提供已核验的 BUILDER_IMAGE,然后运行 docker build --build-arg BUILDER_IMAGE="$BUILDER_IMAGE" -t containerdemo:local .。这条命令会下载镜像并在本机创建构建产物,本文未代为执行。

go.mod ​

go
module example.com/containerdemo

go 1.27.0

main.go ​

代码块 · 59 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"os/signal"
	"syscall"
	"time"
	_ "time/tzdata"
)

func handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
	return mux
}
func run() error {
	if len(os.Args) == 2 && os.Args[1] == "--selftest" {
		w := httptest.NewRecorder()
		handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
		loc, err := time.LoadLocation("Asia/Shanghai")
		if err != nil {
			return err
		}
		fmt.Printf("health=%d body=%q timezone=%s\n", w.Code, w.Body.String(), loc)
		return nil
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	srv := &http.Server{Addr: ":8080", Handler: handler(), ReadHeaderTimeout: 5 * time.Second}
	result := make(chan error, 1)
	go func() { result <- srv.ListenAndServe() }()
	select {
	case err := <-result:
		return err
	case <-ctx.Done():
	}
	stop()
	drain, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(drain); err != nil {
		_ = srv.Close()
		return err
	}
	if err := <-result; !errors.Is(err, http.ErrServerClosed) {
		return err
	}
	return nil
}
func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

main_test.go ​

go
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHealth(t *testing.T) {
	for _, tc := range []struct {
		path string
		want int
	}{{"/healthz", 200}, {"/missing", 404}} {
		w := httptest.NewRecorder()
		handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
		if w.Code != tc.want {
			t.Fatalf("%s: got %d want %d", tc.path, w.Code, tc.want)
		}
	}
}

Dockerfile ​

dockerfile
# Supply a reviewed Debian-based official golang image pinned by digest.
ARG BUILDER_IMAGE
FROM ${BUILDER_IMAGE} AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api .
FROM scratch
COPY --from=build /out/api /api
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/api"]

.dockerignore ​

text
.git
.env*
*.pem
*.key
*.tar
api
*_test.go
layer_demo.py
verify.sh
Dockerfile.bad

Dockerfile.bad ​

dockerfile
# Negative teaching fixture; DO NOT build with real secrets.
FROM scratch
COPY fake-token.txt /token.txt
# A later deletion layer would hide /token.txt, not erase the earlier layer.
# RUN rm /token.txt also fails on scratch because it has no shell.

layer_demo.py ​

python
"""A tiny OCI whiteout teaching model, not a Docker/OverlayFS implementation."""
import io
import tarfile

def layer(name, data):
    out = io.BytesIO()
    with tarfile.open(fileobj=out, mode="w") as t:
        h = tarfile.TarInfo(name)
        h.size = len(data)
        t.addfile(h, io.BytesIO(data))
    return out.getvalue()

first = layer("token.txt", b"FAKE_TOKEN_FOR_DEMO")
second = layer(".wh.token.txt", b"")
view = {}
for blob in (first, second):
    with tarfile.open(fileobj=io.BytesIO(blob)) as t:
        for h in t:
            if h.name.startswith(".wh."):
                view.pop(h.name[4:], None)
            else:
                view[h.name] = t.extractfile(h).read()
assert "token.txt" not in view
assert b"FAKE_TOKEN_FOR_DEMO" in first
print("merged view contains token:", "token.txt" in view)
print("old layer still contains bytes:", b"FAKE_TOKEN_FOR_DEMO" in first)

verify.sh ​

sh
#!/bin/sh
set -eu
cd "$(dirname "$0")"
go version
go test ./...
go test -race ./...
go vet ./...
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o api .
./api --selftest
if readelf -l api | grep -q INTERP; then echo 'unexpected ELF interpreter'; exit 1; fi
echo 'ELF interpreter: absent'
python3 layer_demo.py

fake-token.txt ​

text
FAKE_TOKEN_FOR_DEMO

保留各书来源与许可 · 示例运行条件以正文为准