☰
从test123项目看容器化部署与CI/CD自动化实践
2026/9/30 11:49:28 网站建设 项目流程

很多人看到 test123 这种项目名,第一反应就是占位符,是随手敲出来验证一下思路的临时产物。我偏偏喜欢这种名字,因为它天然没有心理负担:不会有人期待它长成一个完整业务,也不会有人在第一眼就给它贴上一堆架构预期。去年我给自己定了一条很具体的规矩:凡是名字带 test 的小项目,不许纠结业务完整度,但必须把从写代码、跑测试、做镜像、部署上线到健康检查的整条链路走通。test123 就是我反复用来跑这条链路的代号,最后它沉淀成了一整套可以复用的工程基建。这篇文章就是过去验证过程的完整复盘,从需求拆解到选型,从 Docker 编排到 CI 自动化,再到底层细节的坑,全部摊开讲。

如果你正准备学容器部署,或者手里一堆临时实验项目不知道如何变成自己的脚手架,那这套试验思路可以直接拿过去抄。

1. 项目缘起:为什么好好的工程,要叫一个 test123

1.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单机多容器编排够用,学习成本远低于 Kubernetes
CI/CDGitHub Actions仓库本身就在 GitHub,不用额外搭 Jenkins,免维护
镜像仓库GHCR和 GitHub 联动,权限模型简单,不需要另开账号

这个组合还有一个隐性优势:它足够贴近当下主流技术栈。哪怕日后要迁移到 Kubernetes,test123 里的健康检查、镜像构建、环境变量这些概念都能无缝平移。先在地面上跑通,再谈编排,比直接上手重型平台靠谱得多。

2. 核心设计拆解:test123 到底在测什么

2.1 服务本身:一个能稳定返回 JSON 的进程

test123 虽然叫“测试”,但它对外提供的核心功能非常明确:一个独立运行、带状态校验的 HTTP 服务。我选择 JSON 返回而不是返回 HTML,是因为 JSON 接口更容易做自动化断言。接口测试只需要校验状态码和响应结构,不需要关心前端渲染逻辑。这个设计很直接:

from fastapi import FastAPI from datetime import datetime, timezone app = FastAPI(title="test123", version="0.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 PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 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: - TZ=Asia/Shanghai healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"] 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/checkout@v4 - uses: actions/setup-python@v5 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/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v5 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}" timeout=60 interval=2 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 ps+ss -ltnp
容器一直 unhealthy健康检查命令里的工具不存在使用基础镜像自带命令,或检查start_period
CI 测试时好时坏时区、依赖版本漂移固定环境版本,检测用相对时间或宽松断言
镜像构建重复安装依赖Dockerfile 层缓存顺序不当先复制依赖再复制代码,利用层缓存

这张表不是标准答案,但它概括了大多数小体量项目从本地到部署时会遇到的共同问题。排查顺序一般遵循“从外向内”:先看网络能不能通,再看容器状态,再看进程日志,最后看业务代码。

6. 复盘与沉淀:从 test123 长出来的脚手架

6.1 把临时项目改造成可复用模板

test123 跑顺之后,我没有让它停留在一次性实验,而是把它整理成项目模板。整理动作其实很便宜:把目录结构固定下来,把核心代码抽成模板变量,再把 Dockerfile 和 CI 流水线作为标准配件保留。

现在任何新想法进来,我只需要复制模板,改掉项目名和端口,就能在半小时内获得一套带测试、带镜像、带 CI 的完整骨架。这个过程本质上就是用自动化替代重复劳动。模板化最大的收益不是省掉创建文件的几十秒,而是强制每一份新代码都遵循同一条经过验证的路径,不去重蹈那些踩过的坑。

6.2 后续可以扩展的方向

模板稳定之后,我打算把更严格的质量门槛加进去。目前至少有三个方向是安全且常见的:在 CI 里增加基础镜像漏洞扫描,避免把已知问题带进生产;给镜像打上容忍度和资源限制的默认值,防止测试容器占用过高;把部署时的探活脚本从 Bash 换成更严谨的探针形式,方便日后对接 Kubernetes。

我个人的建议是不要急着一次性把这些全接上。先让基本流水线稳定运行一段时间,确认每一环都没有频繁的手工介入,再逐步加压。否则你会分不清到底是新加的安全扫描在报错,还是原有步骤本身就存在隐患。test123 最重要的一条经验就是:链路先行,卡控后补。

6.3 几个真实有效的操作习惯

如果要我从这套项目里拎出最值得带走的经验,我会写下这三条。第一,“测试项目”不是见不得人的临时目录,它是预算最低、探索价值最高的一块试验田,任何不熟悉的技术都该先放进这里撞一轮。第二,健康检查地址永远走业务层而不是端口层,待端口只能代表进程还在,业务是否健康必须由应用自己用接口回答。第三,所有部署参数都写进可编排的配置文件,而不是靠人脑记住一条命令行,因为下次你需要复现的时候,一定会忘记当时的参数是怎么组合出来的。

test123 这个项目本身很小,它带给我的价值却远超过代码量。它让我从“找半天不知道服务为什么挂”变成“上线后听结果就好了”。下次你随手想新建一个临时工程时,我建议你别小看它,把最基本的一条链路走完,你会发现自己积累下的不是一堆零散片段,而是一套随时能复用的行走姿势。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询