
最近折腾了一个简单的 CI/CD(持续集成/持续部署)流水线,主要是为了练手 Docker 和 GitHub Actions。整个项目做下来踩了几个坑,这篇把遇到的问题和解决办法整理一下。
最近在学习 DevOps 相关的东西,想着光看文档不动手等于没学,就搞了这个最简单的流水线练手。

这是整个流程的基本架构。画的丑了点,凑合看吧。
应用本身就是一个极简的 Flask API,只有两个路由:/ 和 /health。就这点东西,没有别的了。我刻意保持简单,因为这个项目的重点从来不是应用本身,而是整个 CI/CD 流程。如果应用搞得太复杂,我大部分时间都会花在调试业务逻辑上,而不是搞清楚 GitHub Actions 到底是怎么工作的。一个"Hello World"接口足够用来测试、构建和部署,不会分散注意力。
flask-cicd-demo/
├── .github/
│ └── workflows/
│ └── ci-cd.yml
├── src/
│ ├── app.py
│ └── requirements.txt
├── tests/
│ └── test_app.py
├── dockerfiles/
│ └── python.dockerfile
├── docker-compose.yml
├── .dockerignore
└── .gitignore
说明一下:我用了 docker-compose,虽然实际上不需要多容器管理,但正好顺便练手。
思路很简单:一个 job,push 时触发,步骤是 checkout 代码、安装依赖、运行 pytest。在碰 Docker 之前先把这一块跑通。
jobs: test_job: runs-on: ubuntu-latest steps: - name: checkout code uses: actions/checkout@v4 - name: setup python uses: actions/setup-python@v4 with: python-version: 3.11 - name: install dependencies run: | cd src && pip install -r requirements.txt cd .. - name: run tests run: | PYTHONPATH=src pytest tests/
第一次跑的时候,直接报错。

低级错误:忘了在 requirements.txt 里加 pytest。补上就解决了。
第二次跑,又撞墙了。报错"No module named app"。

test_app.py 里是这样 import 的:
from app import app
本地从 src/ 目录下跑没问题,但 app.py 其实在 src/ 目录里,而 pytest 默认从项目根目录运行,所以 Python 找不到这个模块。解决办法是在 pytest 命令前加 PYTHONPATH=src,告诉 Python"import 的时候也去 src/ 目录下找"。

加上之后,test_job 终于跑通了。
测试跑起来了,接下来让流水线构建 Docker 镜像。Dockerfile 放在 dockerfiles/python.dockerfile,不是在项目根目录,里面的 COPY 指令是这样的:
COPY src/requirements.txt /app
COPY src/ /app
因为是通过 docker-compose.yml 构建的,需要把 build context 设置正确。第一次我是这么写的:
build: context: ./dockerfiles dockerfile: python.dockerfile
凉了。build context 只包含了 dockerfiles/ 文件夹,src/ 对它来说是透明的,所以 COPY src/requirements.txt /app 根本找不到文件。
正确的做法是把 context 设在项目根目录,dockerfile 相对于根目录指定路径:
build: context: . dockerfile: dockerfiles/python.dockerfile
context 和 Dockerfile 路径是两回事,当你的 Dockerfile 不在默认位置时特别容易搞混。改完之后 Docker 构建也正常了。
这部分是我做这个项目真正想练的东西。
Artifact 就是 job 产生的一些文件,你希望 job 结束后还能保留。runner 跑完之后它的文件系统就没了,不显式保存的话东西就全丢了。actions/upload-artifact 就是干这个的:给它一个名字和路径,它就会把文件附加到这次运行上,可以从 Actions 页面下载,也可以在后续 job 中用 actions/download-artifact 拉取。
我在 requirements.txt 里加了 pytest-cov,测试运行时生成覆盖率文件,然后上传,这样就能从 Actions 摘要里下载:
- name: run tests run: | PYTHONPATH=src pytest tests/ --cov=src --cov-report=xml
- name: Upload Job Artifacts uses: actions/upload-artifact@v4 with: name: test-results path: coverage.xml
第一次跑又报错了。

我在命令里加了 --cov 参数,但还没装插件。pytest 不装 pytest-cov 根本不认识 --cov。先加到 requirements.txt,再跑就正常了。
这个更有意思,不只是给人看的报告,而是后续 job 真正要用到的文件,不用重新构建:
- name: save docker image run: | docker save -o flaskapp.tar flaskapp:latest
- name: Upload Job Artifacts uses: actions/upload-artifact@v4 with: name: docker-image path: flaskapp.tar
这个直接挂了:

原来 docker compose build 默认不会把镜像 tag 成 flaskapp:latest。Compose 会给它起个类似 flaskapp_flaskapp:latest 之类的名字,除非你显式指定。一直不知道这是默认行为,直到踩了这个坑。
修复方法是在 docker-compose.yml 里加个显式的 image 字段:
services: flaskapp: image: flaskapp:latest build: context: . dockerfile: dockerfiles/python.dockerfile
这样就能正常保存 Docker 镜像文件了。

最后一步,我希望流水线能真正部署到某个地方,而不是构建完就停了。我在阿里云的 ECS 上搭了一个自托管 runner。
deploy_job: needs: build_job runs-on: self-hosted steps: - name: Download Job Artifacts uses: actions/download-artifact@v4 with: name: docker-image path: . - name: load docker image run: | docker load -i flaskapp.tar - name: run docker container run: | docker stop flaskapp || true docker rm flaskapp || true docker run -d -p 5000:5000 --name flaskapp flaskapp
这里踩了两个坑。
第一个在下载步骤。我当时写的是 path: flaskapp.tar,因为我确实想要那个文件。这看起来很合理。但 download-artifact 里的 path 不是文件名,而是要下载到的目录。所以它不是下载 flaskapp.tar 文件进去,而是尝试创建一个叫 flaskapp.tar 的文件夹。下一步 docker load -i flaskapp.tar 当然就蒙了,不知道怎么处理这个文件夹。修复方法是把 path 改成 .,直接下载到当前工作目录,文件自然会以 flaskapp.tar 的形式出现。
第二个坑比较隐蔽,只在第二次部署时出现,第一次不会。docker run -d -p 5000:5000 --name flaskapp flaskapp 第一次跑没问题。第二次跑 pipeline 的时候就失败了,报容器名 flaskapp 已经存在。说得也没错,上次部署的那个容器还在跑呢。写步骤的时候完全没想到这个。解决办法是在启动新容器之前先停掉并删除旧的。
整个流水线就这样串起来了:测试、构建、两种 artifact、部署,全链路跑通,部署到了自己的云服务器上。

这个项目学到不少东西:GitHub Actions 的基本用法、upload/download artifact 这些 Action 的细节、还有 Docker 镜像可以打成 tar 包保存这种事之前完全没想过。接下来还有很多要学的,会继续折腾这个小项目。
如果你有什么建议能让这个流水线学到更多东西,欢迎留言。