1 Docker 实战要回答什么
订单 API 在开发电脑上可以启动,搬到云主机后却提示缺少依赖;补好依赖后,又发现运行用户、目录权限和端口与预期不同。Docker 实战首先要回答的,就是怎样把“能运行的程序”变成“别人能按约定运行、观察和替换的交付物”。本课使用返回演示订单的 HTTP 服务,不连接真实支付系统,不保存真实订单。这样可以把注意力放在镜像、网络、身份、数据和运维证据上。
镜像是用于创建容器的只读文件与配置集合,容器则是镜像启动后的运行实例。同一镜像可以启动多个容器,每个容器都有自己的进程状态和可写层。删除容器后,写在可写层中的业务数据不能被当成可靠保留的数据。镜像也不是完整虚拟机:常见 Linux 容器共享宿主机内核,隔离有边界,不能因为程序“装进盒子”就省略主机补丁和访问控制。
把 Docker 放进云环境,可以看到不同层次的责任。云主机提供计算资源,网络规则决定谁能接近主机端口,Docker 决定端口怎样映射到容器,应用决定请求是否有权读取订单。程序在容器里用哪个用户运行,与它访问云存储时使用哪个身份,是不同的问题。前者影响本地文件权限,后者需要受限的应用凭证或工作负载身份。任何层次的“允许”都不应被误认为其他层次已经完成授权。
本课的交付验收很具体:拿到确定的镜像版本,能用记录下来的参数启动服务;只能通过约定入口访问;CPU、内存和写入位置有边界;故障时能拿出状态、日志与资源证据;替换失败后能回到已知版本。容器数量不是完成度指标。只启动成功而无法解释数据在哪里、退出为什么发生,仍然没有形成可维护的部署。

图中镜像负责带来程序和依赖,运行配置负责给出资源与入口,宿主机承接实际执行。数据库与对象存储画在容器之外,是为了提醒我们区分应用生命周期和数据生命周期。本课把数据接口留作边界,不要求搭建整套云产品,也不把单机 Docker 等同于完整云平台。
2 镜像构建、多阶段与供应链边界
Dockerfile 是构建镜像的说明文件。构建时,Docker 从构建上下文读取需要的文件,执行指令并形成镜像层。上下文不是“只有 Dockerfile 所在的那个文件”,而是传给构建命令的目录范围。若把整个项目目录随意发送进去,测试数据、私钥或本地凭证可能进入构建流程。应先用 .dockerignore 排除版本控制目录、环境配置、临时输出与秘密文件,再明确复制应用需要的内容。
镜像分层能让未变化的内容复用缓存。依赖声明经常比源码更少改变,因此许多项目先复制依赖清单并安装依赖,再复制业务代码。缓存只是在输入和规则满足条件时减少重复工作,并不证明产物可信。基础镜像标签变化、软件源变化或构建时从网络下载不同文件,都可能让相同 Dockerfile 得到不同结果。交付记录需要说明源代码版本、基础镜像引用、构建方式与最终镜像摘要。
多阶段构建把编译工具与运行环境分开。前面的阶段可以包含编译器和中间文件,后面的阶段只复制运行所需的二进制或静态文件。它减少最终镜像中不必要的内容,也使运行环境更清楚,但不会自动清除应用漏洞。若复制进来的程序本身包含有缺陷的库,缩小镜像并不能修复它。镜像扫描、依赖更新与应用测试需要分别承担自己的任务。

供应链边界还包括“谁提供了这些内容”。基础镜像、包仓库、项目源码和构建机器都可能影响最终结果。入门阶段可以先建立简单规则:从约定来源取基础镜像,记录摘要,避免安装无关工具,保存构建日志,在发布前检查已知漏洞。软件物料清单用于说明包含哪些组件,签名用于帮助核对产物来源与完整性;它们都不等于对所有行为做出安全保证。
特别注意秘密信息。把密码写在 Dockerfile、通过普通构建参数传入,或先复制秘密再在后面的层删除,都不应被视为安全交付方式。后续删除不会自动改写已经形成的旧层。需要访问私有依赖时,应使用构建系统提供的秘密挂载机制,并确认命令不会把内容写入日志或产物。运行时凭证也应通过受控配置渠道提供,不能烘焙进所有环境共用的镜像。
交付时要分清标签与摘要。标签便于人阅读,可以被重新指向;摘要标识特定镜像内容,适合记录精确交付版本。写下 orders:demo-v1 有助于交流,但要可靠回滚,还应保存它当时解析出的摘要。摘要固定后仍需安排更新:固定版本解决“部署了什么”的问题,更新流程解决“什么时候替换有风险的旧内容”的问题。
交互将源码、构建阶段、运行镜像和运行配置依次展开。请特别观察:端口映射和资源限制属于启动决定,不能靠重新给镜像起名字来实现;编译成功也只是构建链路中的节点,不能替代运行验证。
3 网络模式、卷与资源限制
在常见的桥接网络中,容器拥有独立的网络视图,通过宿主机提供的转发能力与外部通信。用户自定义桥接网络还便于让相关容器通过名称互相访问。应用在容器里监听 0.0.0.0:8080,表示接收容器网络接口上的连接;这并不等于宿主机已经向外开放了 8080。需要再明确发布端口,外部流量才会经过约定的宿主入口进入容器。
-p 127.0.0.1:8080:8080 表示把宿主机回环地址的 8080 映射到容器的 8080,适合本机实验。如果省略绑定地址,发布端口通常会监听宿主机的全部地址,暴露面随之扩大。上云后还要检查安全组、防火墙和入口代理的实际规则。不能只凭“浏览器能打开”判断访问控制正确;应分别从允许位置与不应允许的位置验证入口,明确请求最终经过哪些边界。
宿主网络模式让容器使用宿主机网络栈,端口冲突和隔离方式随之改变,也不再沿用普通桥接模式的端口映射思路。无网络模式则适合不需要网络的任务。不同操作系统和桌面运行环境对这些模式的支持细节存在差别。本课使用 Linux 容器、桥接网络与显式端口映射;没有明确需求时,不应为了少写参数而选择宿主网络。
数据写入有不同去处。容器可写层随容器生命周期变化;命名卷由 Docker 管理,适合需要独立生命周期的数据目录;绑定挂载把宿主机指定路径直接映射进容器,便于开发和文件交换,但也把该路径的权限风险带了进去。临时文件可以放进 tmpfs,它的内容不应被依赖为持久数据,并且会消耗内存资源。选择之前先问:这份数据要保留多久,由谁备份,谁能读取?
命名卷不是数据库备份。即使卷在删除容器后仍然存在,主机损坏、误删卷或应用写坏数据仍可能造成损失。备份应有独立保存位置,并验证恢复步骤与恢复耗时。对于订单 API,业务记录通常应进入独立数据库,图片进入对象存储;容器本地只承担可丢弃缓存与临时文件。若确实在单机卷中保存数据库,交付方案必须明确该单机故障范围。
CPU 和内存限制也需要分开理解。--cpus=0.5 是限制容器可使用的 CPU 时间额度,不是分配专属的半个物理核心。负载升高时,排队可能让延迟变长。--memory=128m 给出内存上限,但不是保证宿主机永远有足够空闲内存。超过限制可能触发内存不足处理,进程是否被杀、交换空间怎样参与,要结合主机能力、Docker 配置与实际状态判断。
进程数量限制能约束意外创建大量进程的影响,非 root 用户、只读根文件系统和减少额外权限则能缩小程序被利用后的操作范围。这些措施各自解决不同问题,不应只堆参数而不理解应用需求。只读根文件系统会让依赖本地写文件的程序启动失败,因此必须提前指定可写临时目录。运行限制需要经过功能验证,才是能执行的边界。
滑动内存和 CPU 参数时,把图中的资源曲线当成教学模型:同样的请求量在不同程序上可能产生不同结果。正确的方法是选定初值、施加可控负载、观察延迟与失败,再调整预算,而不是把交互中的数字复制成生产容量结论。
4 动手:构建并观测一个受限服务容器
下面在已经安装 Docker、能够运行 Linux 容器的实验机上操作。主机需要有可用的 8080 端口和 curl,首次构建还需要获取基础镜像和 Go 模块工具链所需内容的网络条件。本例只使用 Go 标准库,没有外部业务依赖。若使用远程云主机,请在该主机上执行验证请求;回环地址映射不会让个人电脑直接访问远程服务。
在空的实验目录保存 main.go。程序通过环境变量展示发布版本,并提供存活检查和演示订单接口。它不实现身份认证,因此实验入口仅绑定回环地址。真实订单接口必须另行设计认证、授权与审计,不能把这段演示代码直接开放到公网。
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
)
func main() {
version := os.Getenv("APP_VERSION")
if version == "" {
version = "demo-v1"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
})
mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
log.Printf("method=%s path=%s version=%s", r.Method, r.URL.Path, version)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"order_id": "demo-1001", "status": "accepted", "version": version,
})
})
srv := &http.Server{
Addr: ":8080", Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("starting version=%s addr=%s", version, srv.Addr)
log.Fatal(srv.ListenAndServe())
}
在同目录保存 Dockerfile。构建阶段生成静态程序,运行阶段保留证书、用户信息与程序,使用数字用户身份启动。证书支持将来访问 HTTPS 服务时进行证书校验;本例本身只接收 HTTP 请求。示例版本标签用于教学,正式交付要将经过评审的基础镜像引用固定到摘要,并建立更新计划。
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY main.go ./
RUN CGO_ENABLED=0 go build -trimpath -o /out/orders main.go
FROM alpine:3.23 AS runtime
RUN apk add --no-cache ca-certificates \
&& addgroup -g 10001 app \
&& adduser -D -u 10001 -G app app
COPY --from=build /out/orders /usr/local/bin/orders
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/orders"]
再保存 .dockerignore,内容为 .git、.env、*.pem、*.key、orders,每项独占 1 行。这里的排除只是基本防线;构建上下文不应存放秘密,仍需检查实际输入。EXPOSE 用来声明应用端口,不会自动发布端口。执行下面的命令,先建立实验网络,再启动带限制的服务。实验名称若与已有资源冲突,应另选名称,避免删除别人的容器或网络。
docker build -t orders:demo-v1 .
docker network create orders-lab-net
docker run -d --name orders-lab \
--network orders-lab-net \
-p 127.0.0.1:8080:8080 \
--cpus=0.5 --memory=128m --pids-limit=64 \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
--cap-drop=ALL --security-opt no-new-privileges=true \
--log-driver=json-file \
--log-opt max-size=10m --log-opt max-file=3 \
-e APP_VERSION=demo-v1 \
orders:demo-v1
curl -fsS http://127.0.0.1:8080/healthz
curl -fsS http://127.0.0.1:8080/orders
预期健康接口返回 ok,订单接口返回包含 demo-1001 和 demo-v1 的 JSON。容器处于运行状态只能说明主进程还在;HTTP 请求成功则进一步证明当前入口和处理链路可用。日志应该出现启动记录和订单请求记录。没有记录时,先检查是否实际请求了 /orders,不要为了获得日志而随意解除限制。
docker ps --filter name=orders-lab
docker logs --tail 20 orders-lab
docker stats --no-stream orders-lab
docker inspect --format '{{json .State}}' orders-lab
docker inspect --format '{{json .HostConfig.PortBindings}}' orders-lab
docker inspect --format '{{json .Mounts}}' orders-lab
docker image inspect --format '{{.Id}}' orders:demo-v1

把这些输出联系起来阅读:请求说明用户当前看到什么,状态说明进程是否退出,日志解释应用走到了哪里,资源读数说明当前消耗。docker stats 的单次读数不能代表峰值,更不能证明内存限制经过压测。镜像 ID 也不应被误写成仓库摘要;本地构建记录 ID,推送到受控仓库后再记录可拉取的摘要引用。记录时还要注明主机架构,避免把不同平台产物混为同一份交付。
最后练习普通停止与清理:执行 docker stop --time 10 orders-lab,查看退出状态,再执行 docker rm orders-lab 和 docker network rm orders-lab-net。本例没有持久卷,清理不会移除业务数据。这里的极简服务未实现完整的优雅关闭;生产应用应处理终止信号,停止接收新请求并等待在途请求结束。容器停止超时并不等于订单操作必然完整,需要应用协议配合。
5 编排前的单机运维取舍
单机部署的优点是路径短:主机、Docker、容器和入口容易对应,适合学习、开发环境及能够接受单机故障窗口的服务。代价是主机故障会同时影响其上的容器。启动 3 个副本也没有跨出这台主机的故障范围。是否需要更复杂的调度,应该从可接受停机时间、维护方式与恢复目标出发,而不是从容器数量出发。
重启策略能在部分退出场景后重新拉起进程,但不能修复错误配置,也不会保证业务可用。Docker 的健康状态与主进程退出是不同信号,单纯变为不健康不会自动触发普通重启策略。设置策略前先判断谁负责观察失败、是否需要人工介入、反复重启是否会放大数据库压力。本课实验不配置自动重启,便于保留故障现场。
多个服务可以用 Compose 文件声明镜像、网络、卷和启动参数,降低手工命令遗漏的概率。然而依赖服务已启动,不代表它已经能处理业务。应用仍要处理数据库暂不可用、连接失败和重试上限。Compose 简化单机协作,不能消除主机容量、备份和故障域的限制。把命令变成声明文件,是让配置可审查,不是让责任消失。
运维还要覆盖日志增长、磁盘空间与旧产物清理。限制应用内存不会限制所有主机磁盘占用;日志、镜像和卷都可能持续增长。实验设置日志轮转是给本机日志增加边界,正式环境还要考虑集中采集、保存期限与敏感字段处理。清理之前识别哪些镜像用于回滚、哪些卷保存数据,不能把“释放空间”简化成无差别删除。
单机升级可以先在其他回环端口启动候选容器,检查版本和健康,再由入口代理切换流量,观察后保留旧版本一段约定时间。没有代理时,停旧容器、启动新容器通常存在中断,应明确维护窗口。数据库变更若不兼容旧程序,即使旧镜像还在也可能无法回退。把“镜像能启动”和“业务能恢复”分开验收,是单机方案最重要的取舍之一。
6 常见坑:latest 标签掩盖回滚
团队把服务统一写成 orders:latest,每次发布都覆盖这个标签。某次升级后订单查询失败,值班人员重新执行旧文档中的启动命令,却又拉到了新镜像。文档没有记录旧摘要,主机上的旧镜像也已经清理,所谓回滚只剩下再次运行同样的名字。问题并非标签有特殊错误,而是把可变引用当成了历史版本证据。
latest 本质上是普通标签,不表示版本最新、质量最好或适合生产。重复使用的 stable 或 release 也有相同问题。已经运行的容器不会因为远端标签变化就自行替换程序;再次创建容器时拉到了什么内容,还与本地缓存和拉取策略有关。因此故障记录需要同时写出当时运行的镜像标识和创建参数,不能只截图仓库当前标签。
更可靠的发布记录把代码版本、发布标签、仓库摘要和运行配置关联起来。发布前保存已知可用版本,发布后通过响应版本、错误率与关键业务请求确认切换结果。回滚应指定旧摘要,并恢复与之兼容的配置;如果数据格式已经变化,先按预案判断能否回退程序。没有经过恢复演练的旧摘要,只能说明产物存在,不能证明恢复目标可以实现。
交互先展示可变标签怎样让“重跑旧命令”得到新内容,再展示固定摘要怎样找回旧产物。留意两条路径最终都要经过配置与数据兼容检查:摘要解决产物身份,不能自动把数据库回到过去。

排查时先保存证据再修改。访问失败先确认容器是否存在、是否运行;已退出则检查退出码、日志和状态中的内存不足标记。退出码 137 表示进程遭到强制终止等情况,不能仅凭它断言内存不足。进程仍运行则检查监听端口、映射地址、网络规则和应用响应。每次只验证明确假设,避免同时换镜像、加内存和开放端口,导致故障原因再也无法区分。
7 案例:宿主目录挂载扩大攻击面
某团队为了让订单 API 读取配置并导出报表,把宿主机的整个 /srv 挂载到容器中,而且保留默认可写权限。该目录除了当前服务文件,还有其他系统的备份和运维脚本。随后应用的文件读取接口出现漏洞,攻击者能访问容器进程有权限读取的挂载内容。若还能写入脚本目录,影响就可能延伸到容器以外的任务。
这里的关键不是“有挂载就不安全”,而是挂载把宿主机路径纳入了应用可达范围。容器内的目录名称不会改变底层文件的归属。非 root 用户可以减少权限,但仍需核对宿主文件的数字 UID、GID 与模式位;在常见的未启用用户映射环境中,相同数字身份可能直接对应宿主权限。不能因为用户名都叫 app 就推断访问一定正确,也不应通过 chmod 777 解决所有拒绝访问。
修正方案先拆分目的:配置只挂载实际需要的目录并设为只读,报表使用独立输出目录或受控存储接口,其他系统的文件不出现在容器视图中。只读挂载减少篡改机会,但无法阻止配置秘密被读取,所以配置目录本身仍要最小化。开启只读根文件系统也不会自动把单独声明的可写挂载变成只读,需要逐项检查挂载选项。
尤其不要为了在容器里调用 Docker 命令而随手挂载 Docker 套接字。能够向 Docker 守护进程发送管理命令,往往意味着能够创建高权限容器或挂载更多宿主路径,应按高权限管理入口对待。订单查询服务不需要这种能力。应用的文件身份、云 API 身份和 Docker 管理权限需要分别设计,不能用同一份“方便调试”的权限全部打通。
事件处理还包括停止暴露入口、保存必要证据、检查被访问文件、轮换可能泄露的凭证,并按可信来源重建服务。仅删除容器不能撤销已经发生的宿主文件修改,也不能让外泄凭证失效。恢复验收应确认应用只能读取约定配置、只能写入约定输出路径,并检查备份和其他服务目录没有重新进入挂载范围。
8 把镜像与运行参数写进交付方案
合格的交付方案应该让接手者无需猜测即可重建运行条件。镜像部分写清仓库、摘要、目标平台、源码版本和构建责任人;运行部分写清启动命令或声明文件、环境变量名称、资源限制、端口映射、用户身份、挂载和重启策略。秘密内容不写入方案正文,而是说明从哪里获取、由哪个身份读取、谁能轮换。
验收条件应使用可观察的行为。比如健康请求返回成功、订单演示请求包含预期版本、宿主入口只绑定回环地址、容器以 UID 10001 运行、根文件系统只读。还应记录日志位置与轮转方式、资源观察窗口及停止后的退出状态。资源限制配置存在,与服务在目标负载下符合延迟要求,是不同验收项;后者需要约定负载与测量方法。
数据部分记录哪些内容可以丢弃,哪些内容必须保留,以及数据位于卷、宿主目录还是外部服务。备份要写清执行者、频率、恢复位置与演练结果。网络部分则把主机入口、容器入口和上游访问分别说明。若应用需要访问数据库,应该列出目标地址、认证方式和所需最小权限,避免用“网络已通”代替数据库授权。
变更部分保存新旧版本对应关系、切换条件、观察时间、回滚触发条件和执行步骤。比如关键请求连续失败时先停止切换,核对配置与数据兼容性,再按旧摘要恢复服务。不要把任意数字当成通用阈值;阈值来自服务目标和实验数据。最后标注单机方案无法覆盖的主机故障,说明允许的恢复时间,读者才能判断方案是否符合业务需要。
9 从Docker 实战走向Kubernetes 架构(C28)
完成本课后,我们已经能够解释容器从哪里来、怎样启动、为什么失败以及如何替换。当服务需要跨多台主机放置、主机故障后重新安排实例、集中管理入口与配置时,手工维护这些关系会变得困难。下一课 Kubernetes 架构讨论的是怎样用声明和控制机制管理这类期望状态,而不是把本课参数换成更长的文件就算完成迁移。
镜像仍然是重要交付物,监听地址、健康端点、资源预算与数据边界也仍然有用。变化在于运行责任被更多组件协作承担:谁安排实例,谁发现实例失效,谁把请求送到可用实例,都需要明确。不要预先假定平台会补齐应用缺失的能力。无法安全停止的应用不会因为被调度而自动安全停止,写在容器临时目录的订单也不会自动获得可靠持久化。
进入下一课前,可以拿本课服务做口头验收:为什么相同镜像能使用不同端口与内存参数?为什么进程运行不等于订单接口可用?为什么保存旧镜像仍可能回滚失败?如果能分别从运行配置、观测层次和数据兼容性解释这些问题,就具备了理解编排的基础。后续再逐步讨论跨主机运行,无需在此深入内核隔离与调度实现。
放进方案里
本方案使用容器交付订单 API,运行在能够接受单机故障窗口的实验云主机上。发布记录关联源代码版本、构建记录、目标架构与仓库镜像摘要,启动时使用摘要引用。基础镜像来自约定来源,发布前完成依赖检查与功能验证;构建上下文排除凭证和临时文件,运行镜像不包含编译工具,应用使用 UID 10001 启动。
服务在容器内监听 8080,宿主机仅将回环地址的 8080 映射到该端口。若后续增加对外入口,需另行记录入口代理、网络规则与应用认证方式。初始限制为 0.5 CPU、128 MiB 内存和 64 个进程,根文件系统只读,临时目录使用有容量上限的内存挂载,去除额外能力并禁止获得新权限。资源数值作为实验起点,正式容量依据约定负载下的延迟、错误和资源峰值重新评估。
演示订单不持久化,业务上线前需指定独立数据存储及其最小访问权限。不得挂载宿主机共享业务目录或 Docker 管理套接字;配置只读提供,秘密通过受控渠道注入并支持轮换。日志写入标准输出,设置本机轮转边界,记录集中采集与保存期限的后续责任。验收保留健康请求、订单版本、进程状态、挂载清单与资源观察结果。
每次升级保存旧摘要和对应配置,先验证候选版本,再执行约定的流量切换。关键请求失败时暂停发布,确认数据兼容性后恢复旧版本,并重新验证业务请求。回滚演练记录耗时与实际结果;主机故障恢复另行准备,不把容器重启策略当成高可用承诺。方案同时标明执行人、观察窗口和清理范围,使接手者能够复现部署并判断是否恢复成功。
10 小结:下一步Kubernetes 架构(C28)
Docker 实战的结果,是把镜像身份、运行参数、访问边界、数据去向与故障证据连接起来。构建成功、容器运行、接口可用和业务可恢复,各自需要不同证据。限制参数只有经过验证,才能成为交付方案中可靠的约束。
自测时尝试解释 3 个变化:删除容器后哪些数据仍应保留;把端口绑定从回环地址改为全部地址会影响什么;旧标签被覆盖后怎样找到可回退的产物。下一课进入 Kubernetes 架构(C28),把这些单机边界带到跨主机的声明、调度与服务管理中。