把 TaskBoard 交付成镜像|Kubernetes 项目课 | 自在学
把 TaskBoard 交付成镜像
Kubernetes 不会替我们编译源码,它接收的是可以由容器运行时启动的镜像。这一章先完成 TaskBoard 的第一个可交付版本:写出一个不依赖第三方 Python 包的 HTTP 服务,用非 root 用户封装成 taskboard:1.0.0,再通过实际请求确认镜像内的程序确实能工作。
进入本章时,welearn-course 集群和系统 Pod 已经 Ready,但业务侧还是空的;课程目录只有工具和 kubeconfig。完成后,课程目录会新增 TaskBoard 源码与 Dockerfile,Docker 的镜像存储会新增 taskboard:1.0.0,而用于验收的独立容器已经先停止、再按名称明确删除。Kubernetes 中仍不会出现 TaskBoard Pod——把镜像交给集群是下一章的工作。
我们先在容器层证明程序可运行,再引入 Kubernetes。这样安排是在建立故障边界:如果相同镜像连独立容器都无法启动,先修源码、Dockerfile 或端口,不应把问题归因于 Scheduler、Pod 网络或 Service。
先理解从源码到容器的交付链
源码、镜像和容器是三个不同阶段。
app.py + Dockerfile
│ docker build 读取构建上下文
▼
taskboard:1.0.0 镜像
│ docker run 创建网络、可写层和进程
▼
taskboard-standalone 容器
app.py 表达业务行为。Dockerfile 是构建配方,按顺序声明基础文件系统、环境变量、工作目录、文件复制、用户和入口。镜像是构建结果,内容在运行前保持只读。容器是镜像的一次实例化,会增加自己的进程、网络和临时可写层。
这几个边界直接影响 Kubernetes:Pod 清单引用的是镜像名,不会读取你编辑器里的 app.py;修改源码后若没有重新构建并使用新镜像,运行行为不会变化;删除一个容器不会删除镜像,重建容器也不会保留旧容器可写层中的临时改动。
为什么先设计运行接口
容器化不是把任意程序塞入镜像就结束。平台需要知道程序监听哪个端口、如何判断存活和就绪、怎样注入变化配置、从哪里读取日志、终止信号能否到达主进程。若这些接口没有设计清楚,Kubernetes 即使成功拉起进程,也无法可靠地分流或诊断。
TaskBoard 第一版主动留下这些接口:监听固定容器端口 8080;把日志写到标准输出;从环境变量读取版本和配置;提供 /healthz 与 /readyz;主进程直接接收停止信号。后面配置探针、ConfigMap 和滚动发布时,会复用同一个镜像,而不是每章重写程序。
为什么不直接使用现成示例镜像
用 Nginx 可以更快看到 Running,但它隐藏了应用与平台之间的契约。自己完成一个很小的 API,我们才能观察版本怎样进入镜像、Pod 身份怎样注入进程、Redis 故障怎样改变就绪结果,以及 HPA 的 CPU 请求从哪里产生。
课程刻意不引入 Web 框架和第三方 Redis 包,是为了减少下载与依赖变量,不代表生产服务应该手写协议客户端。生产代码应使用成熟库、连接池、超时、重试策略、指标和完整测试;本项目只保留足以让 Kubernetes 行为可观察的业务面。
建立项目工作目录
把源码和后续 Kubernetes 清单放在同一个项目树中,能让版本、路径和清理范围都更明确。本章使用 taskboard/app 保存应用;下一章会在相邻的 taskboard/k8s 中保存清单。
本章开始前,COURSE_ROOT 的归属已在上一章确认。下面只在这个边界中创建 taskboard/app,mkdir -p 会补齐中间目录,路径已存在时也不会清空内容。cd 决定稍后 Docker 构建上下文的位置,pwd 是执行写文件前的防误操作核对。命令会创建目录,不会连接 Docker 或 Kubernetes。预期当前路径以 welearn-kubernetes-course/taskboard/app 结尾。
实操
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if ! { [ -d " $COURSE_ROOT " ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - " $COURSE_ROOT /.course-owned" ; }; then
echo '停止:课程目录标记校验失败,未创建项目文件' >&2
exit 1
fi
mkdir -p " $COURSE_ROOT /taskboard/app"
cd " $COURSE_ROOT /taskboard/app"
pwd
结果展示
<项目工作目录>/welearn-kubernetes-course/taskboard/app
路径前缀取决于操作系统和用户目录,结尾一致即可。
这个输出证明 shell 进入了预定目录,<项目工作目录> 只是文档中的动态前缀,不是要输入的文字。如果结尾不是 taskboard/app,先停止并运行 pwd、ls -la 判断当前位置;在错误目录创建 Dockerfile 会改变构建上下文,可能把无关文件发送给 Docker daemon。
生产项目通常还会用 .dockerignore 排除版本库、缓存、密钥和构建产物。本课程目录只包含两个小文件,因此没有额外引入它;不要据此把“没有 .dockerignore”当成通用最佳实践。
写出可以被探测的 HTTP 服务
TaskBoard 暂时只有一个 API 进程,但它已经为后面的 Kubernetes 能力留好了接口:
/ 与 /api/info 返回版本、欢迎语、Pod 名和 Namespace。
/healthz 只回答进程是否存活,适合存活探针。
/readyz 判断是否可以接收流量,适合就绪探针;当 REQUIRE_REDIS_READY=true 时还会检查 Redis。
/api/tasks 使用 Redis 的 RESP 协议读写任务,后续接入持久化存储时不必更换镜像。RESP 是 Redis 客户端与服务端在 TCP 连接上传递命令和返回值的序列化协议;代码会把 LPUSH、LRANGE 等命令编码成带长度的字节序列,再解析 Redis 响应。
/api/work 制造一段有上限的 CPU 工作量,后面可用于观察指标。
这里直接实现了最小 Redis 客户端,而没有安装额外依赖。这样做是为了让第一版镜像足够小、构建过程可复现;它不是要替代成熟的 Redis 客户端库。
先弄清楚每类接口在回答什么
/api/info 是业务诊断接口,返回构建版本、欢迎语和运行身份。它能回答“我请求到了哪个实例、这个实例是什么版本”。后面副本增多或滚动更新时,这些字段会成为业务层证据。
/healthz 是存活信号,只确认 HTTP 进程还能处理基本请求。把 Redis 连接放进存活判断会有风险:Redis 短暂不可用时,所有 API 容器可能被反复重启,反而放大故障。因此第一版存活检查不依赖外部服务。
/readyz 是流量资格信号。进程活着却暂时不应接收请求时,它返回非 2xx。代码既支持用标记文件主动制造未就绪,也支持在 REQUIRE_REDIS_READY=true 时检查 Redis。后面 Service 只应把 Ready Pod 放入端点。
/api/tasks 才是真正依赖 Redis 的业务接口。Redis 不可用时返回 503,明确告诉调用者存储链路失败。/api/work 在最多两秒的边界内做 CPU 工作,专门为指标实验提供可控负载,不能照搬为生产接口。
环境变量为什么适合运行配置
镜像一旦构建,应尽量在不同环境复用。欢迎语、Pod 名、Namespace、Redis 地址和密码都不应写死在源码或重新烘焙进每个环境的镜像。程序在启动时读取环境变量,Docker 可以用 -e 注入,Kubernetes 后面会用清单、Downward API、ConfigMap 和 Secret 注入。
环境变量也有局限:进程通常要重启才能读取新值;敏感值可能通过调试信息暴露;结构复杂或需要热加载的配置更适合挂载文件或配置服务。课程使用它,是因为能清楚展示同一镜像如何在不同 Pod 中取得不同运行身份。
接下来会写入项目的唯一 Python 源文件。它只使用 Python 标准库:ThreadingHTTPServer 接收 HTTP 请求,socket 直接发送 RESP 命令,os.getenv 读取运行配置。代码启动后监听 0.0.0.0:8080;0.0.0.0 表示容器所有网络接口,若只监听 127.0.0.1,容器外的端口转发无法到达。请在当前目录新建 app.py,保存下面的完整源码。
显示完整 app.py import json
import os
import socket
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
APP_VERSION = os.getenv( "APP_VERSION" , "dev" )
WELCOME_MESSAGE = os.getenv( "WELCOME_MESSAGE" , "你好,Kubernetes" )
POD_NAME = os.getenv( "POD_NAME" , "unknown" )
POD_NAMESPACE
源码把变化的运行信息都放在环境变量中,同一个镜像便能在容器、Pod 和 Deployment 中复用。POD_NAME 与 POD_NAMESPACE 还会帮助我们确认请求到底到达哪个副本。
沿着一次请求读代码
服务启动时,模块顶层读取环境变量并保存在常量中;这说明更新环境变量后需要创建新进程。ThreadingHTTPServer 为并发请求建立处理线程,Handler 根据 URL 路径分支,write_json 统一编码 UTF-8 JSON 并写入准确的 Content-Length。
请求 /api/info 时不会访问 Redis,所以在 Redis 尚未部署的本章也能成功。请求 /api/tasks 时,redis_command 创建有 1.5 秒超时的 TCP 连接,可选发送 AUTH,再发送 LRANGE 或 LPUSH。错误被转换为 503,而不是让进程崩溃。这个区别很重要:依赖失败与进程死亡是两种状态,Kubernetes 对它们的处理也不同。
log_message 把访问日志写到标准输出。容器平台默认收集 stdout/stderr,应用不用猜宿主上的日志文件路径。JSON 日志便于后续检索,不过课程输出只展示关键行,没有构建完整日志管道。
这份源码的生产边界
内置 HTTP Server 与手写 RESP 客户端适合课程,不提供成熟框架的连接池、TLS、请求大小限制、认证、结构化错误体系和停机排空。ThreadingHTTPServer 也没有实现 Kubernetes 终止前的完整优雅下线逻辑。学习目标是看懂平台契约,不是把这一百多行代码当成生产任务系统。
若编辑后 Python 因缩进或复制错误无法启动,构建镜像仍可能成功,因为 Dockerfile 的 COPY 不会执行语法检查。稍后容器日志会显示 Python 异常。修复顺序应是先核对 app.py,重新构建明确版本镜像,再运行同样的接口验收。
用非 root 用户封装程序
Dockerfile 决定镜像如何构建以及容器默认怎样启动。基础镜像固定到 Python 3.13 的 Alpine 变体;ARG APP_VERSION 在构建时写入镜像;USER 10001:10001 让进程不以 root 身份运行。
固定数字 UID/GID 比只写用户名更容易与 Kubernetes 的 securityContext 对齐。ENTRYPOINT 使用 JSON 数组形式,使 Python 直接成为容器主进程并正确接收停止信号。
逐行理解构建配方
FROM python:3.13-alpine@sha256:399babc8b49529dabfd9c922f2b5eea81d611e4512e3ed250d75bd2e7683f4b0 提供 Python 解释器和 Alpine 用户空间。它不是内核;容器运行时使用节点内核。标签帮助人识别版本,摘要把多架构索引锁定到课程验证过的内容;Docker 会从索引中选择当前 CPU 对应的 amd64 或 arm64 清单。生产仍需配合可信仓库、漏洞扫描和有计划的摘要升级,固定摘要不是永不更新依赖。
ARG APP_VERSION 只在构建阶段接收参数。随后的 ENV APP_VERSION=${APP_VERSION} 把值写入镜像运行配置,所以容器默认能返回构建版本。
PYTHONDONTWRITEBYTECODE=1 避免写 .pyc,PYTHONUNBUFFERED=1 让日志及时进入 stdout。
WORKDIR /app 设置后续构建指令和默认运行目录,COPY 只复制需要的源码。
RUN addgroup ... && adduser ... 在镜像中创建固定 UID/GID。两条操作放在同一个构建层,并只在前一步成功后继续。
USER 10001:10001 改变后续默认运行身份。没有额外 Linux capabilities 时,应用即使被利用,也不应默认拥有容器内 root 身份。
EXPOSE 8080 是镜像元数据,不会自动发布宿主端口。真正的端口映射由 docker run -p 或 Kubernetes 网络对象完成。
JSON 数组形式的 ENTRYPOINT 不经 shell 包装,Python 成为容器 PID 1,更直接地接收 SIGTERM。这里只证明信号能送达进程;当前示例程序没有注册终止处理器,也没有停止接收新请求、等待在途请求完成等排空逻辑,所以不能把“收到 TERM”写成“应用完成了优雅下线”。
容器内非 root 不是完整安全方案。镜像依赖、内核隔离、capabilities、只读文件系统、seccomp、网络策略和 Kubernetes securityContext 仍需要单独控制。第十章会把镜像默认用户与集群安全策略对齐。
现在要把这些选择固化进构建文件。请在同一目录新建 Dockerfile,保存下面的完整内容。这个文件不会立即创建镜像,只有稍后的 docker build 才会把每条指令转成构建步骤。
FROM python:3.13-alpine@sha256:399babc8b49529dabfd9c922f2b5eea81d611e4512e3ed250d75bd2e7683f4b0
ARG APP_VERSION=1.0.0
ENV APP_VERSION=${APP_VERSION} \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py /app/app.py
RUN addgroup -g 10001 taskboard \
&& adduser -D -u 10001 -G taskboard taskboard
USER 10001:10001
EXPOSE 8080
ENTRYPOINT [ "python" , "/app/app.py" ]
现在核对两个输入文件都在构建上下文中。我们此时执行 ls,是为了在耗时构建前证明相对路径正确。命令只读取当前目录中两个指定名称;预期同时列出 app.py 与 Dockerfile,否则 Docker 的 COPY app.py 会失败。
实操
结果展示
两行证明文件名和大小写符合 Dockerfile。输出顺序由 ls 排序,不影响构建。如果出现 No such file or directory,先用 pwd 确认仍在 taskboard/app,再检查编辑器是否把文件保存成 Dockerfile.txt。下一条诊断命令是 ls -la,不要通过修改 COPY 路径掩盖工作目录错误。
构建固定版本镜像
镜像标签应表达一次明确交付。本课程使用 taskboard:1.0.0,不使用含义会漂移的 latest。构建参数也显式传入 1.0.0,使 API 返回的版本与镜像标签一致。
标签是镜像引用的可读别名,不是内容本身。重新用同一标签构建,标签可以移动到新镜像;已经存在的容器仍使用创建时解析到的镜像内容。因此真实发布通常还会记录不可变摘要,并让仓库阻止覆盖版本标签。课程后面的 taskboard:2.0.0 会使用新标签展示更新,不覆盖 1.0.0。
构建前确认标签归属
taskboard:1.0.0 可能在课程开始前就由另一个项目创建。课程计划使用同名标签,不等于课程拥有覆盖或删除它的权限。构建前先读取 Docker:标签存在但 .taskboard-1.0.0-owned 不存在时立即停止;标记存在时,其内容必须与当前标签解析到的完整镜像 ID 逐字节一致,否则也停止。
首次成功构建后,命令把 docker image inspect 返回的 sha256:... 镜像 ID 写入 owned 标记。安全恢复时,标签和标记 ID 一致就复用,不重复构建;标签已经不存在但标记格式有效时,可以重新构建并更新 ID。这样最终清理不仅知道“课程曾用过这个名称”,还能在删除前确认该标签仍指向课程记录的镜像内容。
标签可以移动,owned 文件也不能只写 yes。把构建后的镜像 ID 存入标记,才能发现课程运行期间同名标签被其他操作重新指向的情况。
Docker 构建会把当前目录 . 作为构建上下文交给 BuildKit。BuildKit 负责解析 Dockerfile、安排步骤、缓存中间结果并导出镜像;输出中的并行步骤和 CACHED 就来自它。默认 builder 的缓存可能与其他项目共享,无法在结课时证明哪些缓存属于课程,所以这里建立专属的 docker-container builder,而不向默认 builder 写缓存。
这个 builder 使用固定名称 welearn-course-builder,节点容器为 buildx_buildkit_welearn-course-builder0,状态卷为 buildx_buildkit_welearn-course-builder0_state。BuildKit driver 镜像也固定为多架构摘要 sha256:016860...90c9f。创建前,.buildkit-driver-image-preexisting 以严格 yes\n 或 no\n 记录该摘要是否已存在;结课时只有原先为 no、builder 已删除且没有其他容器使用时,才会删除这个精确镜像引用。
.buildx-builder-owned 不是一个简单的 yes。它逐行记录 builder、node、容器名、完整容器 ID、状态卷名、卷创建时间、driver 镜像引用和 driver image ID。复用时要让 buildx 元数据、容器 ID、容器名称、镜像、挂载卷和卷创建时间全部一致。.buildx-builder-attempt 在创建前先写 pending;若创建命令失败但能够取得完整事实,就把真实 ID 写回,供恢复或最终清理使用。只有 pending 而资源已经出现时不自动认领。
专属 builder 不会自动把结果放进 Docker 镜像列表,所以构建命令显式使用 --load。它会把单平台的最终 taskboard:1.0.0 导入 Docker 镜像存储;Python 基础镜像和中间缓存留在专属状态卷中。--build-arg APP_VERSION=1.0.0 给 Dockerfile 的 ARG 赋值,--tag 建立最终名称,--builder 则避免依赖当前默认 builder。
下面先精确复查课程根标记并回到 taskboard/app,建立或验证专属 builder,再核对 TaskBoard 标签与 owned 标记。预期首次运行会记录 builder 容器 ID,随后把结果命名为 docker.io/library/taskboard:1.0.0 并记录镜像 ID;步骤编号和耗时是动态值。
实操
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if ! { [ -d " $COURSE_ROOT " ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - " $COURSE_ROOT /.course-owned" ; }; then
echo '停止:课程目录标记校验失败,未构建镜像' >&2
exit 1
fi
cd " $COURSE_ROOT /taskboard/app"
TASKBOARD_IMAGE = 'taskboard:1.0.0'
TASKBOARD_OWNED =
结果展示
buildkitDriverImagePreexisting=no
builderContainerId=<64 位小写十六进制容器 ID>
[+] Building 2.7s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load metadata for docker.io/library/python:3.13-alpine@sha256:399b...
=> [1/4] FROM docker.io/library/python:3.13-alpine@sha256:399b...
=> [2/4] WORKDIR /app
=> [3/4] COPY app.py /app/app.py
=> [4/4] RUN addgroup -g 10001 taskboard ...
=> exporting to image
=> => naming to docker.io/library/taskboard:1.0.0
课程镜像归属已记录:sha256:6b0269772bb82d567d73c41ee795e4f5a9940314cee2e22db15b144590cde2e3
耗时、容器 ID 和缓存标记会变化,TaskBoard 镜像 ID 也会随源码与构建内容变化。稳定关系有三组:builder 容器事实与八行 owned 账本一致;结果被 --load 命名为 taskboard:1.0.0;镜像 owned 标记与该标签当前解析结果一致。driver 镜像基线若为 yes 也不是错误,它表示结课时应保留原有引用。安全恢复时会验证并复用同一个 builder 与 TaskBoard 镜像,不重复写默认共享缓存。
输出按 Dockerfile 层展示构建证据:读取定义与基础镜像元数据成功,工作目录和源码复制完成,用户创建命令成功,最后导出并命名镜像。CACHED 可能出现,表示输入未变时复用已有层,不是漏执行。若失败在 load metadata,优先检查镜像仓库网络;失败在 COPY app.py,检查构建上下文;失败在 RUN addgroup,检查基础镜像或命令语法。
BuildKit 的步骤编号与总耗时是动态值。课程展示的 2.7s 只记录一次实际构建,不是性能目标。下一条诊断命令可使用同一 docker buildx build --builder welearn-course-builder --load 输出的失败步骤;不要改回默认 docker build,否则缓存边界会再次变得不可归属。构建未成功时也不要继续 docker run,否则可能误用以前残留的同名镜像。
镜像创建成功还不代表安全配置正确。下面用 docker image inspect 读取镜像元数据中的默认用户和入口。--format 精确选择 .Config.User 与 .Config.Entrypoint,json 保留入口数组边界。命令只读镜像配置,不创建容器;预期得到 10001:10001 与 Python 入口。
实操
docker image inspect taskboard:1.0.0 \
--format 'user={{.Config.User}} entrypoint={{json .Config.Entrypoint}}'
结果展示
user=10001:10001 entrypoint=["python","/app/app.py"]
这个结果证明默认启动身份和入口已经进入镜像配置,而不是依赖运行者临时补参数。
user=10001:10001 证明 Dockerfile 的 USER 生效,入口数组证明没有额外 shell 包装。它仍不能证明 Python 源码能启动,所以还需要运行时验收。如果 user 为空或为 0,用 docker history taskboard:1.0.0 和 Dockerfile 检查是否构建了旧内容;如果入口不同,确认没有误用同名旧标签。
运行并请求容器
先把容器的 8080 端口只映射到回环地址 127.0.0.1:18080。欢迎语通过环境变量注入,镜像本身不变。本章故意不使用 --rm:若 Python 启动后立即崩溃,已退出容器仍会出现在 docker ps -a 中,日志也能继续读取。验收完成后再按名称停止和删除,清理边界同样明确。
这一步从静态镜像进入动态运行状态,但会把 docker run -d 拆成 docker create 与 docker start。create 先生成容器实例并返回完整 ID,课程能在任何启动错误发生前把身份记账;start 才启动进程并实际绑定端口。--name 建立可读名称,-p 127.0.0.1:18080:8080 把回环端口 18080 转发到容器端口 8080,-e 只覆盖这个实例的欢迎语,不改变镜像。
命令先预查 Docker 已发布的 18080 端口,再在 create 前写入 .taskboard-standalone-attempt。create 成功后立即把完整 64 位 ID 写回 attempt,核对 ID、精确名称和镜像引用后才提升为 .taskboard-standalone-owned,然后 start。EXIT 与 HUP/INT/TERM trap 会在 create 已生成容器但主流程尚未来得及记账的窄窗口中尝试补录完整 ID;只剩 pending 时仍不会认领未知容器。这样端口绑定失败或后面的健康检查退出,结课清理仍知道可以处理哪一个实例。
同名只是一条查询线索,不是归属。命令发现同名容器却没有账本时会停止;有账本时也必须让当前完整 ID 与记录逐字节一致。若账本存在而容器已经不存在,则把它视为上一次删除已完成,移除过期账本后重新创建。Docker daemon 或权限错误会原样输出,不会被当成“容器不存在”。
实操
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if ! { [ -d " $COURSE_ROOT " ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - " $COURSE_ROOT /.course-owned" ; }; then
echo '停止:课程目录标记校验失败' >&2
exit 1
fi
STANDALONE_NAME = 'taskboard-standalone'
STANDALONE_IMAGE = 'taskboard:1.0.0'
STANDALONE_MARKER = "
结果展示
standaloneContainerId=ab3c48bfb17c7f4cd9580fab56dc99f04eeff2522e943c36f087805b9efa2604
容器 ID 是动态值,你看到不同字符串是正常的。稳定条件是它为 64 位小写十六进制,并与账本和 docker inspect 返回的 .Id 完全一致。
这串 ID 只证明 Docker 接受了创建请求并完成归属记录,不证明进程持续运行。后台容器若启动后立刻崩溃,docker run -d 仍可能先打印 ID。下一条 HTTP 请求会验证端口和进程;如果连接被拒绝,先运行 docker ps -a --filter name=taskboard-standalone 查看状态,再读 docker logs taskboard-standalone。ID 不应写进 Kubernetes 清单,但必须写进清理账本,因为它是实例身份而不是部署配置。
/api/info 会汇总镜像版本和运行配置。此时还没有 Kubernetes 为进程注入 Pod 身份,也没有提供 Redis 密码,所以预期 pod、namespace 为 unknown,redisConfigured 为 false。
后台启动命令返回时,Python 可能还没有完成端口绑定。下面先用最多 20 次、每次间隔 1 秒的 /healthz 请求建立有边界的等待;每次 curl 都把 HTTP 4xx/5xx 当作失败,20 次都失败时会显示容器状态和日志,然后真正停止当前命令链。健康检查通过后,再以同样严格的失败语义请求 /api/info。--silent 隐藏进度条,--show-error 保留最终错误,--fail-with-body 让错误状态码返回非零同时保留响应体。
请求路径会经过端口映射进入容器,由 Python Handler 处理。它只读取运行配置,不写任务数据,也不会连接 Redis;这正适合在存储尚未部署时验收第一版镜像。
实操
for attempt in { 1..20} ; do
if curl --fail-with-body --show-error --silent \
http://127.0.0.1:18080/healthz > /dev/null 2>&1 ; then
echo 'TaskBoard 健康检查已通过'
break
fi
if [ " $attempt " -eq 20 ]; then
echo '停止:20 秒内健康检查未通过' >&2
docker ps -a --filter name=taskboard-standalone
结果展示
TaskBoard 健康检查已通过
{"service":"taskboard","version":"1.0.0","message":"你好,容器已经运行","pod":"unknown","namespace":"unknown","redisConfigured":false}
返回值同时证明端口映射、HTTP 处理器、构建版本和环境变量都生效。
逐项看证据:version 为 1.0.0,说明构建参数进入运行环境;message 等于 docker run -e 的值,说明同一镜像接受运行配置;两个 unknown 说明当前没有 Kubernetes Downward API;redisConfigured:false 说明未注入密码。JSON 字段顺序不应当作为判断条件,真正稳定的是键和值。
若返回 404 或 503,curl 会以非零状态退出,不会把错误响应误当成功;检查 URL、容器日志和依赖状态。若连接拒绝,检查 docker ps -a 中容器是 Running 还是 Exited,以及端口占用;若版本仍是 dev,说明构建参数没有进入 ENV 或运行了旧镜像。下一条诊断命令是 docker inspect taskboard-standalone --format '{{json .Config.Env}}',它可以确认实际容器收到的环境变量。
核对进程身份与日志
Dockerfile 声明非 root 用户之后,容器中的进程应以 UID/GID 10001 运行。镜像元数据只能证明默认配置,docker exec 则进入已经运行的容器创建一个短命令进程,能验证运行时身份。exec 不会修改镜像;这里的 id 只读取用户、组。预期用户和组都不是 0。
实操
docker exec taskboard-standalone id
结果展示
uid=10001(taskboard) gid=10001(taskboard) groups=10001(taskboard)
UID/GID 数字是权限判断的核心,括号中的名称来自容器 /etc/passwd 与 /etc/group。两者都为 10001,证明 Docker 没有在运行时覆盖用户。若看到 uid=0(root),检查 docker inspect 的 .Config.User 以及运行命令中是否增加了 --user; 后续 Kubernetes 安全策略会拒绝不符合非 root 要求的工作负载。
日志是部署后的第一条诊断证据。程序启动时输出版本,每次请求又输出客户端、请求行和 Pod 身份。docker logs 读取容器 stdout/stderr 的已有记录,不进入进程,也不读取应用自建文件。此时执行,是为了把启动事件与刚才的 HTTP 响应交叉验证;预期能看到启动事件与 GET /api/info。
实操
docker logs taskboard-standalone
结果展示
{ "event" : "server_started" , "port" : 8080 , "version" : "1.0.0" }
{ "client" : "172.17.0.1" , "request" : " \" GET /healthz HTTP/1.1 \" 200 -" , "pod" : "unknown" }
{ "client" : "172.17.0.1" , "request" : " \" GET /api/info HTTP/1.1 \" 200 -" , "pod"
client 地址由 Docker 网络分配,是动态值。稳定证据是服务以 1.0.0 启动,并为请求记录了 HTTP 200。
第一行来自进程启动,第二行是有边界等待产生的健康请求,第三行是实际信息请求。两个 HTTP 200 分别证明健康端点和 /api/info Handler 成功响应,pod: unknown 与响应中的身份一致。客户端地址通常是 Docker 桥接网络网关,网络实现不同就会变化;不要把 172.17.0.1 写成健康判断。
若只有启动行,说明请求没有到达这个容器,应检查 curl 地址和端口映射;若日志有 Python traceback,先从最后一行异常类型定位源码;若容器重启,独立 Docker 不会像 Deployment 那样自动维持它,本章应先修好镜像再进入 Kubernetes。
停止本章容器
容器只是镜像的一次运行实例。本章保留容器记录到验收完成,是为了让启动崩溃仍可诊断;现在要把停止与删除拆成两个可观察动作。清理先重新查询同名集合,再核对账本中的完整 ID、精确名称与镜像引用。容器已不存在算终态;ID 被替换或查询失败则停止。它不会删除 taskboard:1.0.0 镜像,下一章仍会把这个镜像加载进 kind 节点。
docker stop 先向容器主进程发送 SIGTERM,在 Docker 的停止超时内等待它退出,超时才发送 SIGKILL。因为 Dockerfile 使用 exec 形式入口,Python 是 PID 1,可以直接收到 TERM;但示例程序没有信号处理与请求排空逻辑,所以这里只能称为“平台先 TERM、后按超时强制终止”的停止流程,不能声称应用完成了优雅下线。容器停止后,docker rm 再删除容器记录、可写层和网络端点,18080 端口随之释放。
实操
COURSE_ROOT = " $HOME /welearn-kubernetes-course"
if ! { [ -d " $COURSE_ROOT " ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - " $COURSE_ROOT /.course-owned" ; }; then
echo '停止:课程目录标记校验失败' >&2
exit 1
fi
STANDALONE_NAME = 'taskboard-standalone'
STANDALONE_IMAGE = 'taskboard:1.0.0'
STANDALONE_MARKER = "
结果展示
taskboard-standalone
taskboard-standalone
container/taskboard-standalone=absent
本章留下的成果是源码、Dockerfile 和固定标签镜像,而不是一个需要长期维护的临时容器。
第一行容器名来自 docker stop,第二行来自 docker rm;最后一行只在按完整 ID 验证 No such object 后出现。若容器早已删除,会显示 already-absent 并清掉过期账本。若 stop 提示容器已经退出,仍可执行精确 docker rm;若任何事实不一致,不要改用全局容器清理命令。
本章形成的项目状态
现在的项目边界很明确:
taskboard/app/app.py 保存 HTTP、探针、Redis 与负载接口。
taskboard/app/Dockerfile 固化 Python 运行时、版本、非 root 用户和入口。
Docker 镜像存储中有经过接口与身份验收的 taskboard:1.0.0。
$COURSE_ROOT/.taskboard-1.0.0-owned 精确保存当前课程镜像 ID;最终清理必须先确认标签仍解析到这个 ID。
.buildx-builder-owned 用八行账本绑定专属 builder 容器、缓存卷和固定 driver image ID;.buildkit-driver-image-preexisting 保存 driver 镜像创建前基线。Python 基础镜像与中间缓存位于这个专属卷,不进入默认共享 builder。
.taskboard-standalone-owned 在运行期间保存验收容器的完整 ID;正常收尾已在 ID、名称和镜像一致后删除容器及账本,18080 端口不再占用。若中途退出,第十一章会执行同一套核对作为兜底。
Kubernetes 仍只有上一章的系统对象,没有 TaskBoard Namespace 或 Pod。
进入下一章前,确认你能解释三个不同结果:构建成功证明交付物生成,image inspect 证明默认配置进入镜像,HTTP 与日志证明容器中的进程确实按配置工作。第四章会把这个经过容器层验收的镜像导入 kind 节点,再新增 Kubernetes 对象层证据。
检查你的理解
1 关于 taskboard:1.0.0 镜像,下列哪些说法正确?
2 为什么课程不使用 taskboard:latest 作为交付标签?
A latest 不能被 Docker 运行 B latest 会让同一个名字指向的内容发生漂移 C Kubernetes 只支持数字标签 D latest 会强制容器以 root 运行
3 为什么 /healthz 不直接把 Redis 可用性作为本课程的存活条件?
A Kubernetes 不支持访问 Redis B 避免下游短暂故障触发 API 容器反复重启并放大故障 C 健康接口不能返回 JSON D Redis 只能由 root 用户访问
4 docker run 成功打印容器 ID 后,还需要哪些证据才能说明本章镜像可用?
=
os.getenv(
"POD_NAMESPACE"
,
"unknown"
)
REDIS_HOST = os.getenv( "REDIS_HOST" , "redis" )
REDIS_PORT = int (os.getenv( "REDIS_PORT" , "6379" ))
REDIS_PASSWORD = os.getenv( "REDIS_PASSWORD" , "" )
REQUIRE_REDIS_READY = os.getenv( "REQUIRE_REDIS_READY" , "false" ).lower() == "true"
NOT_READY_FILE = os.getenv( "NOT_READY_FILE" , "/tmp/not-ready" )
class RedisError ( RuntimeError ):
pass
def read_resp (stream):
prefix = stream.read( 1 )
if not prefix:
raise RedisError( "Redis closed the connection" )
line = stream.readline().rstrip( b " \r\n " )
if prefix == b "+" :
return line.decode( "utf-8" )
if prefix == b "-" :
raise RedisError(line.decode( "utf-8" ))
if prefix == b ":" :
return int (line)
if prefix == b "$" :
size = int (line)
if size == - 1 :
return None
value = stream.read(size)
stream.read( 2 )
return value.decode( "utf-8" )
if prefix == b "*" :
return [read_resp(stream) for _ in range ( int (line))]
raise RedisError( f "Unsupported RESP prefix: { prefix !r } " )
def send_redis_command (sock, stream, * parts):
encoded = [ str (part).encode( "utf-8" ) for part in parts]
payload = [ f "* {len (encoded) }\r\n " .encode( "ascii" )]
for part in encoded:
payload.append( f "$ {len (part) }\r\n " .encode( "ascii" ))
payload.append(part + b " \r\n " )
sock.sendall( b "" .join(payload))
return read_resp(stream)
def redis_command ( * parts):
with socket.create_connection(( REDIS_HOST , REDIS_PORT ), timeout = 1.5 ) as sock:
stream = sock.makefile( "rb" )
if REDIS_PASSWORD :
send_redis_command(sock, stream, "AUTH" , REDIS_PASSWORD )
return send_redis_command(sock, stream, * parts)
class Handler ( BaseHTTPRequestHandler ):
server_version = "TaskBoard/1"
def write_json (self, status, payload):
body = json.dumps(payload, ensure_ascii = False , separators = ( "," , ":" )).encode( "utf-8" )
self .send_response(status)
self .send_header( "Content-Type" , "application/json; charset=utf-8" )
self .send_header( "Content-Length" , str ( len (body)))
self .end_headers()
self .wfile.write(body)
def do_GET (self):
parsed = urlparse( self .path)
if parsed.path in ( "/" , "/api/info" ):
self .write_json(
200 ,
{
"service" : "taskboard" ,
"version" : APP_VERSION ,
"message" : WELCOME_MESSAGE ,
"pod" : POD_NAME ,
"namespace" : POD_NAMESPACE ,
"redisConfigured" : bool ( REDIS_PASSWORD ),
},
)
return
if parsed.path == "/healthz" :
self .write_json( 200 , { "status" : "alive" })
return
if parsed.path == "/readyz" :
if os.path.exists( NOT_READY_FILE ):
self .write_json( 503 , { "status" : "not ready" , "reason" : "marker file exists" })
return
if REQUIRE_REDIS_READY :
try :
redis_command( "PING" )
except ( OSError , RedisError) as exc:
self .write_json( 503 , { "status" : "not ready" , "reason" : str (exc)})
return
self .write_json( 200 , { "status" : "ready" })
return
if parsed.path == "/api/tasks" :
try :
values = redis_command( "LRANGE" , "taskboard:tasks" , 0 , - 1 )
tasks = [json.loads(value) for value in values]
self .write_json( 200 , { "items" : tasks, "count" : len (tasks)})
except ( OSError , RedisError, json.JSONDecodeError) as exc:
self .write_json( 503 , { "error" : "storage unavailable" , "detail" : str (exc)})
return
if parsed.path == "/api/work" :
query = parse_qs(parsed.query)
milliseconds = min ( max ( int (query.get( "ms" , [ "200" ])[ 0 ]), 1 ), 2000 )
deadline = time.perf_counter() + milliseconds / 1000
iterations = 0
while time.perf_counter() < deadline:
iterations += 1
self .write_json( 200 , { "workedMs" : milliseconds, "iterations" : iterations, "pod" : POD_NAME })
return
self .write_json( 404 , { "error" : "not found" })
def do_POST (self):
if urlparse( self .path).path != "/api/tasks" :
self .write_json( 404 , { "error" : "not found" })
return
try :
length = int ( self .headers.get( "Content-Length" , "0" ))
payload = json.loads( self .rfile.read(length) or b "{}" )
title = str (payload.get( "title" , "" )).strip()
if not title:
self .write_json( 400 , { "error" : "title is required" })
return
task = { "title" : title, "done" : False }
redis_command( "LPUSH" , "taskboard:tasks" , json.dumps(task, ensure_ascii = False ))
self .write_json( 201 , task)
except ( ValueError , json.JSONDecodeError) as exc:
self .write_json( 400 , { "error" : "invalid json" , "detail" : str (exc)})
except ( OSError , RedisError) as exc:
self .write_json( 503 , { "error" : "storage unavailable" , "detail" : str (exc)})
def log_message (self, fmt, * args):
print (
json.dumps(
{ "client" : self .client_address[ 0 ], "request" : fmt % args, "pod" : POD_NAME },
ensure_ascii = False ,
),
flush = True ,
)
if __name__ == "__main__" :
address = ( "0.0.0.0" , 8080 )
print (json.dumps({ "event" : "server_started" , "port" : 8080 , "version" : APP_VERSION }), flush = True )
ThreadingHTTPServer(address, Handler).serve_forever()
"
$COURSE_ROOT
/.taskboard-1.0.0-owned"
REUSE_TASKBOARD_IMAGE = 'no'
BUILDER_NAME = 'welearn-course-builder'
BUILDER_NODE = 'welearn-course-builder0'
BUILDER_CONTAINER = 'buildx_buildkit_welearn-course-builder0'
BUILDER_VOLUME = 'buildx_buildkit_welearn-course-builder0_state'
BUILDKIT_IMAGE = 'moby/buildkit:buildx-stable-1@sha256:0168606be2315b7c807a03b3d8aa79beefdb31c98740cebdffdfeebf31190c9f'
BUILDER_OWNED = " $COURSE_ROOT /.buildx-builder-owned"
BUILDER_ATTEMPT = " $COURSE_ROOT /.buildx-builder-attempt"
BUILDKIT_BASELINE = " $COURSE_ROOT /.buildkit-driver-image-preexisting"
is_container_id () {
case " $1 " in
'' |* [!0-9a-f] * ) return 1 ;;
esac
[ "${ # 1 }" -eq 64 ]
}
is_image_id () {
printf '%s\n' " $1 " | grep -Eq '^sha256:[0-9a-f]{64}$'
}
write_builder_ledger () {
WBL_FILE = " $1 "
WBL_CONTAINER_ID = " $2 "
WBL_VOLUME_CREATED = " $3 "
WBL_IMAGE_ID = " $4 "
{
printf 'builder=%s\n' " $BUILDER_NAME "
printf 'node=%s\n' " $BUILDER_NODE "
printf 'container=%s\n' " $BUILDER_CONTAINER "
printf 'container_id=%s\n' " $WBL_CONTAINER_ID "
printf 'volume=%s\n' " $BUILDER_VOLUME "
printf 'volume_created_at=%s\n' " $WBL_VOLUME_CREATED "
printf 'driver_image_ref=%s\n' " $BUILDKIT_IMAGE "
printf 'driver_image_id=%s\n' " $WBL_IMAGE_ID "
} > " $WBL_FILE "
}
read_builder_ledger () {
RBL_FILE = " $1 "
RBL_ALLOW_PENDING = " $2 "
RBL_CONTAINER_ID = "$( sed -n '4s/^container_id=//p' " $RBL_FILE ")"
RBL_VOLUME_CREATED = "$( sed -n '6s/^volume_created_at=//p' " $RBL_FILE ")"
RBL_IMAGE_ID = "$( sed -n '8s/^driver_image_id=//p' " $RBL_FILE ")"
if [ " $RBL_ALLOW_PENDING " = 'yes' ] && \
[ " $RBL_CONTAINER_ID " = 'pending' ] && \
[ " $RBL_VOLUME_CREATED " = 'pending' ] && \
[ " $RBL_IMAGE_ID " = 'pending' ]; then
:
elif ! is_container_id " $RBL_CONTAINER_ID " || \
[ -z " $RBL_VOLUME_CREATED " ] || \
! is_image_id " $RBL_IMAGE_ID " ; then
echo "停止:builder 账本的 ID 或卷时间无效: $RBL_FILE " >&2
return 1
fi
if ! {
printf 'builder=%s\n' " $BUILDER_NAME "
printf 'node=%s\n' " $BUILDER_NODE "
printf 'container=%s\n' " $BUILDER_CONTAINER "
printf 'container_id=%s\n' " $RBL_CONTAINER_ID "
printf 'volume=%s\n' " $BUILDER_VOLUME "
printf 'volume_created_at=%s\n' " $RBL_VOLUME_CREATED "
printf 'driver_image_ref=%s\n' " $BUILDKIT_IMAGE "
printf 'driver_image_id=%s\n' " $RBL_IMAGE_ID "
} | cmp -s - " $RBL_FILE " ; then
echo "停止:builder 账本字段、顺序或换行无效: $RBL_FILE " >&2
return 1
fi
}
read_builder_state () {
if BUILDER_INSPECT = "$( docker buildx inspect " $BUILDER_NAME " 2>&1 )" ; then
BUILDER_EXISTS = 'yes'
else
RBS_RC = $?
case " $BUILDER_INSPECT " in
* 'no builder "' * '" found' * ) BUILDER_EXISTS = 'no' ;;
*)
printf '停止:无法读取 buildx builder,rc=%s\n%s\n' \
" $RBS_RC " " $BUILDER_INSPECT " >&2
return 1
;;
esac
fi
if BUILDER_CONTAINER_IDS = "$(
docker ps -aq --no-trunc --filter "name= $BUILDER_CONTAINER " 2>&1
)" ; then
:
else
RBS_RC = $?
printf '停止:无法查询 builder 容器,rc=%s\n%s\n' \
" $RBS_RC " " $BUILDER_CONTAINER_IDS " >&2
return 1
fi
BUILDER_CONTAINER_COUNT = "$(
printf '%s\n' " $BUILDER_CONTAINER_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
if BUILDER_VOLUME_CREATED = "$(
docker volume inspect \
--format '{{.CreatedAt}}' " $BUILDER_VOLUME " 2>&1
)" ; then
BUILDER_VOLUME_EXISTS = 'yes'
else
RBS_RC = $?
case " $BUILDER_VOLUME_CREATED " in
* 'No such volume:' *|* 'no such volume' * )
BUILDER_VOLUME_EXISTS = 'no'
BUILDER_VOLUME_CREATED = ''
;;
*)
printf '停止:无法查询 builder 状态卷,rc=%s\n%s\n' \
" $RBS_RC " " $BUILDER_VOLUME_CREATED " >&2
return 1
;;
esac
fi
}
validate_builder_facts () {
VBF_CONTAINER_ID = " $1 "
VBF_VOLUME_CREATED = " $2 "
VBF_IMAGE_ID = " $3 "
if [ " $BUILDER_EXISTS " != 'yes' ] || \
! printf '%s\n' " $BUILDER_INSPECT " | \
grep -Eq '^Name:[[:space:]]+welearn-course-builder$' || \
! printf '%s\n' " $BUILDER_INSPECT " | \
grep -Eq '^Driver:[[:space:]]+docker-container$' ; then
echo '停止:builder 名称或 driver 不是课程记录的值' >&2
return 1
fi
if [ " $BUILDER_CONTAINER_COUNT " -ne 1 ] || \
[ " $BUILDER_CONTAINER_IDS " != " $VBF_CONTAINER_ID " ] || \
[ " $BUILDER_VOLUME_EXISTS " != 'yes' ] || \
[ " $BUILDER_VOLUME_CREATED " != " $VBF_VOLUME_CREATED " ]; then
echo '停止:builder 容器或状态卷与账本不一致' >&2
return 1
fi
if BUILDER_CONTAINER_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{.Config.Image}}|{{.Image}}|{{range .Mounts}}{{if eq .Destination "/var/lib/buildkit"}}{{.Name}}{{end}}{{end}}' \
" $VBF_CONTAINER_ID " 2>&1
)" ; then
:
else
VBF_RC = $?
printf '停止:无法读取 builder 容器事实,rc=%s\n%s\n' \
" $VBF_RC " " $BUILDER_CONTAINER_FACTS " >&2
return 1
fi
EXPECTED_BUILDER_FACTS = " $VBF_CONTAINER_ID |/ $BUILDER_CONTAINER | $BUILDKIT_IMAGE | $VBF_IMAGE_ID | $BUILDER_VOLUME "
if [ " $BUILDER_CONTAINER_FACTS " != " $EXPECTED_BUILDER_FACTS " ]; then
printf '停止:builder ID、名称、镜像或挂载卷不匹配\n%s\n' \
" $BUILDER_CONTAINER_FACTS " >&2
return 1
fi
if CURRENT_BUILDKIT_IMAGE_ID = "$(
docker image inspect --format '{{.Id}}' " $BUILDKIT_IMAGE " 2>&1
)" ; then
:
else
VBF_RC = $?
printf '停止:无法读取 BuildKit driver 镜像,rc=%s\n%s\n' \
" $VBF_RC " " $CURRENT_BUILDKIT_IMAGE_ID " >&2
return 1
fi
if [ " $CURRENT_BUILDKIT_IMAGE_ID " != " $VBF_IMAGE_ID " ]; then
echo '停止:BuildKit driver image ID 与账本不一致' >&2
return 1
fi
}
record_buildkit_baseline () {
if [ -e " $BUILDKIT_BASELINE " ]; then
if printf 'yes\n' | cmp -s - " $BUILDKIT_BASELINE " ; then
BUILDKIT_PREEXISTING = 'yes'
elif printf 'no\n' | cmp -s - " $BUILDKIT_BASELINE " ; then
BUILDKIT_PREEXISTING = 'no'
else
echo '停止:BuildKit driver 镜像基线无效' >&2
return 1
fi
elif BUILDKIT_BASELINE_OUTPUT = "$(
docker image inspect " $BUILDKIT_IMAGE " 2>&1
)" ; then
BUILDKIT_PREEXISTING = 'yes'
printf 'yes\n' > " $BUILDKIT_BASELINE "
else
RBB_RC = $?
case " $BUILDKIT_BASELINE_OUTPUT " in
* 'No such image:' * )
BUILDKIT_PREEXISTING = 'no'
printf 'no\n' > " $BUILDKIT_BASELINE "
;;
*)
printf '停止:读取 BuildKit 镜像基线失败,rc=%s\n%s\n' \
" $RBB_RC " " $BUILDKIT_BASELINE_OUTPUT " >&2
return 1
;;
esac
fi
}
read_builder_state || exit 1
HAS_BUILDER_OWNED = 'no'
HAS_BUILDER_ATTEMPT = 'no'
if [ -e " $BUILDER_OWNED " ]; then
read_builder_ledger " $BUILDER_OWNED " 'no' || exit 1
OWNED_BUILDER_CONTAINER_ID = " $RBL_CONTAINER_ID "
OWNED_BUILDER_VOLUME_CREATED = " $RBL_VOLUME_CREATED "
OWNED_BUILDKIT_IMAGE_ID = " $RBL_IMAGE_ID "
HAS_BUILDER_OWNED = 'yes'
fi
if [ -e " $BUILDER_ATTEMPT " ]; then
read_builder_ledger " $BUILDER_ATTEMPT " 'yes' || exit 1
ATTEMPT_BUILDER_CONTAINER_ID = " $RBL_CONTAINER_ID "
ATTEMPT_BUILDER_VOLUME_CREATED = " $RBL_VOLUME_CREATED "
ATTEMPT_BUILDKIT_IMAGE_ID = " $RBL_IMAGE_ID "
HAS_BUILDER_ATTEMPT = 'yes'
fi
if [ " $HAS_BUILDER_OWNED " = 'yes' ] || \
[ " $HAS_BUILDER_ATTEMPT " = 'yes' ]; then
if [ ! -e " $BUILDKIT_BASELINE " ]; then
echo '停止:builder 账本存在,但 driver 镜像基线缺失' >&2
exit 1
fi
else
if [ " $BUILDER_EXISTS " = 'yes' ] || \
[ " $BUILDER_CONTAINER_COUNT " -ne 0 ] || \
[ " $BUILDER_VOLUME_EXISTS " = 'yes' ]; then
echo '停止:发现没有课程账本的同名 builder、容器或状态卷' >&2
exit 1
fi
fi
record_buildkit_baseline || exit 1
printf 'buildkitDriverImagePreexisting=%s\n' " $BUILDKIT_PREEXISTING "
REUSE_BUILDER = 'no'
if [ " $HAS_BUILDER_OWNED " = 'yes' ]; then
validate_builder_facts \
" $OWNED_BUILDER_CONTAINER_ID " \
" $OWNED_BUILDER_VOLUME_CREATED " \
" $OWNED_BUILDKIT_IMAGE_ID " || exit 1
if [ " $HAS_BUILDER_ATTEMPT " = 'yes' ] && \
{ [ " $ATTEMPT_BUILDER_CONTAINER_ID " != \
" $OWNED_BUILDER_CONTAINER_ID " ] || \
[ " $ATTEMPT_BUILDER_VOLUME_CREATED " != \
" $OWNED_BUILDER_VOLUME_CREATED " ] || \
[ " $ATTEMPT_BUILDKIT_IMAGE_ID " != \
" $OWNED_BUILDKIT_IMAGE_ID " ]; }; then
echo '停止:builder 正式账本与尝试账本不一致' >&2
exit 1
fi
rm -f -- " $BUILDER_ATTEMPT "
REUSE_BUILDER = 'yes'
elif [ " $HAS_BUILDER_ATTEMPT " = 'yes' ]; then
if [ " $ATTEMPT_BUILDER_CONTAINER_ID " = 'pending' ]; then
if [ " $BUILDER_CONTAINER_COUNT " -ne 0 ] || \
[ " $BUILDER_VOLUME_EXISTS " = 'yes' ]; then
echo '停止:pending builder 账本无法认领已经出现的容器或卷' >&2
exit 1
fi
if [ " $BUILDER_EXISTS " = 'yes' ]; then
if ! docker buildx rm " $BUILDER_NAME " ; then
echo '停止:无法收尾 pending 尝试留下的 builder 元数据' >&2
exit 1
fi
fi
rm -f -- " $BUILDER_ATTEMPT "
read_builder_state || exit 1
else
validate_builder_facts \
" $ATTEMPT_BUILDER_CONTAINER_ID " \
" $ATTEMPT_BUILDER_VOLUME_CREATED " \
" $ATTEMPT_BUILDKIT_IMAGE_ID " || exit 1
write_builder_ledger " $BUILDER_OWNED " \
" $ATTEMPT_BUILDER_CONTAINER_ID " \
" $ATTEMPT_BUILDER_VOLUME_CREATED " \
" $ATTEMPT_BUILDKIT_IMAGE_ID "
rm -f -- " $BUILDER_ATTEMPT "
REUSE_BUILDER = 'yes'
fi
fi
if [ " $REUSE_BUILDER " = 'no' ]; then
read_builder_state || exit 1
if [ " $BUILDER_EXISTS " = 'yes' ] || \
[ " $BUILDER_CONTAINER_COUNT " -ne 0 ] || \
[ " $BUILDER_VOLUME_EXISTS " = 'yes' ]; then
echo '停止:创建 builder 前仍有同名资源' >&2
exit 1
fi
write_builder_ledger " $BUILDER_ATTEMPT " \
'pending' 'pending' 'pending'
if BUILDER_CREATE_OUTPUT = "$(
docker buildx create \
--name " $BUILDER_NAME " \
--node " $BUILDER_NODE " \
--driver docker-container \
--driver-opt "image= $BUILDKIT_IMAGE " 2>&1
)" ; then
BUILDER_CREATE_RC = 0
else
BUILDER_CREATE_RC = $?
fi
if [ " $BUILDER_CREATE_RC " -eq 0 ]; then
if BUILDER_BOOT_OUTPUT = "$(
docker buildx inspect --bootstrap " $BUILDER_NAME " 2>&1
)" ; then
BUILDER_BOOT_RC = 0
else
BUILDER_BOOT_RC = $?
fi
else
BUILDER_BOOT_RC = 1
BUILDER_BOOT_OUTPUT = 'builder 尚未进入 bootstrap'
fi
read_builder_state || exit 1
if [ " $BUILDER_CONTAINER_COUNT " -eq 1 ] && \
[ " $BUILDER_VOLUME_EXISTS " = 'yes' ]; then
CURRENT_BUILDER_CONTAINER_ID = " $BUILDER_CONTAINER_IDS "
if CURRENT_BUILDKIT_IMAGE_ID = "$(
docker image inspect --format '{{.Id}}' " $BUILDKIT_IMAGE " 2>&1
)" && is_container_id " $CURRENT_BUILDER_CONTAINER_ID " && \
is_image_id " $CURRENT_BUILDKIT_IMAGE_ID " ; then
write_builder_ledger " $BUILDER_ATTEMPT " \
" $CURRENT_BUILDER_CONTAINER_ID " \
" $BUILDER_VOLUME_CREATED " \
" $CURRENT_BUILDKIT_IMAGE_ID "
validate_builder_facts \
" $CURRENT_BUILDER_CONTAINER_ID " \
" $BUILDER_VOLUME_CREATED " \
" $CURRENT_BUILDKIT_IMAGE_ID " || exit 1
fi
fi
if [ " $BUILDER_CREATE_RC " -ne 0 ] || [ " $BUILDER_BOOT_RC " -ne 0 ]; then
printf '停止:专属 builder 创建或启动失败,create_rc=%s boot_rc=%s\n%s\n%s\n' \
" $BUILDER_CREATE_RC " " $BUILDER_BOOT_RC " \
" $BUILDER_CREATE_OUTPUT " " $BUILDER_BOOT_OUTPUT " >&2
exit 1
fi
read_builder_ledger " $BUILDER_ATTEMPT " 'no' || exit 1
write_builder_ledger " $BUILDER_OWNED " \
" $RBL_CONTAINER_ID " " $RBL_VOLUME_CREATED " " $RBL_IMAGE_ID "
rm -f -- " $BUILDER_ATTEMPT "
OWNED_BUILDER_CONTAINER_ID = " $RBL_CONTAINER_ID "
OWNED_BUILDER_VOLUME_CREATED = " $RBL_VOLUME_CREATED "
OWNED_BUILDKIT_IMAGE_ID = " $RBL_IMAGE_ID "
fi
read_builder_ledger " $BUILDER_OWNED " 'no' || exit 1
if BUILDER_BOOT_OUTPUT = "$(
docker buildx inspect --bootstrap " $BUILDER_NAME " 2>&1
)" ; then
:
else
BUILDER_BOOT_RC = $?
printf '停止:课程 builder 无法进入运行状态,rc=%s\n%s\n' \
" $BUILDER_BOOT_RC " " $BUILDER_BOOT_OUTPUT " >&2
exit 1
fi
read_builder_state || exit 1
validate_builder_facts \
" $RBL_CONTAINER_ID " " $RBL_VOLUME_CREATED " " $RBL_IMAGE_ID " || exit 1
printf 'builderContainerId=%s\n' " $RBL_CONTAINER_ID "
if CURRENT_IMAGE_ID = "$(
docker image inspect " $TASKBOARD_IMAGE " --format '{{.Id}}' 2>&1
)" ; then
if [ ! -e " $TASKBOARD_OWNED " ]; then
echo '停止:taskboard:1.0.0 已存在,但不属于本课程' >&2
exit 1
fi
if ! printf '%s\n' " $CURRENT_IMAGE_ID " | cmp -s - " $TASKBOARD_OWNED " ; then
echo '停止:taskboard:1.0.0 与课程记录的镜像 ID 不一致' >&2
exit 1
fi
REUSE_TASKBOARD_IMAGE = 'yes'
else
TASKBOARD_INSPECT_RC = $?
case " $CURRENT_IMAGE_ID " in
* 'No such image:' * ) ;;
*)
printf '停止:无法查询 taskboard:1.0.0,rc=%s\n%s\n' \
" $TASKBOARD_INSPECT_RC " " $CURRENT_IMAGE_ID " >&2
exit 1
;;
esac
if [ -e " $TASKBOARD_OWNED " ]; then
RECORDED_IMAGE_ID = "$( cat " $TASKBOARD_OWNED ")"
else
RECORDED_IMAGE_ID = ''
fi
if [ -e " $TASKBOARD_OWNED " ] && \
{ ! printf '%s\n' " $RECORDED_IMAGE_ID " | \
grep -Eq '^sha256:[0-9a-f]{64}$' || \
! printf '%s\n' " $RECORDED_IMAGE_ID " | \
cmp -s - " $TASKBOARD_OWNED " ; }; then
echo '停止:课程镜像归属标记内容无效' >&2
exit 1
fi
fi
if [ " $REUSE_TASKBOARD_IMAGE " = 'yes' ]; then
echo 'taskboard:1.0.0 与归属标记一致,安全复用'
else
if docker buildx build \
--builder " $BUILDER_NAME " \
--load \
--build-arg APP_VERSION= 1.0.0 \
--tag " $TASKBOARD_IMAGE " \
. ; then
BUILT_IMAGE_ID = "$( docker image inspect " $TASKBOARD_IMAGE " --format '{{.Id}}')" || exit 1
printf '%s\n' " $BUILT_IMAGE_ID " > " $TASKBOARD_OWNED "
printf '课程镜像归属已记录:%s\n' " $BUILT_IMAGE_ID "
else
echo '停止:镜像构建失败,未写入 owned 标记' >&2
exit 1
fi
fi
$COURSE_ROOT
/.taskboard-standalone-owned"
STANDALONE_ATTEMPT = " $COURSE_ROOT /.taskboard-standalone-attempt"
REUSE_STANDALONE = 'no'
read_standalone_state () {
if STANDALONE_IDS = "$(
docker ps -aq --no-trunc --filter "name= $STANDALONE_NAME " 2>&1
)" ; then
:
else
RSS_RC = $?
printf '停止:无法查询独立容器,rc=%s\n%s\n' \
" $RSS_RC " " $STANDALONE_IDS " >&2
return 1
fi
STANDALONE_COUNT = "$(
printf '%s\n' " $STANDALONE_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
}
read_standalone_marker () {
RSM_FILE = " $1 "
RSM_ALLOW_PENDING = " $2 "
RSM_ID = "$( cat " $RSM_FILE ")"
if [ " $RSM_ALLOW_PENDING " = 'yes' ] && [ " $RSM_ID " = 'pending' ]; then
if ! printf 'pending\n' | cmp -s - " $RSM_FILE " ; then
echo '停止:独立容器 attempt 账本换行无效' >&2
return 1
fi
elif ! printf '%s\n' " $RSM_ID " | grep -Eq '^[0-9a-f]{64}$' || \
! printf '%s\n' " $RSM_ID " | cmp -s - " $RSM_FILE " ; then
echo "停止:独立容器账本格式不正确: $RSM_FILE " >&2
return 1
fi
}
validate_standalone_facts () {
VSF_ID = " $1 "
if STANDALONE_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{.Config.Image}}' \
" $VSF_ID " 2>&1
)" ; then
:
else
VSF_RC = $?
printf '停止:无法读取独立容器事实,rc=%s\n%s\n' \
" $VSF_RC " " $STANDALONE_FACTS " >&2
return 1
fi
if [ " $STANDALONE_FACTS " != \
" $VSF_ID |/ $STANDALONE_NAME | $STANDALONE_IMAGE " ]; then
echo '停止:独立容器 ID、名称或镜像事实不一致' >&2
return 1
fi
}
read_standalone_state || exit 1
HAS_STANDALONE_OWNED = 'no'
HAS_STANDALONE_ATTEMPT = 'no'
RECORDED_STANDALONE_ID = ''
if [ -e " $STANDALONE_MARKER " ]; then
read_standalone_marker " $STANDALONE_MARKER " 'no' || exit 1
RECORDED_STANDALONE_ID = " $RSM_ID "
HAS_STANDALONE_OWNED = 'yes'
fi
if [ -e " $STANDALONE_ATTEMPT " ]; then
read_standalone_marker " $STANDALONE_ATTEMPT " 'yes' || exit 1
ATTEMPT_STANDALONE_ID = " $RSM_ID "
HAS_STANDALONE_ATTEMPT = 'yes'
if [ " $ATTEMPT_STANDALONE_ID " != 'pending' ]; then
if [ -n " $RECORDED_STANDALONE_ID " ] && \
[ " $RECORDED_STANDALONE_ID " != " $ATTEMPT_STANDALONE_ID " ]; then
echo '停止:独立容器 owned 与 attempt 账本 ID 不一致' >&2
exit 1
fi
RECORDED_STANDALONE_ID = " $ATTEMPT_STANDALONE_ID "
fi
fi
if [ " $HAS_STANDALONE_ATTEMPT " = 'yes' ] && \
[ " $ATTEMPT_STANDALONE_ID " = 'pending' ] && \
[ " $STANDALONE_COUNT " -ne 0 ]; then
echo '停止:pending attempt 无法认领已经出现的同名容器' >&2
exit 1
fi
if [ -n " $RECORDED_STANDALONE_ID " ]; then
if [ " $STANDALONE_COUNT " -eq 0 ]; then
rm -f -- " $STANDALONE_MARKER " " $STANDALONE_ATTEMPT "
RECORDED_STANDALONE_ID = ''
elif [ " $STANDALONE_COUNT " -eq 1 ] && \
[ " $STANDALONE_IDS " = " $RECORDED_STANDALONE_ID " ]; then
validate_standalone_facts " $RECORDED_STANDALONE_ID " || exit 1
if [ " $HAS_STANDALONE_OWNED " = 'no' ]; then
printf '%s\n' " $RECORDED_STANDALONE_ID " > " $STANDALONE_MARKER "
fi
rm -f -- " $STANDALONE_ATTEMPT "
REUSE_STANDALONE = 'yes'
else
echo '停止:同名容器集合与课程记录的完整 ID 不一致' >&2
exit 1
fi
elif [ " $STANDALONE_COUNT " -ne 0 ]; then
echo '停止:同名独立容器存在,但没有完整 ID 账本' >&2
exit 1
else
rm -f -- " $STANDALONE_ATTEMPT "
fi
if [ " $REUSE_STANDALONE " = 'no' ]; then
if PORT_USERS = "$(
docker ps --filter publish= 18080 \
--format '{{.ID}}|{{.Names}}' 2>&1
)" ; then
:
else
PORT_RC = $?
printf '停止:无法检查 18080 端口映射,rc=%s\n%s\n' \
" $PORT_RC " " $PORT_USERS " >&2
exit 1
fi
if [ -n " $PORT_USERS " ]; then
printf '停止:已有 Docker 容器发布 18080 端口\n%s\n' \
" $PORT_USERS " >&2
exit 1
fi
capture_standalone_attempt () {
if ! printf 'pending\n' | cmp -s - " $STANDALONE_ATTEMPT " ; then
return
fi
if CSA_IDS = "$(
docker ps -aq --no-trunc --filter "name= $STANDALONE_NAME " 2> /dev/null
)" && [ "$( printf '%s\n' " $CSA_IDS " | awk 'NF {n++} END {print n+0}')" -eq 1 ] && \
printf '%s\n' " $CSA_IDS " | grep -Eq '^[0-9a-f]{64}$' ; then
if CSA_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{.Config.Image}}' \
" $CSA_IDS " 2> /dev/null
)" && [ " $CSA_FACTS " = \
" $CSA_IDS |/ $STANDALONE_NAME | $STANDALONE_IMAGE " ]; then
printf '%s\n' " $CSA_IDS " > " $STANDALONE_ATTEMPT "
fi
fi
}
printf 'pending\n' > " $STANDALONE_ATTEMPT "
trap 'capture_standalone_attempt' EXIT
trap 'capture_standalone_attempt; exit 130' HUP INT TERM
if STANDALONE_ID = "$(
docker create \
--name " $STANDALONE_NAME " \
-p 127.0.0.1:18080:8080 \
-e WELCOME_MESSAGE='你好,容器已经运行' \
" $STANDALONE_IMAGE " 2>&1
)" ; then
:
else
STANDALONE_RC = $?
capture_standalone_attempt
trap - EXIT HUP INT TERM
printf '停止:独立容器 create 失败,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_ID " >&2
exit 1
fi
if ! printf '%s\n' " $STANDALONE_ID " | \
grep -Eq '^[0-9a-f]{64}$' ; then
printf '停止:Docker 未返回完整容器 ID\n%s\n' \
" $STANDALONE_ID " >&2
exit 1
fi
printf '%s\n' " $STANDALONE_ID " > " $STANDALONE_ATTEMPT "
validate_standalone_facts " $STANDALONE_ID " || exit 1
printf '%s\n' " $STANDALONE_ID " > " $STANDALONE_MARKER "
rm -f -- " $STANDALONE_ATTEMPT "
trap - EXIT HUP INT TERM
RECORDED_STANDALONE_ID = " $STANDALONE_ID "
fi
if STANDALONE_START_OUTPUT = "$(
docker start " $RECORDED_STANDALONE_ID " 2>&1
)" ; then
:
else
STANDALONE_RC = $?
printf '停止:容器已按 ID 记账,但 start 失败,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_START_OUTPUT " >&2
exit 1
fi
printf 'standaloneContainerId=%s\n' " $RECORDED_STANDALONE_ID "
docker logs taskboard-standalone
exit 1
fi
sleep 1
done
curl --fail-with-body --show-error --silent \
http://127.0.0.1:18080/api/info
:
"unknown"
}
$COURSE_ROOT
/.taskboard-standalone-owned"
STANDALONE_ATTEMPT = " $COURSE_ROOT /.taskboard-standalone-attempt"
if STANDALONE_IDS = "$(
docker ps -aq --no-trunc --filter "name= $STANDALONE_NAME " 2>&1
)" ; then
:
else
STANDALONE_RC = $?
printf '停止:无法查询独立容器,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_IDS " >&2
exit 1
fi
STANDALONE_COUNT = "$(
printf '%s\n' " $STANDALONE_IDS " | \
awk 'NF { count++ } END { print count + 0 }'
)"
RECORDED_STANDALONE_ID = ''
if [ -e " $STANDALONE_MARKER " ]; then
OWNED_STANDALONE_ID = "$( cat " $STANDALONE_MARKER ")"
if ! printf '%s\n' " $OWNED_STANDALONE_ID " | \
grep -Eq '^[0-9a-f]{64}$' || \
! printf '%s\n' " $OWNED_STANDALONE_ID " | \
cmp -s - " $STANDALONE_MARKER " ; then
echo '停止:独立容器 owned 账本格式不正确' >&2
exit 1
fi
RECORDED_STANDALONE_ID = " $OWNED_STANDALONE_ID "
fi
if [ -e " $STANDALONE_ATTEMPT " ]; then
ATTEMPT_STANDALONE_ID = "$( cat " $STANDALONE_ATTEMPT ")"
if [ " $ATTEMPT_STANDALONE_ID " = 'pending' ]; then
if ! printf 'pending\n' | cmp -s - " $STANDALONE_ATTEMPT " ; then
echo '停止:独立容器 attempt 账本格式不正确' >&2
exit 1
fi
if [ " $STANDALONE_COUNT " -ne 0 ]; then
echo '停止:pending attempt 不能授权已有同名容器' >&2
exit 1
fi
elif ! printf '%s\n' " $ATTEMPT_STANDALONE_ID " | \
grep -Eq '^[0-9a-f]{64}$' || \
! printf '%s\n' " $ATTEMPT_STANDALONE_ID " | \
cmp -s - " $STANDALONE_ATTEMPT " ; then
echo '停止:独立容器 attempt 账本格式不正确' >&2
exit 1
else
if [ -n " $RECORDED_STANDALONE_ID " ] && \
[ " $RECORDED_STANDALONE_ID " != " $ATTEMPT_STANDALONE_ID " ]; then
echo '停止:独立容器 owned 与 attempt ID 不一致' >&2
exit 1
fi
RECORDED_STANDALONE_ID = " $ATTEMPT_STANDALONE_ID "
fi
fi
if [ -z " $RECORDED_STANDALONE_ID " ]; then
if [ " $STANDALONE_COUNT " -ne 0 ]; then
echo '停止:同名容器存在但没有课程归属账本' >&2
exit 1
fi
rm -f -- " $STANDALONE_ATTEMPT "
echo 'container/taskboard-standalone=already-absent'
else
if [ " $STANDALONE_COUNT " -eq 0 ]; then
rm -f -- " $STANDALONE_MARKER " " $STANDALONE_ATTEMPT "
echo 'container/taskboard-standalone=already-absent'
elif [ " $STANDALONE_COUNT " -ne 1 ] || \
[ " $STANDALONE_IDS " != " $RECORDED_STANDALONE_ID " ]; then
echo '停止:当前同名容器完整 ID 与课程账本不一致' >&2
exit 1
else
if STANDALONE_FACTS = "$(
docker inspect \
--format '{{.Id}}|{{.Name}}|{{.Config.Image}}' \
" $RECORDED_STANDALONE_ID " 2>&1
)" ; then
:
else
STANDALONE_RC = $?
printf '停止:无法读取独立容器事实,rc=%s\n%s\n' \
" $STANDALONE_RC " " $STANDALONE_FACTS " >&2
exit 1
fi
if [ " $STANDALONE_FACTS " != \
" $RECORDED_STANDALONE_ID |/ $STANDALONE_NAME | $STANDALONE_IMAGE " ]; then
echo '停止:独立容器名称或镜像事实不匹配' >&2
exit 1
fi
docker stop " $RECORDED_STANDALONE_ID "
docker rm " $RECORDED_STANDALONE_ID "
if STANDALONE_VERIFY = "$(
docker inspect " $RECORDED_STANDALONE_ID " 2>&1
)" ; then
echo '停止:独立容器删除后仍可读取' >&2
exit 1
fi
case " $STANDALONE_VERIFY " in
* 'No such object:' *|* 'No such container:' * ) ;;
*)
printf '停止:独立容器删除后验证失败\n%s\n' \
" $STANDALONE_VERIFY " >&2
exit 1
;;
esac
rm -f -- " $STANDALONE_MARKER " " $STANDALONE_ATTEMPT "
echo 'container/taskboard-standalone=absent'
fi
fi