
很多人看到 test123 这种项目名第一反应就是占位符是随手敲出来验证一下思路的临时产物。我偏偏喜欢这种名字因为它天然没有心理负担不会有人期待它长成一个完整业务也不会有人在第一眼就给它贴上一堆架构预期。去年我给自己定了一条很具体的规矩凡是名字带 test 的小项目不许纠结业务完整度但必须把从写代码、跑测试、做镜像、部署上线到健康检查的整条链路走通。test123 就是我反复用来跑这条链路的代号最后它沉淀成了一整套可以复用的工程基建。这篇文章就是过去验证过程的完整复盘从需求拆解到选型从 Docker 编排到 CI 自动化再到底层细节的坑全部摊开讲。如果你正准备学容器部署或者手里一堆临时实验项目不知道如何变成自己的脚手架那这套试验思路可以直接拿过去抄。1. 项目缘起为什么好好的工程要叫一个 test1231.1 最小命名带来的隐形约束项目名会对人的行为产生强烈暗示。你如果起一个“交易中台网关”的名字第一反应肯定是想怎么把架构画漂亮怎么把模块拆干净如果起名 test123反而轻松你的脑子会自动切换到“能用就行”的频道。这种心态对验证基础能力非常有利因为你不会被“应该做得多完整”困住。我把 test123 定位成无压力验证场任何新出的想法、新的部署工具、新的监控方式先扔进 test123 里跑一遍。它会自动把“想法落不落得了地”这个问题暴露出来而且因为代码量小排查起来不费劲。命名越随意试错成本越低这是我试过最实用的工程习惯之一。1.2 把一个大目标拆成四条可验收的标准没有验收标准的项目很容易变成无限扩展的自嗨。test123 开始之前我给“走通链路”定了几条硬指标访问/接口能稳定返回 JSON而不是一段写死的 HTML。提供/healthz健康检查接口容器和外部探测都拿它当判断依据。一套 Docker Compose 配置就能把服务完整拉起来。推代码到仓库后CI 能自动完成测试、打包镜像并在指定目标上完成部署探测。这四条都不关心业务长什么样但它们合起来正好覆盖了一个服务从开发到上线的完整生命周期。目标越简单后面复盘越清楚哪里做错了。1.3 技术选型为什么是 FastAPI Docker Compose GitHub Actions很多人会把技术选型想复杂实际上小项目最该关心的是“是不是随手能跑”。我在 test123 里选型的原则是轻量、文档全、周边生态成熟。组件选择理由Web 框架FastAPI自带参数校验和 OpenAPI 文档接口测试非常方便ASGI 服务器Uvicorn和 FastAPI 同生态配置少启动稳定容器环境Docker Compose单机多容器编排够用学习成本远低于 KubernetesCI/CDGitHub Actions仓库本身就在 GitHub不用额外搭 Jenkins免维护镜像仓库GHCR和 GitHub 联动权限模型简单不需要另开账号这个组合还有一个隐性优势它足够贴近当下主流技术栈。哪怕日后要迁移到 Kubernetestest123 里的健康检查、镜像构建、环境变量这些概念都能无缝平移。先在地面上跑通再谈编排比直接上手重型平台靠谱得多。2. 核心设计拆解test123 到底在测什么2.1 服务本身一个能稳定返回 JSON 的进程test123 虽然叫“测试”但它对外提供的核心功能非常明确一个独立运行、带状态校验的 HTTP 服务。我选择 JSON 返回而不是返回 HTML是因为 JSON 接口更容易做自动化断言。接口测试只需要校验状态码和响应结构不需要关心前端渲染逻辑。这个设计很直接from fastapi import FastAPI from datetime import datetime, timezone app FastAPI(titletest123, version0.1.0) app.get(/) def read_root(): return { service: test123, status: ok, server_time: datetime.now(timezone.utc).isoformat(), }这段代码没有花活但它是整条链路里最值得关注的起点。如果连这个最小的服务都无法被人稳定访问后面所有自动化手段都是在沙滩上盖楼。启动时我绑定的是0.0.0.0:8000不是127.0.0.1:8000。这是个容易绕进去的细节在容器里让进程只监听回环地址外部流量会被直接丢到黑洞里但进程本身又不至于完全崩溃排查起来非常误导人。2.2 健康检查TCP 通了不算业务通了才算很多人做健康检查就是检查端口通不通这种方式只能证明进程在跑不能证明服务能用。test123 里我把健康检查提升到了业务层面专门开启一个/healthz接口让它除了返回 200还能顺带反馈当前的运行状态app.get(/healthz) def healthz(): return {status: alive}这个接口的意义在于它把“进程存活”和“业务可用”区分开了。将来如果 test123 接入了数据库或缓存/healthz就可以把依赖探测一起放进来任何一环异常都直接反映在状态码和响应体上。外部负载均衡器、Docker Compose 的 healthcheck、CI 的部署后探测全都可以复用这一个接口。很多人会在这一步犯一个错误觉得/本身也能返回 200于是省掉了/healthz。等到某个依赖的中间层出问题页面能打开、数据却拿不到的时候就会发现业务接口和健康检查接口的分离有多重要。健康检查接口设计的是一条独立于业务的“小路”它故意不依赖业务逻辑而是依赖基础设施状态。2.3 可观测性日志、请求 ID、状态码一个都不能少小项目最容易忽略可观测性但实际跑起来才发现没有日志连死因都找不到。test123 里我用了两层处理第一层是 Uvicorn 自带访问日志能看到每个请求的路径、状态码和响应时长第二层是业务里加了一个简单的请求中间件给每个请求生成唯一 ID并附加到结构化日志里。import time import uuid from starlette.middleware.base import BaseHTTPMiddleware class RequestIDMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): request_id str(uuid.uuid4()) start time.time() response await call_next(request) response.headers[X-Request-ID] request_id duration time.time() - start print( { event: http_request, request_id: request_id, path: request.url.path, status_code: response.status_code, duration_ms: round((duration) * 1000, 2), } ) return response app.add_middleware(RequestIDMiddleware)别小看这个中间件它让我在后面的部署排错中省了大量时间。请求失败以后沿着X-Request-ID一查就能把入口访问日志和容器内业务日志串起来不用再去猜是哪一段链路断了。2.4 测试策略先让接口说清楚话test123 的测试策略也很朴素不追求覆盖率数字只测两件事接口该返回什么、不该返回什么。from fastapi.testclient import TestClient from app.main import app client TestClient(app) def test_root_returns_expected_json(): response client.get(/) assert response.status_code 200 body response.json() assert body[status] ok assert body[service] test123 def test_healthz_is_available(): response client.get(/healthz) assert response.status_code 200 assert response.json() {status: alive}这段测试写在业务代码之前或之后都行关键是它给了整套流程一个明确的闸门测试不过镜像就不允许被构建。很多做小项目的人嫌写测试啰嗦但我实测下来跳过测试省下的时间远少于后期排查接口被改坏的时间。3. 实操全过程把 test123 跑起来3.1 初始化目录与依赖管理test123 的目录结构从一开始就按项目模板搭而不是随手乱扔文件。良好的目录结构可以避免后面 CI 配置里的路径地狱test123/ ├── app/ │ ├── __init__.py │ ├── main.py │ └── middleware.py ├── tests/ │ └── test_health.py ├── Dockerfile ├── docker-compose.yml ├── requirements.txt └── .github/ └── workflows/ └── ci.yml依赖我写在requirements.txt里核心依赖只有两行fastapi和uvicorn[standard]。如果你想锁定版本可以加上固定版本号我在这类验证项目里更倾向于用带主版本约束的写法既避免意外升级又不会把环境卡得太死。3.2 编写核心服务与测试用例把第 2 节里的main.py和middleware.py放到位之后本地跑起来只需要一条命令pip install -r requirements.txt uvicorn app.main:app --host 0.0.0.0 --port 8000启动后先别急着部署先用测试脚本确认接口符合预期再进入打包阶段。这个“先测后包”的习惯能挡住大量低级错误比如少复制了一个依赖文件、路径写错之类的问题在进入 Docker 环境之前就会被拦下来。3.3 构建 Docker 镜像利用缓存层级减少重复构建Dockerfile 看起来简单但里面的顺序有讲究。我把requirements.txt的复制和安装放到了业务代码复制之前目的是利用 Docker 的层缓存机制。只要依赖没变后面每次构建都会直接命中缓存不用重新安装 Python 包。FROM python:3.11-slim WORKDIR /app ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]PYTHONDONTWRITEBYTECODE和PYTHONUNBUFFERED这两个环境变量我是刻意加上的。前者防止容器写入.pyc字节码文件保持文件系统干净后者让日志不用等缓冲区刷新排错时能看到实时输出。CMD 用数组形式而不是字符串形式也是为了直接以 exec 方式启动避免 shell 作为中间进程残留成孤儿进程。3.4 编排 compose端口映射和健康检查参数说明docker-compose.yml是整条链路里最关键的一环它把服务定义、端口映射、健康检查全部收敛到一个文件里services: test123: build: . image: ghcr.io/yourname/test123:latest container_name: test123 ports: - 8080:8000 environment: - TZAsia/Shanghai healthcheck: test: [CMD, python, -c, import urllib.request; urllib.request.urlopen(http://127.0.0.1:8000/healthz, timeout3)] interval: 10s timeout: 5s retries: 3 start_period: 10s restart: unless-stopped端口映射8080:8000的意思是把宿主机的 8080 端口转到容器内的 8000 端口。你从外部访问http://服务器IP:8080实际上访问到的就是容器里 Uvicorn 的 8000 端口。为什么宿主侧用 8080 而不是 8000主要考虑避免和本地开发环境直接冲突同时保持容器内端口稳定不变。这里还有一个经常被忽略的核心点健康检查命令必须在容器内部访问。你如果写成curl http://localhost:8000/healthz而基础镜像里没装 curl健康检查就会一直失败。我上面的写法直接使用 Python 自带的urllib避开额外安装工具的问题确保镜像足够薄。healthcheck 参数里start_period是我测过很多次才真正重视起来的字段。它代表容器启动后给应用多少时间的“宽限期”。这段时间内检查失败不会计入重试次数也不会让容器被标记为 unhealthy。你的应用如果是 JVM 或重量级框架start_period一定要给足test123 这种轻量 Python 进程10 秒已经绰绰有余。4. 自动化验证让 test123 自己证明自己4.1 在 CI 里跑测试把人工检查变成闸门手动跑一次测试就像给代码做体检而 CI 里跑测试则像给每份代码设了关卡。我用的 GitHub Actions 流程很简洁核心只有三步检出代码、安装依赖、跑 pytest。测试通过后才允许继续构建镜像。name: ci on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 - run: pip install -r requirements.txt pytest - run: pytest tests/有些人会把测试阶段和镜像构建阶段混在一个 job 里表面看省时间实际上会让排错变麻烦。测试失败时连日志都要在构建日志里翻半天。拆开之后哪个环节出了问题一目了然。一次测试的耗时通常只有十几秒没必要用复杂的缓存优化去折腾。4.2 构建镜像并推送到 GHCR标签策略要提前想清楚CI 里的测试步骤通过后下一步就是构建镜像并推送到 GHCR。我选择 GHCR 是因为它和 GitHub 代码仓库在同一个生态里天然用仓库的权限模型不需要单独管理 Docker Hub 的账号密钥。build: needs: test runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkoutv4 - uses: docker/login-actionv3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-actionv5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}:${{ github.sha }} ghcr.io/${{ github.repository }}:latest标签策略值得单独说一句。我同时打两个标签一个是长 SHA记录当前代码的唯一版本一个是latest方便手动部署时不用去想完整版本号。长 SHA 标签可以用来回滚你只要找到之前某次提交的哈希就能把镜像拉回来。latest则只适合测试环境如果你要上生产最好放弃这个习惯因为可追溯性会变得极差。4.3 部署后的自动化探测别只看容器状态镜像推上去之后CI 的工作其实还没结束。如果你把部署动作交给人来完成那就白白浪费了前面的自动化。我在部署目标机上放了一个探测脚本专门用来验证“服务是否真的能用”而不是只看容器是不是还活着。#!/usr/bin/env bash set -euo pipefail target${1:-http://127.0.0.1:8080/healthz} timeout60 interval2 for i in $(seq 1 $((timeout / interval))); do code$(curl -s -o /dev/null -w %{http_code} --max-time 3 $target || true) if [ $code 200 ]; then echo 健康检查通过状态码 $code exit 0 fi sleep $interval done echo 健康检查失败超过 ${timeout}s 未通过 exit 1这个脚本的巧妙之处在于它把“TCP 能连上”和“业务能响应”明显分开。curl的退出码和 HTTP 状态码是两个不同的信号TCP 连不上curl 退出码非零TCP 通但业务不对状态码会变成 404、500 之类脚本依然会判定失败。我会在 CI 的部署 job 里调用这个脚本如果有 CD 工具也可以把这段逻辑直接挂到部署后置检查上。5. 踩坑记录与排查心得5.1 端口冲突与容器网络引发的“连不上”这是我跑 test123 时遇到最多的第一类问题。表现是启动新的容器后宿主机访问原本放在 8080 的服务立刻超时但容器本身看起来一切正常日志也没报错。一查才发现是两个场景叠加造成的前一个容器还在跑占用了 8080新容器以为自己绑定了同一个端口但实际上根本没绑上。这套坑的排查路径很有代表性。docker compose ps能看到当前服务状态ss -ltnp | grep 8080能看宿主机端口到底被谁占用如果本机装了多个容器项目还要注意不同 compose 项目之间的网络隔离。别用docker ps看一眼就下结论端口冲突的常态不是报错而是无响应。5.2 健康检查参数设置不合理导致误判健康检查不是越严格越好我踩过“start_period 过短”的坑。一开始把start_period设成了 3 秒结果 Uvicorn 进程稍微冷启动一下容器就被标记为 unhealthy负载均衡器不断把流量从它身上调走。表面看是服务崩溃实际只是没给它充分的启动时间。合理的做法是观察实际启动耗时然后留出 2 到 3 倍余量。test123 这种轻量服务启动一般不到 2 秒我把start_period定在 10 秒如果是重一点的框架建议至少 30 秒起步。interval也不宜设得太短比如每 2 秒检查一次不但增加负担还容易在进程短暂抖动时误报。我最终用的组合是interval: 10s、timeout: 5s、retries: 3、start_period: 10s这套参数在滚动发布里表现得最稳。5.3 CI 环境差异导致的测试失败CI 跑测试失败这件事谁都会遇到我印象最深的一次是因为时区。本地测试时断言了server_time日期但 GitHub 默认的 UTC 时间和本地小时数不一样导致测试偶尔失败。后面不再直接断言具体时间而是先解析 ISO 格式再检查关键字段才让测试真正稳定下来。另外一类环境差异来自依赖版本。本地装的包可能已经升级CI 却按requirements.txt重新解析版本如果我不锁版本今天能在本地跑通明天 CI 可能就挂了。后来我在 CI 的第一步固定了 Python 版本依赖锁到主版本范围测试环境才逐渐稳定下来。5.4 容器“不要用 shell 包一层进程”这个问题很难从日志排查到但确实影响释放能力。Dockerfile 里如果写CMD uvicorn app.main:app --host 0.0.0.0 --port 8000它会以 shell 形式启动shell 会变成一个中间父进程导致容器里的 PID 1 不是 Uvicorn。这样带来的直接问题包括容器停止时信号不一定会传给真正的工作进程出现进程假死脚本退出时也可能留下孤儿进程。我最后全部改成 exec 形式的数组写法CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这样容器主进程就是 Uvicorn 本身信号传递、退出清理都顺理成章。这种细节对单机小服务影响不大但如果你日后要把 test123 迁移到 Kubernetes或者用更严格的方式做滚动更新这一步就很有必要。5.5 常见问题速查表现象可能原因排查思路外部访问超时容器内正常服务只监听了 127.0.0.1检查--host是否绑定0.0.0.0端口 bind 冲突旧容器没停或端口已被占用docker compose psss -ltnp容器一直 unhealthy健康检查命令里的工具不存在使用基础镜像自带命令或检查start_periodCI 测试时好时坏时区、依赖版本漂移固定环境版本检测用相对时间或宽松断言镜像构建重复安装依赖Dockerfile 层缓存顺序不当先复制依赖再复制代码利用层缓存这张表不是标准答案但它概括了大多数小体量项目从本地到部署时会遇到的共同问题。排查顺序一般遵循“从外向内”先看网络能不能通再看容器状态再看进程日志最后看业务代码。6. 复盘与沉淀从 test123 长出来的脚手架6.1 把临时项目改造成可复用模板test123 跑顺之后我没有让它停留在一次性实验而是把它整理成项目模板。整理动作其实很便宜把目录结构固定下来把核心代码抽成模板变量再把 Dockerfile 和 CI 流水线作为标准配件保留。现在任何新想法进来我只需要复制模板改掉项目名和端口就能在半小时内获得一套带测试、带镜像、带 CI 的完整骨架。这个过程本质上就是用自动化替代重复劳动。模板化最大的收益不是省掉创建文件的几十秒而是强制每一份新代码都遵循同一条经过验证的路径不去重蹈那些踩过的坑。6.2 后续可以扩展的方向模板稳定之后我打算把更严格的质量门槛加进去。目前至少有三个方向是安全且常见的在 CI 里增加基础镜像漏洞扫描避免把已知问题带进生产给镜像打上容忍度和资源限制的默认值防止测试容器占用过高把部署时的探活脚本从 Bash 换成更严谨的探针形式方便日后对接 Kubernetes。我个人的建议是不要急着一次性把这些全接上。先让基本流水线稳定运行一段时间确认每一环都没有频繁的手工介入再逐步加压。否则你会分不清到底是新加的安全扫描在报错还是原有步骤本身就存在隐患。test123 最重要的一条经验就是链路先行卡控后补。6.3 几个真实有效的操作习惯如果要我从这套项目里拎出最值得带走的经验我会写下这三条。第一“测试项目”不是见不得人的临时目录它是预算最低、探索价值最高的一块试验田任何不熟悉的技术都该先放进这里撞一轮。第二健康检查地址永远走业务层而不是端口层待端口只能代表进程还在业务是否健康必须由应用自己用接口回答。第三所有部署参数都写进可编排的配置文件而不是靠人脑记住一条命令行因为下次你需要复现的时候一定会忘记当时的参数是怎么组合出来的。test123 这个项目本身很小它带给我的价值却远超过代码量。它让我从“找半天不知道服务为什么挂”变成“上线后听结果就好了”。下次你随手想新建一个临时工程时我建议你别小看它把最基本的一条链路走完你会发现自己积累下的不是一堆零散片段而是一套随时能复用的行走姿势。