diff --git a/.revision-progress b/.revision-progress index ba495fa..2aa5a1e 100644 --- a/.revision-progress +++ b/.revision-progress @@ -1,2 +1,4 @@ # zhuangang 结构化内容改造进度(每完成一个阶段追加一行;重启时跳过已完成阶段) phase1 done 2026-10-23 15:05:36:目录设计、CONTRIBUTING、schema、tools/build_catalog.py、run_examples.py、check_sensitive.py、31 个技术栈首页骨架、周文件 frontmatter +phase2a done 2026-10-23:后端 7 个技术栈(go/gin/postgresql/mysql/redis/mq/grpc-microservices)完成,提交 5eb67c5 +phase2 done 2026-10-23:后端 7 个 + 运维 5 个技术栈(docker/nginx/linux/cicd/git)全部完成;run_examples 14 项通过;上半部分提交 5eb67c5 diff --git a/catalog.json b/catalog.json index 37373bb..31bc6c4 100644 --- a/catalog.json +++ b/catalog.json @@ -247,70 +247,80 @@ "domain": "devops", "title": "CI/CD", "index": "topics/devops/cicd/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 2, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 3, + "进阶": 3, + "场景题": 3 }, - "examples": [] + "examples": [ + "topics/devops/cicd/examples/pipeline" + ] }, { "stack": "docker", "domain": "devops", "title": "Docker 与 Compose", "index": "topics/devops/docker/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 2, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 4, + "进阶": 3, + "场景题": 3 }, - "examples": [] + "examples": [ + "topics/devops/docker/examples/go-api-compose" + ] }, { "stack": "git", "domain": "devops", "title": "Git", "index": "topics/devops/git/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 2, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 4, + "进阶": 3, + "场景题": 3 }, - "examples": [] + "examples": [ + "topics/devops/git/examples/git-lab" + ] }, { "stack": "linux", "domain": "devops", "title": "Linux 运维基础", "index": "topics/devops/linux/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 2, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 4, + "进阶": 3, + "场景题": 3 }, - "examples": [] + "examples": [ + "topics/devops/linux/examples/log-analysis" + ] }, { "stack": "nginx", "domain": "devops", "title": "Nginx", "index": "topics/devops/nginx/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 2, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 4, + "进阶": 3, + "场景题": 3 }, - "examples": [] + "examples": [ + "topics/devops/nginx/examples/gateway" + ] }, { "stack": "fe-testing-perf", @@ -2134,7 +2144,13 @@ "stack": [ "cicd" ], - "tags": [], + "tags": [ + "CI/CD", + "持续集成", + "持续部署", + "流水线", + "发布" + ], "level": null, "prerequisites": [], "related": [], @@ -2146,7 +2162,248 @@ ], "order": null, "path": "topics/devops/cicd/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "devops.cicd.interview", + "title": "CI/CD 面试题", + "type": "interview", + "domain": "devops", + "stack": [ + "cicd" + ], + "tags": [ + "CI/CD", + "发布", + "回滚", + "面试" + ], + "level": "intermediate", + "prerequisites": [], + "related": [ + "devops.cicd.pipeline", + "devops.cicd.release-rollback" + ], + "est_hours": null, + "week": [ + 14, + 16 + ], + "order": null, + "path": "topics/devops/cicd/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 3, + "进阶": 3, + "场景题": 3 + } + }, + { + "id": "devops.cicd.pipeline", + "title": "流水线设计:触发、阶段、缓存、服务容器、密钥", + "type": "lesson", + "domain": "devops", + "stack": [ + "cicd" + ], + "tags": [ + "流水线", + "GitHub Actions", + "缓存", + "服务容器", + "密钥管理" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.cicd.release-rollback", + "devops.cicd.interview", + "devops.git.index" + ], + "est_hours": 2, + "week": [ + 3, + 7 + ], + "order": 1, + "path": "topics/devops/cicd/01-pipeline.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "devops.cicd.release-rollback", + "title": "发布与回滚:镜像标签、环境、滚动 / 蓝绿 / 金丝雀、数据库迁移", + "type": "lesson", + "domain": "devops", + "stack": [ + "cicd" + ], + "tags": [ + "发布策略", + "回滚", + "蓝绿发布", + "金丝雀", + "数据库迁移", + "特性开关" + ], + "level": "intermediate", + "prerequisites": [ + "devops.cicd.pipeline" + ], + "related": [ + "devops.cicd.scenario.multi-lang-pipeline", + "devops.nginx.https-stream-limit", + "devops.cicd.interview" + ], + "est_hours": 3, + "week": [ + 14 + ], + "order": 2, + "path": "topics/devops/cicd/02-release-rollback.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "devops.cicd.scenario.multi-lang-pipeline", + "title": "多语言仓库的流水线:Go + Python + 前端,按改动构建、自动部署和回滚", + "type": "scenario", + "domain": "devops", + "stack": [ + "cicd", + "docker", + "go", + "python", + "vue" + ], + "tags": [ + "Monorepo", + "流水线", + "路径过滤", + "自动部署", + "回滚" + ], + "level": "intermediate", + "prerequisites": [ + "devops.cicd.pipeline" + ], + "related": [ + "devops.cicd.release-rollback", + "devops.cicd.interview", + "devops.docker.scenario.compose-dev-env" + ], + "est_hours": 2, + "week": [ + 14, + 15 + ], + "order": null, + "path": "topics/devops/cicd/scenarios/multi-lang-pipeline.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] + }, + { + "id": "devops.docker.compose-network-volume", + "title": "Compose、网络和数据卷:多服务编排、健康检查、环境变量", + "type": "lesson", + "domain": "devops", + "stack": [ + "docker" + ], + "tags": [ + "Compose", + "网络", + "数据卷", + "健康检查", + "环境变量" + ], + "level": "basic", + "prerequisites": [ + "devops.docker.image-dockerfile" + ], + "related": [ + "devops.docker.scenario.compose-dev-env", + "devops.docker.interview" + ], + "est_hours": 2, + "week": [ + 3, + 6, + 13 + ], + "order": 2, + "path": "topics/devops/docker/02-compose-network-volume.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "devops.docker.image-dockerfile", + "title": "镜像和 Dockerfile:分层缓存、多阶段构建、非 root、信号处理", + "type": "lesson", + "domain": "devops", + "stack": [ + "docker" + ], + "tags": [ + "Dockerfile", + "多阶段构建", + "镜像分层", + "构建缓存", + "distroless" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.docker.compose-network-volume", + "devops.docker.interview" + ], + "est_hours": 3, + "week": [ + 3, + 6 + ], + "order": 1, + "path": "topics/devops/docker/01-image-dockerfile.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] }, { "id": "devops.docker.index", @@ -2156,7 +2413,12 @@ "stack": [ "docker" ], - "tags": [], + "tags": [ + "Docker", + "容器", + "Compose", + "镜像" + ], "level": null, "prerequisites": [], "related": [], @@ -2169,7 +2431,135 @@ ], "order": null, "path": "topics/devops/docker/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "devops.docker.interview", + "title": "Docker 面试题", + "type": "interview", + "domain": "devops", + "stack": [ + "docker" + ], + "tags": [ + "Docker", + "容器", + "Compose", + "面试" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.docker.image-dockerfile", + "devops.docker.compose-network-volume" + ], + "est_hours": null, + "week": [ + 3, + 14, + 16 + ], + "order": null, + "path": "topics/devops/docker/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 4, + "进阶": 3, + "场景题": 3 + } + }, + { + "id": "devops.docker.scenario.compose-dev-env", + "title": "一条命令拉起开发环境:Go API + Python AI 服务 + PostgreSQL + Redis", + "type": "scenario", + "domain": "devops", + "stack": [ + "docker", + "go", + "fastapi", + "postgresql", + "redis" + ], + "tags": [ + "Compose", + "开发环境", + "多语言", + "健康检查", + "配置管理" + ], + "level": "intermediate", + "prerequisites": [ + "devops.docker.compose-network-volume" + ], + "related": [ + "devops.docker.interview", + "devops.nginx.index" + ], + "est_hours": 2, + "week": [ + 3, + 13, + 14 + ], + "order": null, + "path": "topics/devops/docker/scenarios/compose-dev-env.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] + }, + { + "id": "devops.git.branch-commit", + "title": "分支模型和提交规范:主干开发、Git Flow、Conventional Commits、代码评审", + "type": "lesson", + "domain": "devops", + "stack": [ + "git" + ], + "tags": [ + "分支模型", + "Git Flow", + "主干开发", + "提交规范", + "Code Review" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.git.merge-rebase-recovery", + "devops.cicd.pipeline", + "devops.git.interview" + ], + "est_hours": 1.5, + "week": [ + 1, + 8 + ], + "order": 1, + "path": "topics/devops/git/01-branch-commit.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] }, { "id": "devops.git.index", @@ -2179,7 +2569,12 @@ "stack": [ "git" ], - "tags": [], + "tags": [ + "Git", + "版本控制", + "分支", + "协作" + ], "level": null, "prerequisites": [], "related": [], @@ -2191,7 +2586,170 @@ ], "order": null, "path": "topics/devops/git/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "devops.git.interview", + "title": "Git 面试题", + "type": "interview", + "domain": "devops", + "stack": [ + "git" + ], + "tags": [ + "Git", + "分支", + "撤销", + "面试" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.git.branch-commit", + "devops.git.merge-rebase-recovery" + ], + "est_hours": null, + "week": [ + 15, + 16 + ], + "order": null, + "path": "topics/devops/git/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 4, + "进阶": 3, + "场景题": 3 + } + }, + { + "id": "devops.git.merge-rebase-recovery", + "title": "合并、变基和撤销:merge / rebase / cherry-pick、revert / reset、reflog 找回", + "type": "lesson", + "domain": "devops", + "stack": [ + "git" + ], + "tags": [ + "merge", + "rebase", + "cherry-pick", + "revert", + "reset", + "reflog", + "stash" + ], + "level": "intermediate", + "prerequisites": [ + "devops.git.branch-commit" + ], + "related": [ + "devops.git.scenario.hotfix-and-leaked-secret", + "devops.git.interview" + ], + "est_hours": 2, + "week": [ + 8, + 15 + ], + "order": 2, + "path": "topics/devops/git/02-merge-rebase-recovery.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "devops.git.scenario.hotfix-and-leaked-secret", + "title": "线上紧急修复和密钥误提交:热修复分支、cherry-pick、清理历史和轮换密钥", + "type": "scenario", + "domain": "devops", + "stack": [ + "git", + "cicd" + ], + "tags": [ + "热修复", + "cherry-pick", + "密钥泄露", + "历史清理", + "安全" + ], + "level": "intermediate", + "prerequisites": [ + "devops.git.merge-rebase-recovery" + ], + "related": [ + "devops.git.interview", + "devops.cicd.pipeline" + ], + "est_hours": 1.5, + "week": [ + 8, + 15 + ], + "order": null, + "path": "topics/devops/git/scenarios/hotfix-and-leaked-secret.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] + }, + { + "id": "devops.linux.commands", + "title": "常用命令:文件、文本处理、进程、端口、权限、systemd", + "type": "lesson", + "domain": "devops", + "stack": [ + "linux" + ], + "tags": [ + "grep", + "awk", + "ps", + "ss", + "chmod", + "systemd", + "journalctl" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.linux.troubleshooting", + "devops.linux.interview" + ], + "est_hours": 3, + "week": [ + 14 + ], + "order": 1, + "path": "topics/devops/linux/01-commands.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] }, { "id": "devops.linux.index", @@ -2201,7 +2759,12 @@ "stack": [ "linux" ], - "tags": [], + "tags": [ + "Linux", + "命令行", + "运维", + "线上排查" + ], "level": null, "prerequisites": [], "related": [], @@ -2211,7 +2774,171 @@ ], "order": null, "path": "topics/devops/linux/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "devops.linux.interview", + "title": "Linux 面试题", + "type": "interview", + "domain": "devops", + "stack": [ + "linux" + ], + "tags": [ + "Linux", + "命令", + "排查", + "面试" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.linux.commands", + "devops.linux.troubleshooting" + ], + "est_hours": null, + "week": [ + 14, + 16 + ], + "order": null, + "path": "topics/devops/linux/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 4, + "进阶": 3, + "场景题": 3 + } + }, + { + "id": "devops.linux.scenario.incident-troubleshooting", + "title": "线上故障排查:接口变慢、磁盘写满、进程被 OOM 杀掉", + "type": "scenario", + "domain": "devops", + "stack": [ + "linux", + "go", + "nginx" + ], + "tags": [ + "故障排查", + "OOM", + "磁盘满", + "pprof", + "复盘" + ], + "level": "intermediate", + "prerequisites": [ + "devops.linux.troubleshooting" + ], + "related": [ + "devops.linux.interview", + "devops.nginx.interview" + ], + "est_hours": 2, + "week": [ + 14 + ], + "order": null, + "path": "topics/devops/linux/scenarios/incident-troubleshooting.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] + }, + { + "id": "devops.linux.troubleshooting", + "title": "线上排查:CPU、内存、磁盘、网络、日志的定位思路", + "type": "lesson", + "domain": "devops", + "stack": [ + "linux" + ], + "tags": [ + "排查", + "top", + "负载", + "OOM", + "磁盘", + "TIME_WAIT", + "pprof" + ], + "level": "intermediate", + "prerequisites": [ + "devops.linux.commands" + ], + "related": [ + "devops.linux.scenario.incident-troubleshooting", + "devops.linux.interview" + ], + "est_hours": 3, + "week": [ + 14 + ], + "order": 2, + "path": "topics/devops/linux/02-troubleshooting.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "devops.nginx.https-stream-limit", + "title": "HTTPS、SSE / WebSocket、限流和灰度", + "type": "lesson", + "domain": "devops", + "stack": [ + "nginx" + ], + "tags": [ + "HTTPS", + "TLS", + "SSE", + "WebSocket", + "limit_req", + "灰度发布" + ], + "level": "intermediate", + "prerequisites": [ + "devops.nginx.proxy-static" + ], + "related": [ + "devops.nginx.scenario.ai-gateway-sse-canary", + "devops.nginx.interview" + ], + "est_hours": 3, + "week": [ + 7, + 14 + ], + "order": 2, + "path": "topics/devops/nginx/02-https-stream-limit.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] }, { "id": "devops.nginx.index", @@ -2221,7 +2948,13 @@ "stack": [ "nginx" ], - "tags": [], + "tags": [ + "Nginx", + "反向代理", + "HTTPS", + "负载均衡", + "SSE" + ], "level": null, "prerequisites": [], "related": [], @@ -2233,7 +2966,132 @@ ], "order": null, "path": "topics/devops/nginx/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "devops.nginx.interview", + "title": "Nginx 面试题", + "type": "interview", + "domain": "devops", + "stack": [ + "nginx" + ], + "tags": [ + "Nginx", + "反向代理", + "负载均衡", + "面试" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.nginx.proxy-static", + "devops.nginx.https-stream-limit" + ], + "est_hours": null, + "week": [ + 7, + 14, + 16 + ], + "order": null, + "path": "topics/devops/nginx/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 4, + "进阶": 3, + "场景题": 3 + } + }, + { + "id": "devops.nginx.proxy-static", + "title": "反向代理、负载均衡和静态资源:location 匹配、proxy_pass、SPA 回退", + "type": "lesson", + "domain": "devops", + "stack": [ + "nginx" + ], + "tags": [ + "反向代理", + "location", + "proxy_pass", + "upstream", + "try_files", + "gzip" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.nginx.https-stream-limit", + "devops.nginx.interview" + ], + "est_hours": 2, + "week": [ + 3, + 7 + ], + "order": 1, + "path": "topics/devops/nginx/01-proxy-static.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, + { + "id": "devops.nginx.scenario.ai-gateway-sse-canary", + "title": "AI 应用网关:流式输出不卡顿、按用户灰度新版本", + "type": "scenario", + "domain": "devops", + "stack": [ + "nginx", + "fastapi" + ], + "tags": [ + "SSE", + "流式输出", + "灰度发布", + "限流", + "网关" + ], + "level": "intermediate", + "prerequisites": [ + "devops.nginx.https-stream-limit" + ], + "related": [ + "devops.nginx.interview", + "devops.docker.scenario.compose-dev-env" + ], + "est_hours": 2, + "week": [ + 7, + 13, + 14 + ], + "order": null, + "path": "topics/devops/nginx/scenarios/ai-gateway-sse-canary.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] }, { "id": "frontend.fe-testing-perf.index", diff --git a/topics/devops/cicd/01-pipeline.md b/topics/devops/cicd/01-pipeline.md new file mode 100644 index 0000000..8e90092 --- /dev/null +++ b/topics/devops/cicd/01-pipeline.md @@ -0,0 +1,90 @@ +--- +id: devops.cicd.pipeline +title: 流水线设计:触发、阶段、缓存、服务容器、密钥 +type: lesson +domain: devops +stack: [cicd] +tags: [流水线, GitHub Actions, 缓存, 服务容器, 密钥管理] +level: basic +est_hours: 2 +related: [devops.cicd.release-rollback, devops.cicd.interview, devops.git.index] +week: [3, 7] +order: 1 +--- + +# 流水线设计:触发、阶段、缓存、服务容器、密钥 + +## 解释说明 + +- **CI(持续集成)**:每次推送 / 合并请求自动执行“检查 → 测试 → 构建”,尽早发现问题。**合并请求必须 CI 通过**才允许合并。 +- **CD**:持续交付(自动构建出可发布的产物,人工点一下发布)和持续部署(自动发布到生产)。大部分团队是测试环境自动部署、生产环境人工审批。 +- **典型阶段**: + +| 阶段 | 内容 | 失败时 | +| --- | --- | --- | +| 检查 | 格式化、lint(`go vet`、`ruff`、`eslint`)、类型检查 | 最快失败,最便宜 | +| 测试 | 单元测试、集成测试(用服务容器起数据库) | 阻止合并 | +| 构建 | 编译、打包前端、构建镜像 | 阻止发布 | +| 安全 | 依赖漏洞扫描、镜像扫描、密钥扫描 | 视严重程度阻止 | +| 部署 | 推送镜像、更新环境、健康检查 | 自动回滚 | + +- **触发条件**:`push` 到主分支、`pull_request`、打标签、定时、手动。 +- **缓存**:依赖下载(Go 模块、pip、npm)缓存起来,按锁文件(`go.sum`、`package-lock.json`)的哈希作为缓存 key。 +- **服务容器**:CI 里起一个临时 PostgreSQL / Redis,跑集成测试。 +- **密钥**:放在平台的 Secrets 里,通过环境变量注入;日志里平台会自动打码,但不要主动 `echo`。合并请求来自外部 fork 时,平台默认不提供密钥。 +- **流水线即代码**:配置文件和代码一起放在仓库里,可评审、可追溯。 + +## 案例 + +前端出身转后端,最常见的流水线:合并请求触发 `lint + test + build`;合并到 main 后构建镜像(标签是提交哈希)并自动部署到测试环境;生产发布由负责人在平台上手动触发,指定镜像标签。 + +## 代码示例 + +完整文件见 [examples/pipeline/ci.yml](examples/pipeline/ci.yml),下面是 Go 服务的测试任务(带 PostgreSQL 服务容器): + +```yaml +jobs: + api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod # 和 go.mod 保持一致,自带模块缓存 + - run: go vet ./... + - run: go test -race -count=1 ./... + env: + TEST_DATABASE_URL: postgres://test:test@localhost:5432/test?sslmode=disable +``` + +本仓库自己的检查命令也可以放进流水线: + +```yaml + - run: python3 tools/build_catalog.py --check --strict # 内容规范校验 + - run: python3 tools/run_examples.py # 运行示例代码 +``` + +Action 的版本号(`@v4`、`@v5`)会更新,以各 Action 仓库的说明为准。 + +## 易错点 + +- **测试依赖真实外部服务**(真实的模型 API、第三方支付):不稳定、花钱、泄露密钥。单元测试用假实现,真实调用放到单独的、手动触发的任务里。 +- **用 `latest` 标签部署**:无法确定线上是哪个版本,也无法回滚。 +- **缓存 key 不包含锁文件哈希**:依赖更新了还在用旧缓存。 +- **把密钥写在配置文件里**:即使后来删除,也留在 Git 历史中,必须轮换密钥。 +- **流水线太慢没人等**:并行执行任务、按改动路径只跑相关部分、缓存依赖;目标是 10 分钟以内。 +- **CI 通过不是合并的必要条件**:分支保护里要设置“必须通过检查”。 + +## 面试常问 + +见 [CI/CD 面试题](interview.md):你们的 CI/CD 流程、怎么加速流水线、密钥怎么管理。 diff --git a/topics/devops/cicd/02-release-rollback.md b/topics/devops/cicd/02-release-rollback.md new file mode 100644 index 0000000..b62d673 --- /dev/null +++ b/topics/devops/cicd/02-release-rollback.md @@ -0,0 +1,87 @@ +--- +id: devops.cicd.release-rollback +title: 发布与回滚:镜像标签、环境、滚动 / 蓝绿 / 金丝雀、数据库迁移 +type: lesson +domain: devops +stack: [cicd] +tags: [发布策略, 回滚, 蓝绿发布, 金丝雀, 数据库迁移, 特性开关] +level: intermediate +prerequisites: [devops.cicd.pipeline] +est_hours: 3 +related: [devops.cicd.scenario.multi-lang-pipeline, devops.nginx.https-stream-limit, devops.cicd.interview] +week: [14] +order: 2 +--- + +# 发布与回滚:镜像标签、环境、滚动 / 蓝绿 / 金丝雀、数据库迁移 + +## 解释说明 + +**不可变产物**:一次构建出镜像(标签 = 提交哈希或版本号),测试、预发、生产用**同一个镜像**,只是配置不同。这样测试过的东西就是上线的东西,回滚就是切回上一个标签。 + +**环境**:开发 → 测试 → 预发(和生产配置尽量一致)→ 生产。配置通过环境变量注入,不打进镜像。 + +**发布策略** + +| 策略 | 做法 | 优点 | 缺点 | +| --- | --- | --- | --- | +| 重建 | 停掉旧版本再启动新版本 | 最简单 | 有停机 | +| 滚动 | 逐台替换实例 | 无停机、资源占用少 | 新旧版本同时在线一段时间;回滚也要逐台 | +| 蓝绿 | 准备一整套新环境,验证后切流量 | 切换和回滚都是瞬间 | 需要双倍资源 | +| 金丝雀 | 先给小部分流量,观察指标后逐步放大 | 风险最小 | 需要流量控制能力和监控 | + +**回滚**:应用回滚 = 部署上一个镜像标签。难点在**数据库**:新版本已经改了表结构,旧版本可能跑不起来。 + +**数据库迁移的原则**:迁移脚本放在仓库里、有版本号、由工具执行(Go 的 golang-migrate / goose,Python 的 Alembic);**每次迁移都要向后兼容**——新旧代码都能在新表结构上运行。删除或重命名字段要分多次发布(“扩展 → 迁移 → 收缩”): + +1. 加新字段(旧代码不受影响),新代码同时写新旧字段; +2. 回填历史数据,新代码改为读新字段; +3. 确认不再回滚到旧版本后,再删除旧字段。 + +**特性开关**:代码先上线但功能默认关闭,通过配置打开;出问题直接关开关,比回滚代码更快。 + +## 案例 + +一次发布要把 `users.name` 改名为 `display_name`,迁移脚本直接执行了 `RENAME COLUMN`。发布后发现新版本有 bug,回滚到旧版本——旧代码找不到 `name` 字段,全站报错,故障时间翻倍。按“扩展 → 迁移 → 收缩”分三次发布就能避免。 + +## 代码示例 + +单机部署 + 健康检查 + 失败自动回滚(完整脚本 [examples/pipeline/deploy.sh](examples/pipeline/deploy.sh)): + +```bash +previous="$(cat "$STATE_FILE" 2>/dev/null || true)" # 上一次成功部署的标签 +deploy "$IMAGE_TAG" +if healthy; then + echo "$IMAGE_TAG" > "$STATE_FILE" +else + echo "健康检查失败,回滚到 $previous" >&2 + [[ -n "$previous" ]] && deploy "$previous" + exit 1 +fi +``` + +“扩展 → 迁移 → 收缩”的迁移脚本(PostgreSQL): + +```sql +-- 第 1 次发布:扩展(旧代码不受影响) +ALTER TABLE users ADD COLUMN display_name TEXT; + +-- 第 2 次发布前:回填(大表要分批执行) +UPDATE users SET display_name = name WHERE display_name IS NULL; + +-- 第 3 次发布:确认不再回滚到读 name 的版本后,收缩 +ALTER TABLE users DROP COLUMN name; +``` + +## 易错点 + +- **每个环境重新构建镜像**:测试过的和上线的不是同一个东西。 +- **迁移不向后兼容**:应用无法回滚。 +- **应用启动时自动执行破坏性迁移**:多实例同时启动会并发迁移;迁移失败导致所有实例起不来。迁移作为单独的发布步骤执行。 +- **只看“部署成功”不看业务指标**:容器起来了不代表功能正常。发布后观察错误率、延迟、关键业务指标。 +- **回滚流程从来没有演练过**:真出事时脚本跑不通。 +- **特性开关从不清理**:代码里分支越来越多。功能稳定后删除开关。 + +## 面试常问 + +见 [CI/CD 面试题](interview.md):蓝绿和金丝雀的区别、怎么回滚、数据库变更怎么做到可回滚。 diff --git a/topics/devops/cicd/examples/pipeline/ci.yml b/topics/devops/cicd/examples/pipeline/ci.yml new file mode 100644 index 0000000..49bb3b2 --- /dev/null +++ b/topics/devops/cicd/examples/pipeline/ci.yml @@ -0,0 +1,116 @@ +# GitHub Actions 工作流示例(放到 .github/workflows/ci.yml)。 +# Gitea Actions 的语法与之基本兼容;GitLab CI 语法不同,思路一样。具体字段以所用平台的官方文档为准。 +# 仓库结构假设:api/(Go)、ai/(Python)、web/(前端) +name: ci + +on: + push: + branches: [main] + pull_request: + +concurrency: # 同一分支新的提交会取消旧的运行 + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: # 只构建改动过的部分 + runs-on: ubuntu-latest + outputs: + api: ${{ steps.filter.outputs.api }} + ai: ${{ steps.filter.outputs.ai }} + web: ${{ steps.filter.outputs.web }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + api: ['api/**'] + ai: ['ai/**'] + web: ['web/**'] + + api: + needs: changes + if: needs.changes.outputs.api == 'true' + runs-on: ubuntu-latest + services: + postgres: # 集成测试用的数据库 + image: postgres:17 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10 + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: api/go.mod + - run: go vet ./... + - run: go test -race -count=1 ./... + env: + TEST_DATABASE_URL: postgres://test:test@localhost:5432/test?sslmode=disable + + ai: + needs: changes + if: needs.changes.outputs.ai == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ai + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: ruff check . + - run: pytest -q # 单元测试里用假的模型客户端,不调用真实模型 API + + web: + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run test -- --run + - run: npm run build + + image: # 只在 main 分支、测试全部通过后构建并推送镜像 + needs: [api, ai, web] + # 依赖的任务被跳过时(没改动),默认会连带跳过本任务,所以要显式写 always() 再判断结果 + if: >- + always() && github.ref == 'refs/heads/main' && + !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') + 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 }} # 密钥来自平台,不写进仓库 + - name: build and push + run: | + tag=${GITHUB_SHA::12} # 用提交哈希做标签,可追溯、可回滚 + docker build -t ghcr.io/${{ github.repository }}/api:$tag api + docker push ghcr.io/${{ github.repository }}/api:$tag diff --git a/topics/devops/cicd/examples/pipeline/deploy.sh b/topics/devops/cicd/examples/pipeline/deploy.sh new file mode 100644 index 0000000..50d3b50 --- /dev/null +++ b/topics/devops/cicd/examples/pipeline/deploy.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# 不自动运行:单机 Docker Compose 部署脚本示例——拉取指定版本、健康检查、失败自动回滚 +# 用法:IMAGE_TAG=<提交哈希> ./deploy.sh +# 约定:compose.yaml 里 api 服务的镜像写成 image: registry.example.com/app/api:${IMAGE_TAG} +set -euo pipefail + +: "${IMAGE_TAG:?需要指定 IMAGE_TAG}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/healthz}" +STATE_FILE="${STATE_FILE:-.deployed_tag}" +previous="$(cat "$STATE_FILE" 2>/dev/null || true)" + +deploy() { + local tag="$1" + echo "部署版本 $tag" + IMAGE_TAG="$tag" docker compose pull api + IMAGE_TAG="$tag" docker compose up -d --no-deps api +} + +healthy() { + for _ in $(seq 1 30); do + if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null; then + return 0 + fi + sleep 2 + done + return 1 +} + +deploy "$IMAGE_TAG" +if healthy; then + echo "$IMAGE_TAG" > "$STATE_FILE" + echo "部署成功:$IMAGE_TAG" +else + echo "健康检查失败" >&2 + if [[ -n "$previous" ]]; then + echo "回滚到 $previous" >&2 + deploy "$previous" + healthy && echo "已回滚到 $previous" >&2 + fi + exit 1 +fi diff --git a/topics/devops/cicd/examples/pipeline/test_ci_config.py b/topics/devops/cicd/examples/pipeline/test_ci_config.py new file mode 100644 index 0000000..90fa9d1 --- /dev/null +++ b/topics/devops/cicd/examples/pipeline/test_ci_config.py @@ -0,0 +1,44 @@ +# requires: yaml +"""检查 ci.yml 的结构是否符合约定(YAML 能解析、关键任务存在、镜像只在 main 构建、没有明文密钥)。 + +运行:python -m pytest -q test_ci_config.py +这不能代替在真实 CI 平台上运行,只用来防止明显的配置错误。 +""" +import re +from pathlib import Path + +import yaml + +HERE = Path(__file__).resolve().parent +TEXT = (HERE / "ci.yml").read_text(encoding="utf-8") +CI = yaml.safe_load(TEXT) + + +def test_jobs_exist(): + assert {"changes", "api", "ai", "web", "image"} <= set(CI["jobs"]) + + +def test_trigger(): + # 注意:PyYAML 按 YAML 1.1 把键 on 解析成 True + triggers = CI.get("on", CI.get(True)) + assert "pull_request" in triggers and triggers["push"]["branches"] == ["main"] + + +def test_image_only_on_main_and_after_tests(): + image = CI["jobs"]["image"] + assert set(image["needs"]) == {"api", "ai", "web"} + assert "refs/heads/main" in image["if"] + + +def test_tests_run(): + api_steps = " ".join(s.get("run", "") for s in CI["jobs"]["api"]["steps"]) + assert "go test" in api_steps and "go vet" in api_steps + web_steps = " ".join(s.get("run", "") for s in CI["jobs"]["web"]["steps"]) + assert "npm ci" in web_steps and "npm run build" in web_steps + + +def test_no_plaintext_secrets(): + for line in TEXT.splitlines(): + if re.search(r"(?i)(password|token|secret)\s*:", line) and "secrets." not in line: + # 只有 CI 服务容器里的测试数据库密码允许明文 + assert "POSTGRES_PASSWORD: test" in line, line diff --git a/topics/devops/cicd/index.md b/topics/devops/cicd/index.md index 81a21bc..91123d0 100644 --- a/topics/devops/cicd/index.md +++ b/topics/devops/cicd/index.md @@ -4,12 +4,39 @@ title: CI/CD type: index domain: devops stack: [cicd] -tags: [] +tags: [CI/CD, 持续集成, 持续部署, 流水线, 发布] week: [3, 7, 14] --- # CI/CD -流水线设计、测试与构建、镜像发布、部署与回滚。 +持续集成(每次提交自动检查、测试、构建)和持续部署(自动发布到环境)。面试里常被问“你们的发布流程是怎样的”“出问题怎么回滚”。本专题以 GitHub Actions 语法为例(Gitea Actions 基本兼容,GitLab CI 思路相同),重点是流水线设计、发布策略和回滚,而不是某个平台的细节。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [流水线设计:触发、阶段、缓存、服务容器、密钥](01-pipeline.md) | 基础 | 2 小时 | +| 2 | [发布与回滚:镜像标签、环境、滚动 / 蓝绿 / 金丝雀、数据库迁移](02-release-rollback.md) | 进阶 | 3 小时 | + +## 企业场景 + +- [多语言仓库的流水线:Go + Python + 前端,按改动构建、自动部署和回滚](scenarios/multi-lang-pipeline.md) + +## 面试题 + +- [CI/CD 面试题](interview.md) + +## 示例代码 + +- [examples/pipeline](examples/pipeline/):`ci.yml`(GitHub Actions 语法的完整流水线)、`deploy.sh`(单机部署 + 健康检查 + 自动回滚,已用 shellcheck 检查)、`test_ci_config.py`(对流水线配置做结构检查)。流水线本身需要在 CI 平台上实际运行验证 + +## 在 16 周计划中 + +- [第 3 周:项目一 CI](../../../plan/week03-project1.md)、[第 7 周:前端构建](../../../plan/week07-frontend.md)、[第 14 周:部署](../../../plan/week14-backend-extend-deploy.md) + +## 官方资料 + +- GitHub Actions:https://docs.github.com/actions +- Gitea Actions:https://docs.gitea.com/usage/actions/overview +- GitLab CI/CD:https://docs.gitlab.com/ci/ diff --git a/topics/devops/cicd/interview.md b/topics/devops/cicd/interview.md new file mode 100644 index 0000000..70e9dbd --- /dev/null +++ b/topics/devops/cicd/interview.md @@ -0,0 +1,55 @@ +--- +id: devops.cicd.interview +title: CI/CD 面试题 +type: interview +domain: devops +stack: [cicd] +tags: [CI/CD, 发布, 回滚, 面试] +level: intermediate +related: [devops.cicd.pipeline, devops.cicd.release-rollback] +week: [14, 16] +--- + +# CI/CD 面试题 + +## 基础 + +### 【基础】CI 和 CD 分别是什么? + +**参考答案**:CI(持续集成)是开发者频繁合并代码,每次合并自动执行检查、测试和构建,尽早发现集成问题;CD 包括持续交付(每次变更都自动产出可发布的产物,发布由人触发)和持续部署(自动发布到生产)。 + +### 【基础】一条典型的流水线包括哪些阶段? + +**参考答案**:代码检查(格式、lint、类型检查)→ 单元测试和集成测试 → 构建(编译、打包、构建镜像)→ 安全扫描(依赖、镜像、密钥)→ 部署到测试环境 → (审批)→ 部署生产 → 部署后验证(健康检查、冒烟测试)。 + +### 【基础】流水线里的密钥怎么管理? + +**参考答案**:存放在 CI 平台的 Secrets 或专门的密钥管理系统中,以环境变量注入,不写进仓库和镜像;按环境和最小权限划分;不要在日志里打印;来自外部 fork 的合并请求默认拿不到密钥;泄露后立即轮换。 + +## 进阶 + +### 【进阶】蓝绿发布、滚动发布、金丝雀发布有什么区别? + +**参考答案**:滚动发布逐台替换实例,资源占用少,但新旧版本会同时在线,回滚也要逐台;蓝绿发布准备一整套新环境,验证后一次性切换流量,回滚即切回,但需要双倍资源;金丝雀发布先把小比例流量导到新版本,观察错误率等指标后逐步扩大,风险最小,需要流量控制和监控能力。 + +### 【进阶】怎么让数据库变更也能回滚? + +**参考答案**:迁移脚本版本化,由工具执行;每次变更保持向后兼容,使新旧代码都能在新表结构上运行;破坏性变更拆成多次发布:先扩展(加字段 / 表),再迁移数据和切换读写,最后确认不会回滚后再收缩(删旧字段)。大表变更用在线 DDL 工具,并提前在预发环境演练。 + +### 【进阶】流水线太慢,怎么优化? + +**参考答案**:缓存依赖(按锁文件哈希作为 key);任务并行;按改动路径只运行相关部分;Docker 构建利用层缓存;把慢的端到端测试放到合并后或定时执行;拆分测试并行运行;用更快的执行机器。 + +## 场景题 + +### 【场景题】新版本上线后错误率升高,你怎么处理? + +**参考答案**:先止血:确认是否与本次发布相关(时间点吻合),是的话立即回滚到上一个镜像标签,或关闭对应的特性开关;同时保留日志和错误信息。恢复后再定位原因,补充测试,复盘为什么没有在测试阶段发现。前提是平时就有一键回滚的能力,并且数据库变更向后兼容。 + +### 【场景题】你们团队的发布流程是怎样的?(如果之前是前端,可以怎么回答) + +**参考答案**:按真实情况讲流程:合并请求触发 lint / 测试 / 构建,评审通过且检查通过后合并;合并后自动构建产物并部署到测试环境;生产发布由负责人触发,发布后观察监控,有问题回滚。前端可以讲:构建产物上传 CDN、`index.html` 不缓存、带哈希的资源长缓存、通过切换入口文件回滚。再补充你在转岗项目里实现的后端流水线(本专题的示例)。不要编造没做过的经历。 + +### 【场景题】线上运行的是哪个版本的代码?怎么确认? + +**参考答案**:镜像标签使用提交哈希或语义化版本,部署记录里保存每次部署的标签;服务在健康检查或版本接口里返回构建时注入的版本号和提交哈希(Go 用 `-ldflags "-X main.version=..."`);日志和错误上报里带上版本号。这样出问题时能准确定位到代码。 diff --git a/topics/devops/cicd/scenarios/multi-lang-pipeline.md b/topics/devops/cicd/scenarios/multi-lang-pipeline.md new file mode 100644 index 0000000..0767d09 --- /dev/null +++ b/topics/devops/cicd/scenarios/multi-lang-pipeline.md @@ -0,0 +1,111 @@ +--- +id: devops.cicd.scenario.multi-lang-pipeline +title: 多语言仓库的流水线:Go + Python + 前端,按改动构建、自动部署和回滚 +type: scenario +domain: devops +stack: [cicd, docker, go, python, vue] +tags: [Monorepo, 流水线, 路径过滤, 自动部署, 回滚] +level: intermediate +prerequisites: [devops.cicd.pipeline] +est_hours: 2 +related: [devops.cicd.release-rollback, devops.cicd.interview, devops.docker.scenario.compose-dev-env] +week: [14, 15] +--- + +# 多语言仓库的流水线:Go + Python + 前端,按改动构建、自动部署和回滚 + +## 业务背景与问题 + +一个 AI 知识库产品放在一个仓库里:`api/`(Go)、`ai/`(Python FastAPI)、`web/`(Vue)。之前的发布方式是开发者在本机构建、`scp` 到服务器、手动重启。问题: + +- 有人本机环境不同,构建出来的产物线上运行出错; +- 只改了前端文案,也要把三个部分都重新构建一遍; +- 发布出错时,没人记得上一个版本是什么,回滚靠手工。 + +## 约束 + +- 小团队,使用代码托管平台自带的 CI(GitHub Actions / Gitea Actions 等)。 +- 单台或两台服务器,用 Docker Compose 部署。 +- 合并请求的检查要在 10 分钟内完成。 +- 模型 API Key 等密钥不能出现在仓库和日志里。 + +## 方案设计 + +```mermaid +flowchart LR + PR[合并请求] --> C{改了哪些目录?} + C -->|api/| A[Go: vet + test + 服务容器 PostgreSQL] + C -->|ai/| P[Python: ruff + pytest(假模型客户端)] + C -->|web/| W[前端: lint + test + build] + A & P & W --> M[合并到 main] + M --> I[构建镜像,标签 = 提交哈希,推送镜像仓库] + I --> T[自动部署测试环境] + T -->|人工审批| PROD[部署生产:健康检查失败自动回滚] +``` + +1. **按改动路径执行**:用路径过滤只运行改动部分的检查;所有检查作为合并的必要条件。 +2. **测试不依赖真实模型**:AI 服务的单元测试注入假的模型客户端;真实模型的效果评测作为单独的手动任务(见 Agent 评测专题)。 +3. **不可变镜像**:main 分支构建一次镜像,标签用提交哈希;测试和生产使用同一镜像。 +4. **部署脚本**:拉取指定标签 → 启动 → 健康检查 → 记录成功的标签;失败自动回滚到上一个成功的标签。 +5. **数据库迁移**:独立步骤,向后兼容(扩展 → 迁移 → 收缩)。 + +## 关键代码 + +完整文件见 [examples/pipeline](../examples/pipeline/)。路径过滤 + 只在 main 构建镜像: + +```yaml +jobs: + changes: + runs-on: ubuntu-latest + outputs: + api: ${{ steps.filter.outputs.api }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + api: ['api/**'] + + api: + needs: changes + if: needs.changes.outputs.api == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: cd api && go test ./... + + image: + needs: [api] + # 依赖任务被跳过时默认会连带跳过,所以用 always() 再判断结果 + if: >- + always() && github.ref == 'refs/heads/main' && + !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker build -t registry.example.com/app/api:${GITHUB_SHA::12} api +``` + +部署和回滚见 [deploy.sh](../examples/pipeline/deploy.sh)。 + +## 取舍 + +| 选择 | 优点 | 缺点 | +| --- | --- | --- | +| **单仓库 + 路径过滤(本方案)** | 一次合并请求可以同时改前后端;统一规范 | 流水线配置更复杂 | +| 每个服务一个仓库 | 流水线简单、权限独立 | 跨服务改动要多个合并请求协调 | +| 生产自动部署 | 最快 | 需要很好的测试和监控 | +| **生产人工审批(本方案)** | 可控 | 多一步人工操作 | + +## 易错点 + +- 必需检查被路径过滤跳过后,平台认为“检查未完成”导致无法合并:需要一个汇总任务(总是运行)作为必需检查。 +- 依赖任务被跳过导致镜像任务也被跳过:条件里加 `always()` 并判断结果。 +- 前端构建产物里写死了测试环境的接口地址:用相对路径 `/api`,由 Nginx 转发;或在运行时注入配置。 +- 部署脚本没有记录上一个成功版本:回滚时不知道回到哪里。 +- CI 日志里打印了环境变量:密钥泄露。 + +## 面试怎么讲 + +> “我们的仓库里有 Go、Python 和前端三部分。我设计的流水线按改动路径只跑相关检查,所有检查是合并的必要条件;AI 服务的单元测试用假的模型客户端,不依赖外部 API。合并到 main 后构建一次镜像,用提交哈希做标签,测试和生产用同一个镜像;部署脚本做健康检查,失败自动回滚到上一个成功的标签。数据库迁移单独执行并保持向后兼容。” diff --git a/topics/devops/docker/01-image-dockerfile.md b/topics/devops/docker/01-image-dockerfile.md new file mode 100644 index 0000000..75a7909 --- /dev/null +++ b/topics/devops/docker/01-image-dockerfile.md @@ -0,0 +1,93 @@ +--- +id: devops.docker.image-dockerfile +title: 镜像和 Dockerfile:分层缓存、多阶段构建、非 root、信号处理 +type: lesson +domain: devops +stack: [docker] +tags: [Dockerfile, 多阶段构建, 镜像分层, 构建缓存, distroless] +level: basic +est_hours: 3 +related: [devops.docker.compose-network-volume, devops.docker.interview] +week: [3, 6] +order: 1 +--- + +# 镜像和 Dockerfile:分层缓存、多阶段构建、非 root、信号处理 + +## 解释说明 + +- **镜像 vs 容器**:镜像是只读的模板(像“类”),容器是镜像运行起来的实例(像“对象”),容器在镜像之上加一个可写层。 +- **分层**:Dockerfile 中 `RUN`、`COPY`、`ADD` 每条指令产生一层。构建时如果某一层的输入没变,就直接用缓存;**某一层变了,它后面的层全部重新构建**。所以把变化少的放前面(安装依赖),变化多的放后面(复制源码)。 +- **多阶段构建**:第一阶段用带完整工具链的镜像编译,第二阶段只复制编译产物到很小的运行镜像。Go 编译成静态二进制后,运行镜像可以是 `distroless` 甚至 `scratch`,体积从几百 MB 降到十几 MB,攻击面也小。 +- **`.dockerignore`**:排除 `.git`、`node_modules`、本地数据,减少构建上下文、避免把密钥打进镜像。 +- **非 root 运行**:`USER` 指定普通用户,容器被攻破时影响更小。 +- **PID 1 和信号**:`ENTRYPOINT ["/api"]`(exec 形式)让程序直接成为 PID 1,能收到 `docker stop` 发出的 SIGTERM 做优雅退出;`ENTRYPOINT /api`(shell 形式)会让 `/bin/sh` 成为 PID 1,信号传不到程序,最后被强制 kill。 +- **`CMD` vs `ENTRYPOINT`**:`ENTRYPOINT` 是固定执行的程序,`CMD` 是默认参数(运行时可以覆盖)。 + +**常用命令**:`docker build -t api:1.0 .`、`docker run -d -p 8080:8080 --name api api:1.0`、`docker logs -f api`、`docker exec -it api sh`(distroless 没有 shell)、`docker images`、`docker history api:1.0`(看每层大小)。 + +## 案例 + +Go 服务最初用 `FROM golang` 单阶段构建,镜像 800MB+,每次改一行代码都要重新下载依赖。改成:先 `COPY go.mod go.sum` 再 `go mod download`(依赖层可缓存)→ 再复制源码编译 → 运行阶段用 distroless,镜像十几 MB,代码改动时构建只需重新编译。 + +Python(FastAPI)服务同理:先复制 `requirements.txt` / `pyproject.toml` 安装依赖,再复制源码;用 `python:3.12-slim` 作为基础镜像。 + +## 代码示例 + +Go 服务的多阶段 Dockerfile(完整示例见 [examples/go-api-compose](examples/go-api-compose/)): + +```dockerfile +# syntax=docker/dockerfile:1 +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download # 依赖层:go.mod 不变就用缓存 +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/api /api +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/api"] # exec 形式:能收到 SIGTERM +``` + +Python FastAPI 服务(示意,按项目实际的依赖文件调整): + +```dockerfile +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +RUN useradd -m appuser +USER appuser +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +Go 程序里配合优雅退出: + +```go +ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +defer stop() +go srv.ListenAndServe() +<-ctx.Done() // 收到 docker stop 的 SIGTERM +shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second) +defer cancel() +srv.Shutdown(shutdownCtx) // 等正在处理的请求完成 +``` + +## 易错点 + +- **`COPY . .` 放在安装依赖之前**:每次改代码都重新安装依赖,构建很慢。 +- **把密钥写进 Dockerfile 或复制进镜像**:镜像的每一层都能被查看,删掉的文件也还在之前的层里。密钥通过运行时环境变量或密钥管理注入。 +- **使用 `latest` 标签**:不知道实际跑的是哪个版本,无法回滚。用版本号或 Git 提交哈希。 +- **服务监听 `127.0.0.1`**:容器外访问不到,要监听 `0.0.0.0`。 +- **shell 形式的 ENTRYPOINT**:收不到 SIGTERM,每次停止都要等 10 秒后被强杀,正在处理的请求被中断。 +- **CGO 编译的二进制放进 scratch / distroless static**:缺少 libc 运行不起来,要 `CGO_ENABLED=0` 或换带 libc 的基础镜像。 + +## 面试常问 + +见 [Docker 面试题](interview.md):镜像和容器的区别、分层与缓存、多阶段构建、如何减小镜像体积、CMD 和 ENTRYPOINT。 diff --git a/topics/devops/docker/02-compose-network-volume.md b/topics/devops/docker/02-compose-network-volume.md new file mode 100644 index 0000000..bfca40d --- /dev/null +++ b/topics/devops/docker/02-compose-network-volume.md @@ -0,0 +1,85 @@ +--- +id: devops.docker.compose-network-volume +title: Compose、网络和数据卷:多服务编排、健康检查、环境变量 +type: lesson +domain: devops +stack: [docker] +tags: [Compose, 网络, 数据卷, 健康检查, 环境变量] +level: basic +prerequisites: [devops.docker.image-dockerfile] +est_hours: 2 +related: [devops.docker.scenario.compose-dev-env, devops.docker.interview] +week: [3, 6, 13] +order: 2 +--- + +# Compose、网络和数据卷:多服务编排、健康检查、环境变量 + +## 解释说明 + +- **Compose**:用一个 `compose.yaml` 描述多个服务(应用、数据库、缓存),`docker compose up -d` 一起启动。适合本地开发、测试环境和小规模单机部署;多机、大规模用 Kubernetes。 +- **网络**:Compose 默认为项目创建一个网络,服务之间**用服务名当主机名**访问(`db:5432`),不需要知道 IP。容器内的 `localhost` 指的是容器自己,不是宿主机。 +- **端口映射**:`ports: "8080:8080"` 是“宿主机端口:容器端口”,只有需要从宿主机访问的服务才映射。数据库不映射端口更安全(只在内部网络可见)。 +- **数据卷**:容器删除后可写层的数据就没了。数据库数据要放在**命名卷**(Docker 管理)或**绑定挂载**(宿主机目录,开发时挂源码目录方便热更新)。 +- **健康检查和启动顺序**:`depends_on` 默认只保证“先启动”,不保证“已就绪”。给数据库配 `healthcheck`,应用用 `condition: service_healthy` 等待。应用本身也要有连接重试。 +- **环境变量**:`environment` 直接写,或 `env_file: .env`;`.env` 文件不要提交到 Git。Compose 文件里 `${VAR}` 会从 shell 或项目目录的 `.env` 读取。 + +**常用命令**:`docker compose up -d --build`、`docker compose ps`、`docker compose logs -f api`、`docker compose exec db psql -U app`、`docker compose down`(加 `-v` 会删除数据卷,小心)。 + +## 案例 + +开发 RAG 服务时需要 PostgreSQL(含 pgvector)+ Redis + 应用。用 Compose:新同事 clone 仓库后 `docker compose up -d` 就能跑;数据库数据存在命名卷里,重启不丢;`db` 不映射端口,需要调试时临时 `docker compose exec db psql`。 + +## 代码示例 + +完整文件见 [examples/go-api-compose/compose.yaml](examples/go-api-compose/compose.yaml): + +```yaml +services: + api: + build: . + ports: + - "8080:8080" + environment: + DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable # 用服务名 db 访问 + REDIS_URL: redis://redis:6379/0 + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + + db: + image: postgres:17 + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d app"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + +volumes: + pgdata: +``` + +## 易错点 + +- **应用里连 `localhost:5432`**:在容器里 localhost 是应用容器自己,要用服务名 `db`。 +- **以为 `depends_on` 会等数据库就绪**:只等容器启动。要配健康检查,应用也要能重试连接。 +- **`docker compose down -v`**:会删掉命名卷,数据库数据全没。 +- **把数据库端口映射到公网服务器的 `0.0.0.0`**:数据库直接暴露在公网。不映射,或者只绑定 `127.0.0.1:5432:5432`。 +- **在 compose 文件里写真实密码并提交**:用 `.env`(加入 `.gitignore`)或部署平台的密钥管理。 +- **镜像版本升级时数据目录变化**:例如 PostgreSQL 新大版本的数据目录约定可能变化,升级前看官方镜像说明并备份。 + +## 面试常问 + +见 [Docker 面试题](interview.md):容器之间怎么通信、数据怎么持久化、depends_on 的局限。 diff --git a/topics/devops/docker/examples/go-api-compose/.dockerignore b/topics/devops/docker/examples/go-api-compose/.dockerignore new file mode 100644 index 0000000..d484b7d --- /dev/null +++ b/topics/devops/docker/examples/go-api-compose/.dockerignore @@ -0,0 +1,4 @@ +.git +*.md +*_test.go +compose.yaml diff --git a/topics/devops/docker/examples/go-api-compose/Dockerfile b/topics/devops/docker/examples/go-api-compose/Dockerfile new file mode 100644 index 0000000..a548b06 --- /dev/null +++ b/topics/devops/docker/examples/go-api-compose/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 +# ---------- 构建阶段:带完整 Go 工具链 ---------- +FROM golang:1.26-alpine AS build +WORKDIR /src +# 先只复制依赖描述文件,依赖不变时这一层可以复用缓存 +COPY go.mod ./ +RUN go mod download +COPY . . +# 静态编译,去掉调试信息减小体积 +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api . + +# ---------- 运行阶段:只放二进制 ---------- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/api /api +ENV PORT=8080 +EXPOSE 8080 +USER nonroot:nonroot +# exec 形式,进程是 PID 1,能直接收到 SIGTERM +ENTRYPOINT ["/api"] diff --git a/topics/devops/docker/examples/go-api-compose/compose.yaml b/topics/devops/docker/examples/go-api-compose/compose.yaml new file mode 100644 index 0000000..6c9881a --- /dev/null +++ b/topics/devops/docker/examples/go-api-compose/compose.yaml @@ -0,0 +1,36 @@ +# 本地开发环境:docker compose up -d --build +# 镜像版本仅作示例,按项目需要调整 +services: + api: + build: . + ports: + - "8080:8080" + environment: + DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable # 服务名 db 就是主机名 + REDIS_URL: redis://redis:6379/0 + depends_on: + db: + condition: service_healthy # 等数据库健康检查通过再启动 + redis: + condition: service_started + restart: unless-stopped + + db: + image: postgres:17 + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app # 仅限本地开发;生产用密钥管理 + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data # 命名卷:容器删除后数据仍在 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d app"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + +volumes: + pgdata: diff --git a/topics/devops/docker/examples/go-api-compose/go.mod b/topics/devops/docker/examples/go-api-compose/go.mod new file mode 100644 index 0000000..ed50d53 --- /dev/null +++ b/topics/devops/docker/examples/go-api-compose/go.mod @@ -0,0 +1,3 @@ +module example/goapi + +go 1.22 diff --git a/topics/devops/docker/examples/go-api-compose/main.go b/topics/devops/docker/examples/go-api-compose/main.go new file mode 100644 index 0000000..ecd9dda --- /dev/null +++ b/topics/devops/docker/examples/go-api-compose/main.go @@ -0,0 +1,54 @@ +// 一个最小的 Go HTTP 服务,用来演示多阶段构建、健康检查和优雅退出。 +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func newMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /api/hello", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"message":"hello from container"}`)) + }) + return mux +} + +func main() { + addr := ":" + getenv("PORT", "8080") + srv := &http.Server{Addr: addr, Handler: newMux(), ReadHeaderTimeout: 5 * time.Second} + + // 容器停止时 Docker 先发 SIGTERM,等待一段时间(默认 10 秒)后才 SIGKILL + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go func() { + log.Printf("listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + }() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { // 不再接新请求,等正在处理的请求完成 + log.Printf("shutdown: %v", err) + } + log.Println("bye") +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/topics/devops/docker/examples/go-api-compose/main_test.go b/topics/devops/docker/examples/go-api-compose/main_test.go new file mode 100644 index 0000000..59fd183 --- /dev/null +++ b/topics/devops/docker/examples/go-api-compose/main_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRoutes(t *testing.T) { + srv := httptest.NewServer(newMux()) + defer srv.Close() + for path, want := range map[string]int{"/healthz": 200, "/api/hello": 200, "/nope": 404} { + resp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Errorf("%s = %d, want %d", path, resp.StatusCode, want) + } + } +} diff --git a/topics/devops/docker/index.md b/topics/devops/docker/index.md index 1809fdc..77c2f11 100644 --- a/topics/devops/docker/index.md +++ b/topics/devops/docker/index.md @@ -4,12 +4,39 @@ title: Docker 与 Compose type: index domain: devops stack: [docker] -tags: [] +tags: [Docker, 容器, Compose, 镜像] week: [3, 6, 13, 14] --- # Docker 与 Compose -镜像、多阶段构建、网络、数据卷、Compose 编排。 +几乎所有后端和 AI 应用岗位都要求“会用 Docker 部署”。目标是:能写出体积小、可缓存、安全的 Dockerfile;能用 Compose 一条命令拉起“应用 + 数据库 + 缓存”的完整环境;能排查容器启动失败、连不上数据库等常见问题。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [镜像和 Dockerfile:分层缓存、多阶段构建、非 root、信号处理](01-image-dockerfile.md) | 基础 | 3 小时 | +| 2 | [Compose、网络和数据卷:多服务编排、健康检查、环境变量](02-compose-network-volume.md) | 基础 | 2 小时 | + +## 企业场景 + +- [一条命令拉起开发环境:Go API + Python AI 服务 + PostgreSQL + Redis](scenarios/compose-dev-env.md) + +## 面试题 + +- [Docker 面试题](interview.md) + +## 示例代码 + +- [examples/go-api-compose](examples/go-api-compose/):Go 服务(健康检查、优雅退出)+ 多阶段 Dockerfile + compose.yaml;Go 代码有测试,Dockerfile 需要本机安装 Docker 后自行构建验证 + +## 在 16 周计划中 + +- [第 3 周:项目一部署](../../../plan/week03-project1.md)、[第 6 周:RAG 服务容器化](../../../plan/week06-rag-2.md)、[第 13 周:Agent 部署](../../../plan/week13-agent-m4-eval-lowcode.md)、[第 14 周:部署](../../../plan/week14-backend-extend-deploy.md) + +## 官方资料 + +- Docker 文档:https://docs.docker.com/ +- Dockerfile 参考:https://docs.docker.com/reference/dockerfile/ +- Compose 文件参考:https://docs.docker.com/reference/compose-file/ diff --git a/topics/devops/docker/interview.md b/topics/devops/docker/interview.md new file mode 100644 index 0000000..855558d --- /dev/null +++ b/topics/devops/docker/interview.md @@ -0,0 +1,59 @@ +--- +id: devops.docker.interview +title: Docker 面试题 +type: interview +domain: devops +stack: [docker] +tags: [Docker, 容器, Compose, 面试] +level: basic +related: [devops.docker.image-dockerfile, devops.docker.compose-network-volume] +week: [3, 14, 16] +--- + +# Docker 面试题 + +## 基础 + +### 【基础】容器和虚拟机有什么区别? + +**参考答案**:虚拟机虚拟化硬件,每个虚拟机有自己完整的操作系统内核,隔离性强但启动慢、占用大;容器共享宿主机内核,靠 Linux 的 namespace 做隔离(进程、网络、文件系统等)、cgroups 限制资源,启动快、占用小,但隔离性比虚拟机弱。 + +### 【基础】镜像和容器是什么关系?镜像为什么要分层? + +**参考答案**:镜像是只读模板,容器是镜像运行起来的实例,在镜像之上加了一个可写层。分层让多个镜像可以共享相同的底层(节省磁盘和下载),构建时没有变化的层可以复用缓存。 + +### 【基础】`CMD` 和 `ENTRYPOINT` 的区别?`COPY` 和 `ADD` 呢? + +**参考答案**:`ENTRYPOINT` 指定容器启动时固定执行的程序;`CMD` 提供默认参数或默认命令,`docker run` 后面的参数会覆盖 `CMD`。两者都推荐使用 exec 形式(JSON 数组),程序作为 PID 1 才能收到信号。`COPY` 只复制本地文件;`ADD` 还能自动解压 tar 和下载 URL,行为不直观,一般用 `COPY`。 + +### 【基础】容器里的数据怎么持久化? + +**参考答案**:容器可写层的数据随容器删除而消失。持久化用数据卷(Docker 管理的命名卷,适合数据库)或绑定挂载(把宿主机目录挂进容器,适合开发时挂源码、挂配置文件)。 + +## 进阶 + +### 【进阶】怎么减小镜像体积、加快构建? + +**参考答案**:多阶段构建,运行阶段只保留产物;选小的基础镜像(slim、alpine、distroless);把变化少的步骤(安装依赖)放前面,充分利用层缓存;合并 RUN 并在同一层清理缓存(如 `pip install --no-cache-dir`);用 `.dockerignore` 排除无关文件。 + +### 【进阶】`docker stop` 时发生了什么?怎么做优雅退出? + +**参考答案**:Docker 向容器的 PID 1 发送 SIGTERM,等待超时(默认 10 秒)后发送 SIGKILL。程序要监听 SIGTERM,停止接受新请求、处理完进行中的请求、关闭连接后退出。前提是程序本身是 PID 1(exec 形式启动),否则信号被 shell 吞掉。 + +### 【进阶】Compose 中服务之间怎么通信?`depends_on` 能保证依赖服务已就绪吗? + +**参考答案**:同一个 Compose 项目的服务在同一个网络里,用服务名作为主机名访问。`depends_on` 默认只保证启动顺序,不保证就绪;要配合 `healthcheck` 和 `condition: service_healthy`,应用本身也要有连接重试。 + +## 场景题 + +### 【场景题】容器启动后马上退出,怎么排查? + +**参考答案**:`docker ps -a` 看退出码,`docker logs <容器>` 看输出。常见原因:主进程是后台进程(容器以前台进程为生命周期)、配置或环境变量缺失导致程序报错退出、二进制和基础镜像不兼容(CGO 程序放进 scratch)、权限问题(非 root 用户无权读文件)。可以用 `docker run -it --entrypoint sh 镜像` 进入容器手动执行排查。 + +### 【场景题】应用容器连不上数据库容器,怎么排查? + +**参考答案**:确认连接地址用的是服务名而不是 localhost;两个容器是否在同一网络(`docker network inspect`);数据库是否真的就绪(健康检查、日志);端口是否是容器内端口而不是宿主机映射端口;用户名密码和数据库名是否和初始化时一致(数据卷已存在时环境变量不会重新生效)。 + +### 【场景题】怎么避免把密钥打包进镜像? + +**参考答案**:不在 Dockerfile 里写密钥,不把 `.env` 等文件复制进镜像(`.dockerignore` 排除);运行时通过环境变量或密钥管理系统(Kubernetes Secret、云厂商的密钥服务)注入;构建时确实需要的密钥(如私有依赖仓库的 token)用 BuildKit 的 `--secret` 挂载,不会留在镜像层里;镜像推送前用扫描工具检查。 diff --git a/topics/devops/docker/scenarios/compose-dev-env.md b/topics/devops/docker/scenarios/compose-dev-env.md new file mode 100644 index 0000000..bf62c01 --- /dev/null +++ b/topics/devops/docker/scenarios/compose-dev-env.md @@ -0,0 +1,129 @@ +--- +id: devops.docker.scenario.compose-dev-env +title: 一条命令拉起开发环境:Go API + Python AI 服务 + PostgreSQL + Redis +type: scenario +domain: devops +stack: [docker, go, fastapi, postgresql, redis] +tags: [Compose, 开发环境, 多语言, 健康检查, 配置管理] +level: intermediate +prerequisites: [devops.docker.compose-network-volume] +est_hours: 2 +related: [devops.docker.interview, devops.nginx.index] +week: [3, 13, 14] +--- + +# 一条命令拉起开发环境:Go API + Python AI 服务 + PostgreSQL + Redis + +## 业务背景与问题 + +一个企业知识库项目:Go 写业务 API(用户、权限、文档管理),Python FastAPI 写 AI 服务(RAG 检索、调用大模型),PostgreSQL(含 pgvector)存数据和向量,Redis 做缓存。新人入职配环境要一两天:Python 版本不一致、pgvector 扩展装不上、各自本机的端口冲突;测试环境和开发环境配置也对不上。 + +## 约束 + +- 开发者使用 macOS、Windows(WSL2)、Linux 都有。 +- 模型 API Key 不能出现在仓库里。 +- 开发时改代码要能快速生效,不想每次都重新构建镜像。 +- 同一套编排文件,尽量也能用于单机测试环境。 + +## 方案设计 + +```mermaid +flowchart LR + Dev[浏览器 / 前端 dev server] -->|:8080| GW[Nginx 网关] + GW -->|/api| API[Go API] + GW -->|/ai| AI[Python AI 服务] + API --> DB[(PostgreSQL + pgvector)] + AI --> DB + API --> R[(Redis)] + AI --> R +``` + +1. **一个 `compose.yaml` 描述全部服务**,服务之间用服务名通信;只有网关映射端口到宿主机。 +2. **数据库用带 pgvector 的镜像**,初始化 SQL 放在 `./db/init/` 挂载到 `/docker-entrypoint-initdb.d/`(只在数据卷为空的首次启动时执行)。 +3. **健康检查 + 依赖条件**:数据库健康后才启动 API 和 AI 服务。 +4. **配置分层**:`compose.yaml` 写公共配置;`compose.override.yaml`(开发时自动合并)挂载源码目录、开启热重载;密钥放在不提交的 `.env`,仓库里提供 `.env.example`。 +5. **Makefile 封装常用命令**:`make up`、`make logs`、`make psql`、`make reset-db`。 + +## 关键代码 + +```yaml +# compose.yaml(节选) +services: + gateway: + image: nginx:1.27-alpine + ports: ["8080:80"] + volumes: + - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: [api, ai] + + api: + build: ./api + environment: + DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app?sslmode=disable + REDIS_URL: redis://redis:6379/0 + depends_on: + db: { condition: service_healthy } + + ai: + build: ./ai + environment: + DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app + LLM_API_KEY: ${LLM_API_KEY} # 来自不提交的 .env + depends_on: + db: { condition: service_healthy } + + db: + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro # 首次启动执行 CREATE EXTENSION vector 等 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d app"] + interval: 5s + retries: 10 + + redis: + image: redis:7-alpine + +volumes: + pgdata: +``` + +```yaml +# compose.override.yaml:docker compose up 时自动合并,只用于本地开发 +services: + ai: + volumes: + - ./ai/app:/app/app # 挂载源码 + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +``` + +单个 Go 服务的完整 Dockerfile 和 compose 示例见 [examples/go-api-compose](../examples/go-api-compose/)。镜像标签仅作示例,以各镜像的官方页面为准。 + +## 取舍 + +| 方案 | 优点 | 缺点 | +| --- | --- | --- | +| **Compose 全部容器化(本方案)** | 环境一致、上手快 | 在 macOS / Windows 上挂载源码的文件性能较差;镜像占磁盘 | +| 只把依赖(数据库、Redis)放容器,应用本机跑 | 调试方便、热更新快 | 应用的语言版本仍需各自安装 | +| 远程开发环境(云端开发机 / Dev Container) | 环境完全统一 | 需要网络和额外成本 | + +实际团队里常见做法是两者结合:依赖一律容器化;应用代码可以选择容器内运行或本机运行。 + +## 易错点 + +- 修改了 `db/init` 里的 SQL 却发现没执行:初始化脚本只在数据卷为空时执行,需要 `docker compose down -v` 重建(会清空数据)或写迁移脚本。 +- `.env` 被提交到仓库,泄露模型 API Key。加入 `.gitignore`,提交前扫描(仓库里的 `tools/check_sensitive.py`)。 +- 所有服务都映射端口,本机端口冲突;只映射网关。 +- 开发环境的 `--reload`、源码挂载被带到测试环境:override 文件只用于开发,测试环境显式指定 `-f compose.yaml`。 +- 容器内时区、字符集和生产不一致,导致时间相关的 bug 只在某个环境出现。统一使用 UTC 存储。 + +## 面试怎么讲 + +> “项目是 Go 业务 API 加 Python AI 服务,依赖 PostgreSQL(pgvector)和 Redis。我用 Compose 把它们编排起来:服务之间用服务名通信,只有网关对外映射端口;数据库加了健康检查,应用等数据库就绪才启动;密钥放在不提交的 .env,开发时用 override 文件挂载源码热重载。新人 clone 后一条命令就能跑起来。” + +追问准备:生产环境也用 Compose 吗(单机小规模可以,多机用 Kubernetes)?数据怎么备份? diff --git a/topics/devops/git/01-branch-commit.md b/topics/devops/git/01-branch-commit.md new file mode 100644 index 0000000..9bb42a3 --- /dev/null +++ b/topics/devops/git/01-branch-commit.md @@ -0,0 +1,66 @@ +--- +id: devops.git.branch-commit +title: 分支模型和提交规范:主干开发、Git Flow、Conventional Commits、代码评审 +type: lesson +domain: devops +stack: [git] +tags: [分支模型, Git Flow, 主干开发, 提交规范, Code Review] +level: basic +est_hours: 1.5 +related: [devops.git.merge-rebase-recovery, devops.cicd.pipeline, devops.git.interview] +week: [1, 8] +order: 1 +--- + +# 分支模型和提交规范:主干开发、Git Flow、Conventional Commits、代码评审 + +## 解释说明 + +**分支模型** + +| 模型 | 做法 | 适合 | +| --- | --- | --- | +| 主干开发(Trunk-Based) | 所有人基于 main 开短期功能分支(一两天内合并),未完成的功能用特性开关隐藏 | 有完善 CI、持续部署的团队 | +| GitHub Flow | main 永远可发布;功能分支 → 合并请求 → 评审 → 合并 → 部署 | 大多数 Web 项目 | +| Git Flow | `main`(发布)、`develop`(集成)、`feature/*`、`release/*`、`hotfix/*` | 有固定版本发布周期(App、客户端、私有化交付) | + +**提交规范(Conventional Commits)**:`类型(范围): 描述`,类型常用 `feat`、`fix`、`docs`、`refactor`、`test`、`chore`、`perf`。好处:历史清晰、能自动生成变更日志、方便按类型筛选。 + +**好的提交**:一个提交只做一件事;提交信息说明“为什么”而不只是“改了什么”;不要把格式化和逻辑改动混在一起;不要提交生成文件、依赖目录和密钥(`.gitignore`)。 + +**合并请求和代码评审**:合并请求小而聚焦(几百行以内);描述写清背景、改动、测试方式;CI 通过 + 至少一人评审才能合并;main 分支开启保护,禁止直接推送和强制推送。 + +## 案例 + +本学习仓库的约定:直接在 main 上提交(个人项目),提交信息用中文描述;每个阶段完成后提交一次并推送;提交前运行 `tools/build_catalog.py --check` 和 `tools/check_sensitive.py` 检查内容规范和敏感信息。团队项目则用 GitHub Flow:`feat/xxx` 分支 → 合并请求 → CI + 评审 → squash 合并。 + +## 代码示例 + +```bash +# 功能分支开发 +git switch -c feat/order-export +git add -p # 按块挑选要提交的改动,避免把调试代码带进去 +git commit -m "feat(order): 支持按月导出订单" -m "运营需要按月对账,导出改为异步任务,避免接口超时" +git push -u origin feat/order-export # 然后在平台上创建合并请求 + +# 全局忽略编辑器和系统文件 +git config --global core.excludesFile ~/.gitignore_global +printf '.DS_Store\n.idea/\n.vscode/\n' >> ~/.gitignore_global + +# 提交前钩子:.git/hooks/pre-commit(或用 pre-commit 框架统一管理) +#!/bin/sh +python3 tools/check_sensitive.py || exit 1 +``` + +## 易错点 + +- **一个提交几千行、混合多件事**:没法评审,出问题也没法单独回滚。 +- **提交信息写“update”“fix bug”**:半年后没人知道改了什么。 +- **长期不合并的功能分支**:和 main 差异越来越大,最后合并冲突无数。 +- **把 `.env`、密钥、大文件提交进仓库**:删除也会留在历史里。 +- **在公共分支上强制推送**:覆盖别人的提交。 +- **把 `node_modules`、构建产物提交**:仓库膨胀、冲突频繁。 + +## 面试常问 + +见 [Git 面试题](interview.md):你们用什么分支模型、提交规范、怎么做代码评审。 diff --git a/topics/devops/git/02-merge-rebase-recovery.md b/topics/devops/git/02-merge-rebase-recovery.md new file mode 100644 index 0000000..c432dd1 --- /dev/null +++ b/topics/devops/git/02-merge-rebase-recovery.md @@ -0,0 +1,83 @@ +--- +id: devops.git.merge-rebase-recovery +title: 合并、变基和撤销:merge / rebase / cherry-pick、revert / reset、reflog 找回 +type: lesson +domain: devops +stack: [git] +tags: [merge, rebase, cherry-pick, revert, reset, reflog, stash] +level: intermediate +prerequisites: [devops.git.branch-commit] +est_hours: 2 +related: [devops.git.scenario.hotfix-and-leaked-secret, devops.git.interview] +week: [8, 15] +order: 2 +--- + +# 合并、变基和撤销:merge / rebase / cherry-pick、revert / reset、reflog 找回 + +## 解释说明 + +**merge vs rebase** + +- `git merge feat`:把两个分支的历史合在一起,可能产生一个合并提交;历史真实反映“并行开发”,但图形会交错。`--no-ff` 强制生成合并提交,记录“这里合入了一个功能”;`--squash` 把功能分支压成一个提交。 +- `git rebase main`:把当前分支的提交“搬到” main 最新提交之后重新应用,历史是一条直线;但**会生成新的提交(哈希变了)**。 +- 原则:**只 rebase 自己的、还没被别人基于它工作的分支**;公共分支(main、已共享的分支)不要 rebase。 +- `git rebase -i`:交互式整理提交(合并、改信息、删除、调整顺序),合并请求提交前整理用。 + +**cherry-pick**:把某个提交复制到当前分支,常用于把热修复同时带到 release 和 main。 + +**撤销** + +| 场景 | 命令 | 说明 | +| --- | --- | --- | +| 工作区改乱了 | `git restore 文件` | 丢弃未暂存的修改 | +| 误 `add` 了 | `git restore --staged 文件` | 取消暂存,修改保留 | +| 最后一次提交信息写错 / 漏文件(未推送) | `git commit --amend` | 改写最后一个提交 | +| 撤销本地提交(未推送) | `git reset --soft HEAD~1`(保留改动)/ `--hard`(丢弃改动) | 改写历史 | +| 撤销**已推送**的提交 | `git revert 提交` | 生成一个反向提交,不改写历史,团队协作的安全做法 | + +**reflog**:记录 HEAD 每次移动的位置(本地,默认保留约 90 天)。`reset --hard`、删分支、错误的 rebase 之后,都可以在 reflog 里找到原来的提交并恢复。**只要提交过,就几乎丢不了。** + +**stash**:临时保存未提交的改动,`git stash push -m "说明"`、`git stash pop`。 + +## 案例 + +- 合并请求前,用 `rebase -i` 把“WIP”“修正拼写”这类提交合并成两三个有意义的提交,再 `rebase main` 解决冲突,评审者看到的历史清晰。 +- 上线后发现某个提交引入 bug,main 已经被多人拉取:用 `git revert`,不要 reset 后强推。 +- `reset --hard` 后发现删掉了还需要的提交:`git reflog` 找到哈希,`git reset --hard 哈希` 或 `git branch rescue 哈希` 恢复。 + +## 代码示例 + +下面这些操作在 [examples/git-lab/lab.py](examples/git-lab/lab.py) 里会在临时仓库中自动执行并验证: + +```bash +# 功能分支同步最新 main(自己的分支,可以 rebase) +git switch feat/login +git fetch origin +git rebase origin/main # 有冲突:解决后 git add 文件 && git rebase --continue;放弃:git rebase --abort +git push --force-with-lease # 改写过历史的个人分支推送;比 --force 安全:远端有别人的新提交时会拒绝 + +# 撤销已推送的提交 +git revert a1b2c3d + +# 误 reset 之后找回 +git reflog # 找到 “commit: feat: 重要改动” 那一行的哈希 +git reset --hard 0ecd7f4 + +# 热修复带回 main +git switch main +git cherry-pick 9f8e7d6 +``` + +## 易错点 + +- **对公共分支 rebase 后强推**:别人的历史和你的对不上,提交会“重复”或丢失。 +- **用 `--force` 而不是 `--force-with-lease`**:可能覆盖同事刚推送的提交。 +- **已推送的提交用 reset 撤销**:其他人再推送时又把它带回来。用 revert。 +- **revert 一个合并提交**:要用 `-m 1` 指定保留哪一边;之后想再合入同一分支,需要先 revert 这个 revert。 +- **以为 reflog 能找回从未提交过的改动**:没提交(也没 stash)的工作区修改被 `reset --hard` 后是真的没了。 +- **解决冲突时直接全选“我的”或“他的”**:可能丢掉对方的修改,冲突解决后要运行测试。 + +## 面试常问 + +见 [Git 面试题](interview.md):merge 和 rebase 的区别、revert 和 reset 的区别、误删提交怎么找回。 diff --git a/topics/devops/git/examples/git-lab/lab.py b/topics/devops/git/examples/git-lab/lab.py new file mode 100644 index 0000000..69572e1 --- /dev/null +++ b/topics/devops/git/examples/git-lab/lab.py @@ -0,0 +1,85 @@ +"""Git 练习场:在临时目录里自动演示并验证常用操作。 + +覆盖:功能分支 + 合并、rebase 整理、revert 撤销已推送的提交、reset 后用 reflog 找回、cherry-pick 热修复。 +运行:python lab.py(只需要本机安装 git,不会改动你的任何仓库) +""" +import subprocess +import tempfile +from pathlib import Path + + +def git(repo: Path, *args: str) -> str: + out = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, + env={"GIT_AUTHOR_NAME": "dev", "GIT_AUTHOR_EMAIL": "dev@example.com", + "GIT_COMMITTER_NAME": "dev", "GIT_COMMITTER_EMAIL": "dev@example.com", + "HOME": str(repo), "PATH": "/usr/bin:/bin:/usr/local/bin"}) + if out.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} 失败:{out.stderr}") + return out.stdout.strip() + + +def commit(repo: Path, name: str, content: str, msg: str) -> str: + (repo / name).write_text(content) + git(repo, "add", name) + git(repo, "commit", "-q", "-m", msg) + return git(repo, "rev-parse", "--short", "HEAD") + + +def log(repo: Path, ref: str = "HEAD") -> list[str]: + return git(repo, "log", "--format=%s", ref).splitlines() + + +def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + git(repo, "init", "-q", "-b", "main") + commit(repo, "app.txt", "v1\n", "feat: 初始化项目") + + # 1. 功能分支开发,期间 main 有新提交 + git(repo, "switch", "-q", "-c", "feat/login") + commit(repo, "login.txt", "login\n", "feat(login): 登录表单") + commit(repo, "login.txt", "login\nvalidate\n", "fix: 拼写") + git(repo, "switch", "-q", "main") + commit(repo, "readme.txt", "readme\n", "docs: 说明文档") + + # 2. 在功能分支上 rebase 到最新 main:历史变成一条直线,不产生合并提交 + git(repo, "switch", "-q", "feat/login") + git(repo, "rebase", "-q", "main") + assert log(repo)[:3] == ["fix: 拼写", "feat(login): 登录表单", "docs: 说明文档"] + print("rebase 后:", log(repo)) + + # 3. 合并回 main(--no-ff 保留一个合并提交,记录“这里合入了一个功能”) + git(repo, "switch", "-q", "main") + git(repo, "merge", "-q", "--no-ff", "feat/login", "-m", "merge: 登录功能") + assert log(repo)[0] == "merge: 登录功能" + + # 4. 已推送的错误提交:用 revert 生成一个反向提交,不改写历史 + bad = commit(repo, "app.txt", "v2-bug\n", "feat: 有问题的改动") + git(repo, "revert", "--no-edit", bad) + assert (repo / "app.txt").read_text() == "v1\n" + assert log(repo)[0].startswith('Revert "feat: 有问题的改动"') + print("revert 后 app.txt =", (repo / "app.txt").read_text().strip()) + + # 5. 误用 reset --hard 丢掉了提交,用 reflog 找回 + lost = commit(repo, "important.txt", "重要数据\n", "feat: 重要改动") + git(repo, "reset", "-q", "--hard", "HEAD~1") + assert not (repo / "important.txt").exists() + reflog = git(repo, "reflog", "--format=%h %gs") + found = next(line.split()[0] for line in reflog.splitlines() if "feat: 重要改动" in line) + assert found == lost + git(repo, "reset", "-q", "--hard", found) + assert (repo / "important.txt").read_text() == "重要数据\n" + print("reflog 找回提交", found) + + # 6. 热修复:在 release 分支修复后 cherry-pick 回 main + git(repo, "switch", "-q", "-c", "release/1.0", "HEAD~1") + fix = commit(repo, "hotfix.txt", "fix\n", "fix: 线上紧急修复") + git(repo, "switch", "-q", "main") + git(repo, "cherry-pick", fix) + assert log(repo)[0] == "fix: 线上紧急修复" and (repo / "hotfix.txt").exists() + print("cherry-pick 后 main:", log(repo)[:2]) + print("Git 练习全部通过") + + +if __name__ == "__main__": + main() diff --git a/topics/devops/git/index.md b/topics/devops/git/index.md index b9f812d..5237eef 100644 --- a/topics/devops/git/index.md +++ b/topics/devops/git/index.md @@ -4,12 +4,39 @@ title: Git type: index domain: devops stack: [git] -tags: [] +tags: [Git, 版本控制, 分支, 协作] week: [1, 8, 15] --- # Git -分支模型、提交规范、合并与变基、误操作恢复、协作流程。 +七年前端,Git 日常操作你已经熟悉。这个专题补的是**面试和团队协作里容易被追问的部分**:分支模型怎么选、merge 和 rebase 的区别、怎么安全地撤销、误操作怎么找回、密钥误提交怎么处理。本计划的学习仓库本身也按这些规范管理。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [分支模型和提交规范:主干开发、Git Flow、Conventional Commits、代码评审](01-branch-commit.md) | 基础 | 1.5 小时 | +| 2 | [合并、变基和撤销:merge / rebase / cherry-pick、revert / reset、reflog 找回](02-merge-rebase-recovery.md) | 进阶 | 2 小时 | + +## 企业场景 + +- [线上紧急修复和密钥误提交:热修复分支、cherry-pick、清理历史和轮换密钥](scenarios/hotfix-and-leaked-secret.md) + +## 面试题 + +- [Git 面试题](interview.md) + +## 示例代码 + +- [examples/git-lab](examples/git-lab/):`lab.py` 在临时目录里自动演示并验证 rebase、`--no-ff` 合并、revert、reflog 找回、cherry-pick + +## 在 16 周计划中 + +- [第 1 周:仓库和提交规范](../../../plan/week01-go-gin.md)、[第 8 周:整理项目仓库](../../../plan/week08-inventory-architecture.md)、[第 15 周:作品集打包](../../../plan/week15-package-resume.md) + +## 官方资料 + +- Pro Git(中文版):https://git-scm.com/book/zh/v2 +- Git 命令参考:https://git-scm.com/docs +- Conventional Commits:https://www.conventionalcommits.org/zh-hans/ diff --git a/topics/devops/git/interview.md b/topics/devops/git/interview.md new file mode 100644 index 0000000..56c1a16 --- /dev/null +++ b/topics/devops/git/interview.md @@ -0,0 +1,59 @@ +--- +id: devops.git.interview +title: Git 面试题 +type: interview +domain: devops +stack: [git] +tags: [Git, 分支, 撤销, 面试] +level: basic +related: [devops.git.branch-commit, devops.git.merge-rebase-recovery] +week: [15, 16] +--- + +# Git 面试题 + +## 基础 + +### 【基础】`git merge` 和 `git rebase` 有什么区别? + +**参考答案**:merge 把两条分支的历史合并,可能产生合并提交,保留真实的并行开发历史;rebase 把当前分支的提交在目标分支最新提交之后重新应用,历史变成一条直线,但会生成新的提交哈希。公共分支不要 rebase,个人功能分支可以用 rebase 同步主干、整理提交。 + +### 【基础】`git revert` 和 `git reset` 有什么区别? + +**参考答案**:revert 生成一个新提交来抵消指定提交的改动,不改写历史,适合撤销已推送的提交;reset 移动分支指针到指定提交,`--soft` 保留改动在暂存区、`--mixed` 保留在工作区、`--hard` 丢弃改动,会改写历史,只适合未推送的本地提交。 + +### 【基础】`git fetch` 和 `git pull` 的区别? + +**参考答案**:fetch 只下载远程的最新提交,更新远程跟踪分支(如 `origin/main`),不改动本地分支;pull = fetch + merge(或配置为 rebase),会把远程改动合并到当前分支。习惯先 fetch 看清楚差异再决定合并方式。 + +### 【基础】你们用什么分支模型? + +**参考答案**:按真实情况回答,并说明原因。例如 GitHub Flow:main 始终可发布,功能分支通过合并请求、CI 和评审后合并,合并后自动部署测试环境。有固定版本周期的(App、私有化交付)用 Git Flow 的 release / hotfix 分支。持续部署成熟的团队用主干开发 + 特性开关。 + +## 进阶 + +### 【进阶】误执行了 `git reset --hard`,提交还能找回吗? + +**参考答案**:只要曾经提交过就能找回。`git reflog` 记录了 HEAD 的每次移动,找到 reset 之前的提交哈希,`git reset --hard 哈希` 或 `git branch 新分支 哈希` 恢复。删除的分支同理。但从未提交、也没有 stash 的工作区改动无法通过 reflog 找回。 + +### 【进阶】`--force` 和 `--force-with-lease` 有什么区别? + +**参考答案**:`--force` 无条件用本地覆盖远程分支;`--force-with-lease` 只有当远程分支仍然是你上次获取时的状态才覆盖,如果别人在这期间推送了新提交就会拒绝,避免误覆盖同事的工作。改写过历史的个人分支推送时应该用后者。 + +### 【进阶】什么时候用 cherry-pick?有什么注意事项? + +**参考答案**:需要把某个提交单独应用到另一个分支时,例如热修复同时带到 release 和 main、把某个修复回移到旧版本。注意 cherry-pick 会生成新的提交(不同哈希),如果之后两个分支合并,可能出现重复改动或冲突;大量 cherry-pick 说明分支策略可能有问题。 + +## 场景题 + +### 【场景题】有人把密钥提交到了远程仓库,怎么处理? + +**参考答案**:第一步立即作废并轮换密钥、检查是否被滥用;然后删除文件并加入 `.gitignore`;视情况用 `git filter-repo` 清理历史并强制推送,通知所有人重新克隆,联系平台清理缓存;最后加上提交前和 CI 中的密钥扫描。清理历史不能代替轮换。详见[热修复和密钥误提交场景](scenarios/hotfix-and-leaked-secret.md)。 + +### 【场景题】线上版本有紧急 bug,但 main 上已经有很多未发布的功能,怎么修? + +**参考答案**:从线上版本的标签或 release 分支拉热修复分支,修复并补测试,走 CI 后合入 release 分支发布补丁版本;再把修复 cherry-pick 或合并回 main,保证下个版本包含修复。不要从 main 拉分支修复,否则会把未测试的功能带上线。 + +### 【场景题】合并时遇到大量冲突,怎么处理? + +**参考答案**:先理解双方各自改了什么(`git log`、`git diff` 看两边的提交),逐个文件解决,不要一键全选某一方;解决后运行测试;冲突太多时可以和对方一起解决。根本上要避免长期不合并的分支:功能分支保持短小、经常同步主干。 diff --git a/topics/devops/git/scenarios/hotfix-and-leaked-secret.md b/topics/devops/git/scenarios/hotfix-and-leaked-secret.md new file mode 100644 index 0000000..ee70b76 --- /dev/null +++ b/topics/devops/git/scenarios/hotfix-and-leaked-secret.md @@ -0,0 +1,89 @@ +--- +id: devops.git.scenario.hotfix-and-leaked-secret +title: 线上紧急修复和密钥误提交:热修复分支、cherry-pick、清理历史和轮换密钥 +type: scenario +domain: devops +stack: [git, cicd] +tags: [热修复, cherry-pick, 密钥泄露, 历史清理, 安全] +level: intermediate +prerequisites: [devops.git.merge-rebase-recovery] +est_hours: 1.5 +related: [devops.git.interview, devops.cicd.pipeline] +week: [8, 15] +--- + +# 线上紧急修复和密钥误提交:热修复分支、cherry-pick、清理历史和轮换密钥 + +## 业务背景与问题 + +团队按“release 分支发版”的方式工作:`main` 上已经合入了下个版本的多个功能(未测试完),线上运行的是 `release/1.4`。周五下午出现两件事: + +1. 线上 1.4 版本有一个金额计算的 bug,需要马上修复,但不能把 main 上未测试的功能带上线。 +2. 有同事把含有大模型 API Key 的 `.env` 文件提交并推送到了远程仓库。 + +## 约束 + +- 热修复要在一小时内上线,而且下个版本也必须包含这个修复。 +- 仓库在公司内部平台上,有十几个人已经拉取过包含密钥的提交。 + +## 方案设计 + +**热修复** + +1. 从线上版本的标签 / release 分支创建 `hotfix/amount-rounding`; +2. 修复 + 补测试,走合并请求和 CI,合入 `release/1.4`,打标签 `v1.4.1` 发布; +3. 把修复提交 `cherry-pick` 到 main(或从 release 合并回 main),保证下个版本不会“复活”这个 bug。 + +**密钥泄露**——顺序很重要: + +1. **第一时间轮换密钥**:在模型服务商控制台作废旧 Key、生成新 Key,并更新部署环境的密钥配置。只要密钥进过远程仓库,就必须当作已经泄露,**清理历史不能代替轮换**。 +2. 检查旧 Key 的调用记录,确认是否有异常使用。 +3. 删除文件并加入 `.gitignore`;提供 `.env.example`。 +4. 评估是否需要清理历史:用 `git filter-repo` 从所有历史中移除文件,强制推送,通知所有人重新克隆;托管平台上的缓存、合并请求、fork 中可能仍有副本,需要联系平台管理员。 +5. 预防:提交前钩子扫描密钥(本仓库的 `tools/check_sensitive.py`),CI 中也执行密钥扫描。 + +## 关键代码 + +```bash +# 热修复 +git switch -c hotfix/amount-rounding v1.4.0 +# ……修改代码、补测试…… +git commit -am "fix(order): 金额四舍五入改为银行家舍入前先转换为分" +# 合并请求合入 release/1.4 后打标签 +git tag -a v1.4.1 -m "热修复:金额计算" +git push origin v1.4.1 +# 带回 main +git switch main && git cherry-pick <修复提交哈希> + +# 密钥:先轮换!然后再处理仓库 +git rm --cached .env +echo ".env" >> .gitignore +git commit -m "chore: 移除误提交的 .env,并忽略该文件" +# 需要清理历史时(会改写所有提交哈希,需要团队协调): +git filter-repo --path .env --invert-paths +git push --force --all && git push --force --tags +``` + +`git filter-repo` 需要单独安装,用法以官方说明为准:https://github.com/newren/git-filter-repo 。在临时仓库里练习 cherry-pick、revert、reflog 的脚本见 [examples/git-lab](../examples/git-lab/)。 + +## 取舍 + +| 做法 | 优点 | 缺点 | +| --- | --- | --- | +| **只轮换密钥 + 删除文件** | 快、不影响团队 | 旧密钥仍留在历史里(已作废,风险可控) | +| 轮换 + 清理历史 | 历史中不再有密钥 | 所有提交哈希改变,所有人要重新克隆;平台副本仍需单独处理 | +| 只清理历史不轮换 | —— | **错误做法**:密钥可能已被复制 | + +热修复用 cherry-pick 还是“release 合并回 main”:cherry-pick 精确,只带修复;合并回 main 能保证 release 上所有改动都回到 main,但可能带来冲突。团队约定一种即可。 + +## 易错点 + +- 热修复只改了 release 分支,忘了带回 main:下个版本 bug 又出现。 +- 从 main 拉热修复分支:把未测试的功能带上线。 +- 以为“删除文件再提交一次”密钥就安全了:历史里仍然有。 +- 先花半天清理历史再轮换密钥:轮换应该是第一步。 +- 强制推送清理后的历史却没通知团队:有人基于旧历史推送,又把文件带回来。 + +## 面试怎么讲 + +> “线上版本出 bug 时,我从线上的标签拉热修复分支,修复并补测试后合入 release 分支打补丁版本发布,再 cherry-pick 回 main,避免把 main 上未测试的功能带上线。还处理过密钥误提交:第一步是作废并轮换密钥,检查调用记录;然后删除文件、加入 .gitignore,加上提交前的密钥扫描。是否清理历史要评估成本,清理时用 filter-repo 并通知团队重新克隆。” diff --git a/topics/devops/linux/01-commands.md b/topics/devops/linux/01-commands.md new file mode 100644 index 0000000..606fcbb --- /dev/null +++ b/topics/devops/linux/01-commands.md @@ -0,0 +1,103 @@ +--- +id: devops.linux.commands +title: 常用命令:文件、文本处理、进程、端口、权限、systemd +type: lesson +domain: devops +stack: [linux] +tags: [grep, awk, ps, ss, chmod, systemd, journalctl] +level: basic +est_hours: 3 +related: [devops.linux.troubleshooting, devops.linux.interview] +week: [14] +order: 1 +--- + +# 常用命令:文件、文本处理、进程、端口、权限、systemd + +## 解释说明 + +| 类别 | 命令 | 常用写法 | +| --- | --- | --- | +| 文件 | `ls` `cd` `cp` `mv` `rm` `find` | `find /var/log -name "*.log" -mtime +7`(7 天前的日志) | +| 查看 | `cat` `less` `head` `tail` | `tail -f app.log`(实时跟踪)、`tail -n 200 app.log` | +| 文本 | `grep` `awk` `sort` `uniq` `wc` `sed` | `grep -n "ERROR" app.log`、`awk '{print $1}'`(取第一列) | +| 进程 | `ps` `top` / `htop` `kill` `pgrep` | `ps -ef | grep api`、`kill -15 PID`(先礼后兵,最后才 `-9`) | +| 端口 / 网络 | `ss` `curl` `ping` `dig` | `ss -lntp`(谁在监听哪个端口)、`curl -v http://127.0.0.1:8080/healthz` | +| 磁盘 | `df` `du` | `df -h`、`du -sh /var/log/* | sort -h` | +| 权限 | `chmod` `chown` `sudo` | `chmod 600 .env`、`chown app:app /srv/app -R` | +| 服务 | `systemctl` `journalctl` | `systemctl status api`、`journalctl -u api -f` | +| 传输 | `scp` `rsync` `ssh` | `rsync -avz dist/ user@host:/srv/web/` | + +**权限**:`rwx` 分别对应 4、2、1;`chmod 755` = 所有者 rwx、组和其他人 r-x。密钥文件用 `600`。服务不要用 root 运行,单独建一个用户。 + +**管道和重定向**:`|` 把前一个命令的输出交给后一个;`>` 覆盖写文件、`>>` 追加;`2>&1` 把错误输出也合并进来。 + +**systemd**:把程序注册成系统服务——开机自启、崩溃自动重启、日志统一交给 journald。 + +## 案例 + +- 统计访问日志里请求最多的 10 个 IP:`awk '{print $1}' access.log | sort | uniq -c | sort -rn | head`。 +- 统计 5xx 的数量:`awk '$9 >= 500' access.log | wc -l`(combined 格式第 9 列是状态码)。 +- 找出占用 8080 端口的进程:`ss -lntp | grep 8080`。 +- Go API 部署成 systemd 服务,崩溃 3 秒后自动重启,日志用 `journalctl -u api` 查看。 + +## 代码示例 + +文本处理组合(可以对 [examples/log-analysis/sample_access.log](examples/log-analysis/sample_access.log) 直接运行): + +```bash +# 状态码分布 +awk '{print $9}' sample_access.log | sort | uniq -c | sort -rn +# 请求最多的 IP +awk '{print $1}' sample_access.log | sort | uniq -c | sort -rn | head -n 3 +# 耗时超过 1 秒的请求(最后一列是 $request_time) +awk '$NF > 1 {print $NF, $7}' sample_access.log | sort -rn +# 某个时间段的错误 +grep '23/Oct/2026:10:00' sample_access.log | grep -E '" 5[0-9]{2} ' +``` + +同样的分析用 Python 写成脚本:[examples/log-analysis/analyze.py](examples/log-analysis/analyze.py)。 + +Go 服务的 systemd 单元文件 `/etc/systemd/system/api.service`: + +```ini +[Unit] +Description=Order API +After=network-online.target +Wants=network-online.target + +[Service] +User=app +Group=app +WorkingDirectory=/srv/api +EnvironmentFile=/srv/api/.env +ExecStart=/srv/api/bin/api +Restart=on-failure +RestartSec=3 +# 停止时先发 SIGTERM,等 15 秒再强杀 +TimeoutStopSec=15 +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload # 修改单元文件后必须执行 +sudo systemctl enable --now api # 开机自启并立即启动 +systemctl status api +journalctl -u api -f --since "10 min ago" +``` + +## 易错点 + +- **`kill -9` 当默认**:进程来不及清理(关闭连接、写完文件)。先 `kill`(SIGTERM),等不及再 `-9`。 +- **`rm -rf $DIR/` 变量为空**:变成 `rm -rf /`。脚本里加 `set -u`,或写 `"${DIR:?}"/`。 +- **删除正在被写的大日志文件**:空间不释放(进程还持有文件句柄)。用 `: > app.log` 清空,或者重启进程;配置 logrotate。 +- **`chmod 777` 解决权限问题**:安全隐患。找出正确的用户和组。 +- **修改 systemd 单元文件后忘了 `daemon-reload`**:改动不生效。 +- **在服务器上直接 `vim` 改线上配置不留记录**:配置纳入 Git,通过部署流程发布。 + +## 面试常问 + +见 [Linux 面试题](interview.md):查端口占用、查日志、统计、软链接和硬链接、进程信号。 diff --git a/topics/devops/linux/02-troubleshooting.md b/topics/devops/linux/02-troubleshooting.md new file mode 100644 index 0000000..e49ae1e --- /dev/null +++ b/topics/devops/linux/02-troubleshooting.md @@ -0,0 +1,99 @@ +--- +id: devops.linux.troubleshooting +title: 线上排查:CPU、内存、磁盘、网络、日志的定位思路 +type: lesson +domain: devops +stack: [linux] +tags: [排查, top, 负载, OOM, 磁盘, TIME_WAIT, pprof] +level: intermediate +prerequisites: [devops.linux.commands] +est_hours: 3 +related: [devops.linux.scenario.incident-troubleshooting, devops.linux.interview] +week: [14] +order: 2 +--- + +# 线上排查:CPU、内存、磁盘、网络、日志的定位思路 + +## 解释说明 + +**排查的总顺序**:先止血(回滚、扩容、限流、重启),再定位,最后复盘。定位时**从现象到资源再到代码**: + +| 资源 | 看什么 | 命令 | +| --- | --- | --- | +| 负载 | load average 和 CPU 核数的比较;高负载可能是 CPU 忙,也可能是 IO 等待 | `uptime`、`top`(看 `us` / `sy` / `wa`) | +| CPU | 哪个进程、哪个线程占用高 | `top -H -p PID`;Go 用 pprof、Python 用 py-spy | +| 内存 | 剩余可用内存(看 `available`)、是否用到 swap、是否有 OOM | `free -h`、`journalctl -k | grep -i oom` | +| 磁盘 | 空间和 inode 是否用满、IO 是否繁忙 | `df -h`、`df -i`、`du -sh`、`iostat -x 1` | +| 网络 | 端口监听、连接状态分布、连接数是否打满 | `ss -lntp`、`ss -ant` 统计状态、`curl -w` 看耗时 | +| 日志 | 错误日志、慢请求、时间点 | `journalctl -u 服务`、`grep`、`awk` | + +**几个关键概念** + +- **load average**:正在运行和等待运行(包括等待 IO)的进程数的平均值。4 核机器 load 为 4 左右算满载。 +- **`wa`(iowait)高**:CPU 在等磁盘,问题在 IO(慢查询扫盘、日志写太多),而不是计算。 +- **OOM Killer**:内存耗尽时内核按评分杀掉进程,应用日志里往往什么都没有,只能在内核日志里看到 `Killed process`。 +- **TIME_WAIT 很多**:主动关闭连接的一方会进入 TIME_WAIT。大量短连接(每次请求都新建连接)导致,解决办法是复用连接(HTTP keep-alive、连接池)。 +- **CLOSE_WAIT 很多**:对方已关闭,本方没有关闭——通常是代码里忘了关闭连接(如 Go 的 `resp.Body.Close()`)。 + +**语言级工具**:Go 服务开启 `net/http/pprof`(只在内网端口暴露),`go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30` 采集 CPU 热点,`/debug/pprof/heap` 看内存,`/debug/pprof/goroutine` 看 goroutine 泄漏。Python 用 `py-spy top --pid PID`。 + +## 案例 + +- 接口变慢:`top` 看到 `wa` 30%,`iostat` 显示磁盘利用率 100%;进一步发现是同一台机器上的数据库在做全表扫描的报表查询。把报表改到从库。 +- 服务每天半夜重启:应用日志没有错误,`journalctl -k` 里有 `Out of memory: Killed process ... (api)`。用 pprof 看 heap,发现一个按天累积的本地缓存没有上限。 +- `ss -ant` 统计出几千个 CLOSE_WAIT:代码里调用第三方接口后没有关闭响应体。 + +## 代码示例 + +一分钟快照脚本 [examples/log-analysis/diag.sh](examples/log-analysis/diag.sh)(只读命令)的核心部分: + +```bash +uptime +ps -eo pid,user,%cpu,%mem,etime,cmd --sort=-%cpu | head -n 10 +free -h +df -h -x tmpfs -x devtmpfs +ss -ant | awk 'NR > 1 {count[$1]++} END {for (s in count) print s, count[s]}' +``` + +Go 服务暴露 pprof(只监听本机端口,不要暴露到公网): + +```go +package main + +import ( + "log" + "net/http" + _ "net/http/pprof" // 注册 /debug/pprof/* 到 http.DefaultServeMux +) + +func main() { + go func() { + // 单独的端口,只监听 127.0.0.1;业务服务用自己的 ServeMux,不暴露 pprof + log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) + }() + biz := http.NewServeMux() + biz.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) + log.Fatal(http.ListenAndServe(":8080", biz)) +} +``` + +```bash +# 在服务器上采集 30 秒 CPU 数据,然后看最耗 CPU 的函数 +go tool pprof -top http://127.0.0.1:6060/debug/pprof/profile?seconds=30 +# goroutine 数量是否持续增长(泄漏) +curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=1 | head -n 1 +``` + +## 易错点 + +- **一上来就重启**:现场没了,问题下次还会出现。重启前至少保存快照(diag.sh 的输出、pprof、日志片段)。 +- **只看 CPU 不看 iowait**:负载高不一定是计算问题。 +- **`free` 看 `free` 列认为内存不够**:Linux 会把空闲内存用作缓存,要看 `available`。 +- **在业务端口暴露 pprof**:泄露内部信息,还能被用来消耗资源。 +- **删了大文件磁盘空间没释放**:`lsof +L1` 找到仍持有已删除文件的进程。 +- **排查结论没有数据支撑**:每一步都记录命令和输出,便于复盘。 + +## 面试常问 + +见 [Linux 面试题](interview.md):CPU 飙高怎么排查、内存泄漏怎么查、磁盘满了怎么办、TIME_WAIT 和 CLOSE_WAIT。 diff --git a/topics/devops/linux/examples/log-analysis/analyze.py b/topics/devops/linux/examples/log-analysis/analyze.py new file mode 100644 index 0000000..9f3ca93 --- /dev/null +++ b/topics/devops/linux/examples/log-analysis/analyze.py @@ -0,0 +1,65 @@ +"""分析 Nginx 访问日志:状态码分布、请求最多的 IP、最慢的接口、5xx 明细。 + +日志格式:Nginx combined 格式末尾追加 $request_time(秒),对应配置: + log_format main '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" $request_time'; +运行:python analyze.py [日志文件],默认分析同目录的 sample_access.log +等价的命令行写法见 Linux 专题课程(awk / sort / uniq)。 +""" +import re +import sys +from collections import Counter, defaultdict +from pathlib import Path + +LINE = re.compile( + r'(?P\S+) \S+ \S+ \[(?P