site logo

Marico's space

搭建首个 CI/CD Pipeline 的经验总结 (Docker + GitHub Actions)

服务器技术 2026-09-07 20:56:08 5

最近折腾了一个简单的 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,虽然实际上不需要多容器管理,但正好顺便练手。

阶段一:在 CI 中运行测试

思路很简单:一个 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 终于跑通了。

阶段二:在 CI 中构建 Docker 镜像

测试跑起来了,接下来让流水线构建 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 构建也正常了。

阶段三:Job Artifacts

这部分是我做这个项目真正想练的东西。

Artifact 就是 job 产生的一些文件,你希望 job 结束后还能保留。runner 跑完之后它的文件系统就没了,不显式保存的话东西就全丢了。actions/upload-artifact 就是干这个的:给它一个名字和路径,它就会把文件附加到这次运行上,可以从 Actions 页面下载,也可以在后续 job 中用 actions/download-artifact 拉取。

第一个 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,再跑就正常了。

第二个 artifact:构建好的 Docker 镜像本身

这个更有意思,不只是给人看的报告,而是后续 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 镜像文件了。

阶段四:通过自托管 Runner 部署

最后一步,我希望流水线能真正部署到某个地方,而不是构建完就停了。我在阿里云的 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 包保存这种事之前完全没想过。接下来还有很多要学的,会继续折腾这个小项目。

如果你有什么建议能让这个流水线学到更多东西,欢迎留言。