Django-云环境中的持续集成

这篇讲什么

  • 持续集成的价值不在于「有个流水线图标」,而在于把「上线前该做的检查」变成机器每次都会做的动作。这篇把 Django 项目里真正该进流水线的六条命令逐条实测了一遍退出码——包括两个会让人踩坑的地方:check --deploy 默认不会让流水线失败,测试库残留会让 manage.py test 在 CI 里卡在交互提问。
  • Jenkins、GitHub Actions、阿里云密钥控制台这些平台侧的配置按官方文档给可复制写法,本机没有容器运行时和云账号,这部分未验证。
  • 实测环境:WSL2 Ubuntu 26.04.1 LTS + Python 3.14.4 + Django 5.2.19 LTS + MySQL 9.7.4 + ruff 0.17.3 + black 26.10.1 + Coverage.py 7.17.2。
  • 平台侧(Jenkins / GitHub Actions / ACK / ACR / SLS)只给配置写法,未在本机执行。

CI/CD 的三段与部署策略

1.1 流程

  • Build & Package:装依赖、构建镜像或产物。
  • Test:静态检查、单元测试、迁移一致性、部署体检。
  • Deployment:把产物推到目标环境,并做可回滚的切换。

CICD的工作流程

CD 阶段的三种切换策略

策略 做法 优点 代价
Rolling Upgrade 滚动更新 多实例逐个下线升级 资源占用小,k8s 默认 新老版本会短暂共存,迁移必须双向兼容
Blue/Green 蓝绿 新集群整体部署完再切流量 回滚就是切回旧集群,快 需要双份资源
Canary 金丝雀 先放 1% 流量到新版本,观察再放量 风险最小 需要流量切分与指标基线(见《Django-接入监控告警》)

CD阶段不同的部署策略

滚动更新对 Django 的硬约束是:数据库迁移不能破坏上一版代码(加列可空、删列分两次发布),细节见《Django-生产环境要注意哪些事项》3.5。

二、流水线里该跑的六条命令(实测退出码)

先给结论表,后面逐条给实测输出。所有退出码都是本机跑的:

命令 作用 通过时 不通过时
ruff check . 静态错误与风格 0 1
black --check . 格式一致性 0 1
python manage.py makemigrations --check --dry-run 有没有忘了生成迁移 0 1
python manage.py check --deploy --fail-level WARNING 生产配置体检 0 1
python manage.py test --noinput 单元测试 0 1
coverage report --fail-under=80 覆盖率闸门 0 2

静态检查:ruff

1
2
3
4
5
$ ruff check core proj manage.py
Found 33 errors.
[*] 22 fixable with `--fix` (6 unsafe fixes available with `--fix --unsafe-fixes`).
$ echo $?
1

典型的一条提示(Python 3 已经不需要编码声明):

1
2
3
1 | # -*- coding: utf-8 -*-
| ^^^^^^^^^^^^^^^^^^^^^^^
help: Remove unnecessary coding comment

ruff 是 Rust 实现、单文件配置,官方文档见参考链接;--fix 只修安全项,--unsafe-fixes 要人工复核。

格式:black

1
2
3
4
5
6
$ black --check core proj manage.py
would reformat /mnt/e/Desktop/Test/django-deploy-probe/proj/settings/base.py
would reformat manage.py
11 files would be reformatted, 7 files would be left unchanged
$ echo $?
1

一个和 Django 有关的实测冷知识(前面批次踩过):startproject 生成的文件是否被格式化,取决于执行环境里有没有装 black(Django 会 shutil.which("black") 后调用格式化器),和 Django 版本无关。所以同一个 Django 版本在两台机器上生成的 settings.py 排版可能不一样,CI 里最好把 black 固定成依赖。

迁移一致性

1
2
3
4
$ python manage.py makemigrations --check --dry-run
No changes detected
$ echo $?
0

改了模型但忘了生成迁移时:

1
2
3
4
5
6
$ python manage.py makemigrations --check --dry-run
Migrations for 'core':
core/migrations/0002_deploynote.py
+ Create model DeployNote
$ echo $?
1

这条命令的意义:把「代码里有模型变更、仓库里没有对应迁移文件」变成构建失败,而不是等上线后 OperationalError: no such column。

2.4 部署体检:必须加 --fail-level

1
2
3
4
$ python manage.py check --deploy --settings=proj.settings.prod
System check identified 3 issues (0 silenced).
$ echo $?
0

默认有 warning 也返回 0,流水线会绿灯通过——这是最容易误会的点。要真的拦住:

1
2
3
4
$ python manage.py check --deploy --fail-level WARNING ; echo $?
1
$ python manage.py check --deploy --fail-level CRITICAL ; echo $?
0

--fail-level ERROR 同样返回 0(warning 仍不算 error),所以阈值要写 WARNING。

测试:--noinput 不是可选项

第一次踩坑现场:上一轮用了 --keepdb,测试库 test_django_note_deploy 留在实例里,这一轮不带 --noinput:

1
2
3
4
5
$ python manage.py test --settings=proj.settings.dev < /dev/null
Type 'yes' if you would like to try deleting the test database 'test_django_note_deploy', or 'no' to cancel:
EOFError: EOF when reading a line
$ echo $?
1

CI 里没有 tty,Django 的交互提问直接 EOFError,看起来像「测试挂了」其实是命令写法问题。加上 --noinput:

1
2
3
4
$ python manage.py test --noinput --settings=proj.settings.dev ; echo $?
Found 4 test(s).
System check identified no issues (0 silenced).
0

失败用例的退出码也实测过:

1
2
3
$ python manage.py test core.tests_fail_tmp --noinput ; echo $?
FAILED (failures=1)
1

覆盖率闸门

1
2
3
4
5
6
7
8
$ coverage run --source=core,proj manage.py test --noinput --settings=proj.settings.dev
$ coverage report
core/models.py 10 0 100%
core/views.py 64 39 39%
proj/settings/base.py 51 1 98%
proj/urls.py 4 0 100%
-------------------------------------------------
TOTAL 190 63 67%
1
2
3
4
$ coverage report --fail-under=20 ; echo $?
0
$ coverage report --fail-under=99 ; echo $?
2

注意阈值不达标时退出码是 2(不是 1),如果你的流水线只判断 == 1 会漏掉。

2.7 一个 shell 层的坑:管道之后取 $?

上面所有退出码都是「命令直接结束就取 $?」。如果写成 ruff check . | tail -5; echo $?,拿到的是 tail 的退出码(永远 0),流水线会假装通过。本轮实测就中过一次:ruff 明明报了 33 个错,脚本却显示退出码 0。要么不加管道,要么用 set -o pipefail。

三、GitHub Actions 写法(官方文档,未在本机验证)

.github/workflows/ci.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: app
ports: ["3306:3306"]
options: >-
--health-cmd="mysqladmin ping"
--health-interval=5s
--health-timeout=5s
--health-retries=10
env:
DJANGO_SETTINGS_MODULE: proj.settings.prod
DJANGO_SECRET_KEY: ci-only-not-a-real-secret-please-rotate-1234567890
DJANGO_ALLOWED_HOSTS: 127.0.0.1,localhost
DJANGO_DB_HOST: 127.0.0.1
DJANGO_DB_PORT: "3306"
DJANGO_DB_NAME: app
DJANGO_DB_USER: root
DJANGO_DB_PASSWORD: ""
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: sudo apt-get update && sudo apt-get install -y default-libmysqlclient-dev pkg-config
- run: pip install -r requirements.txt
- run: ruff check .
- run: black --check .
- run: python manage.py makemigrations --check --dry-run
- run: python manage.py check --deploy --fail-level WARNING
- run: coverage run --source=core,proj manage.py test --noinput
- run: coverage report --fail-under=60
- run: coverage xml
- uses: actions/upload-artifact@v4
with:
name: coverage-xml
path: coverage.xml

要点(都是第二节实测结论的直接应用):

  • services 给 MySQL 配 healthcheck,否则第一条 SQL 会撞在还没就绪的库上。
  • DJANGO_SECRET_KEY 在 CI 里用一个专用假值即可(体检只看长度),不要把生产密钥放进 workflow。
  • --fail-level WARNING 与 --noinput 都要写全。
  • 本机没有 GitHub Actions 运行环境,这份 YAML 未执行验证,字段版本以官方文档为准。

四、Jenkins 流水线写法(官方文档,未在本机验证)

Jenkinsfile(declarative):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
pipeline {
agent any

environment {
DJANGO_SETTINGS_MODULE = "proj.settings.prod"
DJANGO_SECRET_KEY = credentials('django-ci-secret-key')
}

stages {
stage('Install') {
steps { sh 'pip install -r requirements.txt' }
}
stage('Lint') {
steps {
sh 'ruff check .'
sh 'black --check .'
}
}
stage('Checks') {
steps {
sh 'python manage.py makemigrations --check --dry-run'
sh 'python manage.py check --deploy --fail-level WARNING'
}
}
stage('Test') {
steps { sh 'coverage run --source=core,proj manage.py test --noinput' }
post {
always { sh 'coverage xml && coverage report --fail-under=60' }
}
}
stage('Build image') {
steps { sh 'docker build -t myapp:${BUILD_NUMBER} .' }
}
stage('Deploy') {
steps { sh 'kubectl apply -f k8s/ && kubectl rollout status deploy/web' }
}
}
}

老笔记里那句「Jenkins 的启动日志里面会自动产生初始化的 admin 密码」是对的(官方文档也是这个说法),但生产上应该用 --java-jar 参数或 K8s Secret 固定管理员密码,别每次从日志里捞。

kubectl rollout status 是「等滚动更新真的完成」的关键,否则流水线会在 Pod 还没就绪时就报成功。

4.1 工具对照

工具 形态 适合
Jenkins 自建,Pipeline as Code 需要精细控制、内网构建、和 k8s 深度集成
GitHub Actions 仓库内 YAML + 托管 runner 开源项目、GitHub 生态、零运维
GitLab CI .gitlab-ci.yml + Runner 代码托管与 CI 一体的团队
Spinnaker / Harness 专职 CD 平台 多集群、多区域复杂发布策略

老笔记把「CICD 工具」列成 Jenkins/Spinnaker/Harness 三项,缺了最常用的 GitHub Actions 与 GitLab CI,实际选型优先看代码托管在哪。

CICD的使用

五、密钥不进仓库:Kubernetes Secrets

5.1 原则

  • 镜像构建不依赖本地文件(COPY 的东西都在仓库里,密钥不能进去)。
  • 账号密码不进代码库、不进 Dockerfile、不进 docker-compose.yml 的明文段。
  • 存放位置:Kubernetes Secrets、Vault、云 KMS/凭据管理;运行时通过环境变量注入。

5.2 命令(官方文档口径,未在本机执行)

老笔记里 --fromliteral= 少了连字符,正确写法:

1
2
3
4
5
6
$ kubectl create secret generic prod-db-secret \
--from-literal=username=produser \
--from-literal=password=change-me
$ kubectl get secret prod-db-secret
$ kubectl describe secret prod-db-secret
$ kubectl delete secret prod-db-secret

在 Deployment 里引用:

1
2
3
4
5
6
7
8
9
10
11
env:
- name: DJANGO_DB_PASSWORD
valueFrom:
secretKeyRef:
name: prod-db-secret
key: password
- name: DJANGO_SECRET_KEY
valueFrom:
secretKeyRef:
name: django-secret-key
key: value

代码侧仍然是熟悉的那一套:

settings.py
1
2
3
4
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES["default"]["PASSWORD"] = os.environ.get("DJANGO_DB_PASSWORD", "")

三个必须知道的限制:

  • Secret 默认只是 base64 编码,不是加密;要开 etcd 静态加密或外部 KMS。
  • kubectl describe pod 会把环境变量名打出来(值显示为 <set>),日志里不小心打印就泄漏。
  • Secret 权限要用 RBAC 收紧,别给所有 ServiceAccount 读全命名空间的权限。

5.3 阿里云 ACK 上的做法(控制台操作,未验证)

  1. 集群「配置管理 → 保密字典」创建 Secret。
  2. 工作负载 YAML 里引用 secretKeyRef。
  3. Python 代码仍然只读环境变量(第 5.2 节那段),换平台不用改代码。

阿里云创建密钥

密钥引用

环境变量读取

六、在 k8s 里跑 Jenkins(官方文档口径,未验证)

老笔记的方式是 Docker-in-Docker 镜像部署到集群:

1
2
3
4
5
$ docker pull myjenkins/blueocean:latest
$ docker tag myjenkins/blueocean:latest registry.cn-beijing.aliyuncs.com/your-ns/jenkins:latest
$ docker push registry.cn-beijing.aliyuncs.com/your-ns/jenkins:latest
$ kubectl apply -f jenkins-service.yaml
$ kubectl apply -f jenkins-deployment.yaml

说明两点:

  • 原文用的是个人镜像 ihopeit/jenkins-dod:1.1,长期维护性差;现在应优先用官方 jenkins/jenkins 或 myjenkins/blueocean,DinD 场景还要给 --privileged 或改用 Kaniko/buildah 这类无 daemon 构建方案。
  • 流程总结仍然是对的:建 Ingress 路由 → 配置 kubeconfig 与镜像仓库凭据 → 建 Pipeline 项目 → 触发构建。

Jenkins 使用

落地顺序建议

  1. 先把第二节的六条命令在本地跑通,确认每条的退出码符合预期(这是流水线的骨架)。
  2. 把命令搬进 CI:先只跑 lint + test,稳定后再加 check --deploy --fail-level WARNING 和覆盖率闸门。
  3. 密钥全部走环境变量注入,仓库里只留 .env.example。
  4. 加镜像构建与推送(这一步才需要容器运行时)。
  5. 部署阶段加 kubectl rollout status 或云上的发布等待,别「apply 完就报成功」。
  6. 发布策略从滚动更新起步,量大了再上蓝绿/金丝雀,并保证迁移双向兼容。

参考