阶段2(下):运维专题 Docker/Nginx/Linux/CI-CD/Git 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:36:06 +08:00
1 parent 5eb67c5d08
commit b8010a74f3
42 files changed
+3533 -55

No files matched your search

+2
View File
@@ -1,2 +1,4 @@
# zhuangang 结构化内容改造进度(每完成一个阶段追加一行;重启时跳过已完成阶段) # zhuangang 结构化内容改造进度(每完成一个阶段追加一行;重启时跳过已完成阶段)
phase1 done 2026-10-23 15:05:36:目录设计、CONTRIBUTING、schema、tools/build_catalog.py、run_examples.py、check_sensitive.py、31 个技术栈首页骨架、周文件 frontmatter 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
+898 -40
View File
File diff suppressed because it is too large. Load diff
+90
View File
@@ -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 流程、怎么加速流水线、密钥怎么管理。
+87
View File
@@ -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):蓝绿和金丝雀的区别、怎么回滚、数据库变更怎么做到可回滚。
+116
View File
@@ -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
@@ -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
@@ -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
+30 -3
View File
@@ -4,12 +4,39 @@ title: CI/CD
type: index type: index
domain: devops domain: devops
stack: [cicd] stack: [cicd]
tags: [] tags: [CI/CD, 持续集成, 持续部署, 流水线, 发布]
week: [3, 7, 14] week: [3, 7, 14]
--- ---
# CI/CD # 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/
+55
View File
@@ -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=..."`);日志和错误上报里带上版本号。这样出问题时能准确定位到代码。
@@ -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 后构建一次镜像,用提交哈希做标签,测试和生产用同一个镜像;部署脚本做健康检查,失败自动回滚到上一个成功的标签。数据库迁移单独执行并保持向后兼容。”
@@ -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。
@@ -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 的局限。
@@ -0,0 +1,4 @@
.git
*.md
*_test.go
compose.yaml
@@ -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"]
@@ -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:
@@ -0,0 +1,3 @@
module example/goapi
go 1.22
@@ -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
}
@@ -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)
}
}
}
+30 -3
View File
@@ -4,12 +4,39 @@ title: Docker 与 Compose
type: index type: index
domain: devops domain: devops
stack: [docker] stack: [docker]
tags: [] tags: [Docker, 容器, Compose, 镜像]
week: [3, 6, 13, 14] week: [3, 6, 13, 14]
--- ---
# Docker 与 Compose # 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/
+59
View File
@@ -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` 挂载,不会留在镜像层里;镜像推送前用扫描工具检查。
@@ -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)?数据怎么备份?
+66
View File
@@ -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):你们用什么分支模型、提交规范、怎么做代码评审。
@@ -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 的区别、误删提交怎么找回。
+85
View File
@@ -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()
+30 -3
View File
@@ -4,12 +4,39 @@ title: Git
type: index type: index
domain: devops domain: devops
stack: [git] stack: [git]
tags: [] tags: [Git, 版本控制, 分支, 协作]
week: [1, 8, 15] week: [1, 8, 15]
--- ---
# Git # 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/
+59
View File
@@ -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` 看两边的提交),逐个文件解决,不要一键全选某一方;解决后运行测试;冲突太多时可以和对方一起解决。根本上要避免长期不合并的分支:功能分支保持短小、经常同步主干。
@@ -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 并通知团队重新克隆。”
+103
View File
@@ -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):查端口占用、查日志、统计、软链接和硬链接、进程信号。
+99
View File
@@ -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。
@@ -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<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+) [^"]*" '
r'(?P<status>\d{3}) (?P<bytes>\d+) "[^"]*" "[^"]*" (?P<rt>[\d.]+)'
)
def parse(path: Path):
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
m = LINE.match(line)
if not m:
print(f"第 {n} 行格式不匹配,跳过", file=sys.stderr)
continue
d = m.groupdict()
d["path"] = d["path"].split("?")[0] # 去掉查询参数,按接口聚合
d["rt"] = float(d["rt"])
yield d
def analyze(rows: list[dict]) -> dict:
by_api: dict[str, list[float]] = defaultdict(list)
for r in rows:
by_api[f'{r["method"]} {r["path"]}'].append(r["rt"])
slowest = sorted(((max(v), k) for k, v in by_api.items()), reverse=True)[:3]
return {
"status": Counter(r["status"] for r in rows),
"top_ips": Counter(r["ip"] for r in rows).most_common(3),
"slowest": [(k, t) for t, k in slowest],
"errors_5xx": [(r["time"], r["status"], r["path"], r["rt"]) for r in rows if r["status"].startswith("5")],
}
def main() -> None:
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("sample_access.log")
rows = list(parse(path))
res = analyze(rows)
print(f"共 {len(rows)} 条请求")
print("状态码分布:", dict(sorted(res["status"].items())))
print("请求最多的 IP:", res["top_ips"])
print("最慢的接口(最大耗时):", res["slowest"])
print("5xx 明细:")
for e in res["errors_5xx"]:
print(" ", *e)
if path.name == "sample_access.log": # 用示例日志自检
assert res["status"]["401"] == 3 and res["status"]["429"] == 1
assert res["top_ips"][0] == ("198.51.100.7", 4)
assert res["slowest"][0] == ("GET /api/reports/monthly", 30.001)
assert [e[1] for e in res["errors_5xx"]] == ["504", "502"]
if __name__ == "__main__":
main()
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# 不自动运行:线上问题的“第一分钟”快照,只读命令,不修改任何东西
# 用法:bash diag.sh [进程名关键字] > diag-$(date +%F-%H%M).txt
set -uo pipefail
keyword="${1:-}"
section() { printf '\n===== %s =====\n' "$1"; }
section "时间和负载"
date
uptime
section "CPU 占用最高的进程"
ps -eo pid,ppid,user,%cpu,%mem,etime,cmd --sort=-%cpu | head -n 10
section "内存"
free -h
section "磁盘空间和 inode"
df -h -x tmpfs -x devtmpfs
df -i -x tmpfs -x devtmpfs | awk 'NR == 1 || $5+0 > 80'
section "监听端口"
ss -lntp 2>/dev/null | head -n 30
section "TCP 连接状态统计"
ss -ant | awk 'NR > 1 {count[$1]++} END {for (s in count) print s, count[s]}'
if [[ -n "$keyword" ]]; then
section "进程:$keyword"
pgrep -af -- "$keyword" || echo "没有找到进程"
fi
section "最近的内核日志(OOM 等)"
if kernel_log="$(journalctl -k --since "1 hour ago" --no-pager 2>/dev/null)" || kernel_log="$(dmesg 2>/dev/null)"; then
grep -iE 'oom|killed process' <<<"$kernel_log" | tail -n 20 || echo "最近没有 OOM 记录"
else
echo "无权限读取内核日志"
fi
@@ -0,0 +1,10 @@
203.0.113.5 - - [23/Oct/2026:10:00:01 +0800] "GET /api/orders?page=1 HTTP/1.1" 200 512 "-" "Mozilla/5.0" 0.023
203.0.113.5 - - [23/Oct/2026:10:00:02 +0800] "GET /api/orders?page=2 HTTP/1.1" 200 498 "-" "Mozilla/5.0" 0.031
198.51.100.7 - - [23/Oct/2026:10:00:02 +0800] "POST /api/login HTTP/1.1" 401 64 "-" "curl/8.5.0" 0.004
198.51.100.7 - - [23/Oct/2026:10:00:02 +0800] "POST /api/login HTTP/1.1" 401 64 "-" "curl/8.5.0" 0.004
198.51.100.7 - - [23/Oct/2026:10:00:03 +0800] "POST /api/login HTTP/1.1" 401 64 "-" "curl/8.5.0" 0.005
198.51.100.7 - - [23/Oct/2026:10:00:03 +0800] "POST /api/login HTTP/1.1" 429 31 "-" "curl/8.5.0" 0.001
192.0.2.10 - - [23/Oct/2026:10:00:04 +0800] "GET /api/reports/monthly HTTP/1.1" 504 167 "-" "Mozilla/5.0" 30.001
192.0.2.10 - - [23/Oct/2026:10:00:40 +0800] "GET /api/reports/monthly HTTP/1.1" 200 20480 "-" "Mozilla/5.0" 12.874
203.0.113.9 - - [23/Oct/2026:10:00:41 +0800] "GET /api/orders/42 HTTP/1.1" 502 157 "-" "Mozilla/5.0" 0.002
203.0.113.9 - - [23/Oct/2026:10:00:42 +0800] "GET /api/orders/42 HTTP/1.1" 200 233 "-" "Mozilla/5.0" 0.018
+29 -3
View File
@@ -4,12 +4,38 @@ title: Linux 运维基础
type: index type: index
domain: devops domain: devops
stack: [linux] stack: [linux]
tags: [] tags: [Linux, 命令行, 运维, 线上排查]
week: [14] week: [14]
--- ---
# Linux 运维基础 # Linux 运维基础
常用命令、进程与端口、日志、磁盘、权限、线上排查。 后端岗位默认你能独立登录服务器:看日志、查进程和端口、处理磁盘满、配置 systemd 服务、排查“接口变慢 / 服务挂了”。这个专题只讲开发者最常用的部分,不追求系统管理员的深度。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 ## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [常用命令:文件、文本处理、进程、端口、权限、systemd](01-commands.md) | 基础 | 3 小时 |
| 2 | [线上排查:CPU、内存、磁盘、网络、日志的定位思路](02-troubleshooting.md) | 进阶 | 3 小时 |
## 企业场景
- [线上故障排查:接口变慢、磁盘写满、进程被 OOM 杀掉](scenarios/incident-troubleshooting.md)
## 面试题
- [Linux 面试题](interview.md)
## 示例代码
- [examples/log-analysis](examples/log-analysis/):`analyze.py` 分析 Nginx 访问日志(状态码、高频 IP、慢接口、5xx);`diag.sh` 一键采集系统快照(只读命令)
## 在 16 周计划中
- [第 14 周:后端扩展与部署](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- systemd 服务单元:https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
- 各命令的手册:`man 命令名`,或 https://man7.org/linux/man-pages/
+59
View File
@@ -0,0 +1,59 @@
---
id: devops.linux.interview
title: Linux 面试题
type: interview
domain: devops
stack: [linux]
tags: [Linux, 命令, 排查, 面试]
level: basic
related: [devops.linux.commands, devops.linux.troubleshooting]
week: [14, 16]
---
# Linux 面试题
## 基础
### 【基础】怎么查看某个端口被哪个进程占用?
**参考答案**:`ss -lntp | grep 8080`(或旧命令 `netstat -lntp`、`lsof -i :8080`),输出里有进程名和 PID。没有 root 权限时可能看不到其他用户进程的信息。
### 【基础】怎么实时查看日志?怎么查找包含某个关键字的行?
**参考答案**:`tail -f app.log` 实时跟踪;`grep -n "ERROR" app.log` 查找并显示行号,`grep -C 5` 显示上下文,`grep -r` 递归目录;systemd 服务用 `journalctl -u 服务名 -f`。
### 【基础】`kill`、`kill -9`、`kill -15` 有什么区别?
**参考答案**:`kill` 默认发送 SIGTERM(15),进程可以捕获它做清理后退出;`kill -9` 发送 SIGKILL,不能被捕获,进程立即被杀死,来不及释放资源。应该先 SIGTERM,等待超时后再 SIGKILL。
### 【基础】软链接和硬链接的区别?
**参考答案**:硬链接是同一个 inode 的另一个文件名,删除原文件后硬链接仍能访问,不能跨文件系统、不能链接目录;软链接是一个单独的文件,内容是目标路径,原文件删除后软链接失效,可以跨文件系统、可以链接目录。部署中常用软链接切换版本(`current -> releases/v2`)。
## 进阶
### 【进阶】服务器负载很高,你怎么排查?
**参考答案**:`uptime` 看 load 和核数对比;`top` 看 CPU 的 `us`(用户态计算)、`sy`(内核态)、`wa`(IO 等待);`wa` 高查磁盘(`iostat -x`、`iotop`),`us` 高找占用最高的进程和线程(`top -H -p`),再用语言工具(Go pprof、py-spy)定位到函数;同时看内存是否在用 swap。
### 【进阶】TIME_WAIT 和 CLOSE_WAIT 过多分别说明什么?
**参考答案**:TIME_WAIT 出现在主动关闭连接的一方,大量出现通常是频繁创建短连接,应该复用连接(keep-alive、连接池)。CLOSE_WAIT 表示对方已关闭、本方还没关闭,大量出现基本是程序没有关闭连接(例如 Go 没有 `resp.Body.Close()`),属于代码 bug。
### 【进阶】磁盘满了怎么处理?
**参考答案**:`df -h` 确认哪个分区满,`df -i` 检查 inode 是否用尽(大量小文件);`du -sh /* | sort -h` 逐层找大目录;常见是日志、临时文件、Docker 镜像和容器日志。删除正在被进程写的文件不会释放空间,用 `lsof +L1` 查找,清空文件内容或重启进程。长期方案:logrotate、Docker 日志大小限制、磁盘使用率告警。
## 场景题
### 【场景题】Go 服务内存持续上涨,最后被系统杀掉,怎么定位?
**参考答案**:先在内核日志确认是 OOM(`journalctl -k | grep -i oom`);服务开启 pprof,在内存上涨过程中多次采集 heap,对比哪些对象在增长(`go tool pprof -inuse_space`、`-base` 对比);同时看 goroutine 数量是否持续增长(goroutine 泄漏也会占内存)。常见原因:无上限的缓存或 map、goroutine 阻塞泄漏、读取大文件 / 大响应到内存。修复后加内存告警和 `MemoryMax` 限制。
### 【场景题】用户反馈接口偶尔很慢,但你登录服务器时一切正常,怎么办?
**参考答案**:需要事后可查的数据:Nginx 日志记录 `$request_time` 和 `$upstream_response_time`,用 awk 找出慢请求的时间点和接口;应用日志带请求 ID 和每一步耗时;数据库开慢查询日志;看云监控在对应时间点的 CPU、IO、网络曲线。根据时间点关联定时任务、备份、其他服务的批处理。长期方案是接入指标监控和链路追踪。
### 【场景题】怎么把一个 Go 程序部署成开机自启、崩溃自动重启的服务?
**参考答案**:编写 systemd 单元文件:指定运行用户、工作目录、环境变量文件、`ExecStart`、`Restart=on-failure`、`RestartSec`、停止超时;`systemctl daemon-reload`、`enable --now`;日志用 `journalctl -u` 查看。程序本身要处理 SIGTERM 优雅退出。也可以用 Docker 的 `restart: unless-stopped` 实现类似效果。
@@ -0,0 +1,118 @@
---
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]
est_hours: 2
related: [devops.linux.interview, devops.nginx.interview]
week: [14]
---
# 线上故障排查:接口变慢、磁盘写满、进程被 OOM 杀掉
## 业务背景与问题
一个小团队维护的 SaaS 系统,部署在两台云服务器上(Nginx + Go API + PostgreSQL),没有专职运维。某天上午:用户反馈页面很慢,部分请求 502;中午服务自己恢复了;下午又出现。你被要求“查清楚原因并保证不再发生”。
## 约束
- 业务优先:排查不能让故障时间更长。
- 只有 SSH 权限和基本的云监控(CPU、内存、磁盘曲线),没有完整的监控平台。
- 需要给出书面复盘。
## 方案设计
按“止血 → 采集现场 → 定位 → 修复 → 预防”推进:
1. **止血**:确认影响范围(Nginx 日志里 5xx 比例),如果最近有发布先回滚;必要时重启服务恢复,但**重启前先采集现场**。
2. **采集现场**:运行 `diag.sh` 保存系统快照;保存最近的 Nginx 错误日志、应用日志;对 Go 服务抓 pprof(heap、goroutine)。
3. **定位**(本例的实际线索):
- Nginx 错误日志:`connect() failed (111: Connection refused) while connecting to upstream` → 502 是因为 API 进程不在了;
- `journalctl -k`:`Out of memory: Killed process 2345 (api)` → API 被 OOM 杀掉,systemd 自动拉起,所以“自己恢复了”;
- 内存曲线:API 内存从启动开始持续上涨;
- pprof heap:大部分内存在一个“导出任务结果”的 map 里——导出结果放在内存里等用户下载,从不清理。
- 同时发现 `df -h` 磁盘使用 92%:应用日志没有切割。
4. **修复**:导出结果改为写入对象存储 / 临时文件并设置过期清理;本地缓存加容量上限;配置 logrotate;systemd 设置 `MemoryMax` 让问题早暴露、不拖垮整机。
5. **预防**:增加内存、磁盘、5xx 比例的告警;发布后观察内存曲线;复盘文档记录时间线和改进项。
## 关键代码
现场采集(只读):
```bash
bash diag.sh api > /tmp/diag-$(date +%F-%H%M).txt
journalctl -k --since "today" | grep -i -E "oom|killed process"
tail -n 200 /var/log/nginx/error.log | grep -E "upstream|connect\(\)"
curl -s -o /tmp/heap.pb.gz http://127.0.0.1:6060/debug/pprof/heap
go tool pprof -top -inuse_space /tmp/heap.pb.gz | head -n 15
```
logrotate 配置 `/etc/logrotate.d/api`:
```text
/srv/api/logs/*.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
}
```
带上限的本地缓存(修复思路,替代“无限增长的 map”):
```go
// 最多保留 max 个条目,超过时淘汰最早写入的(简单的 FIFO,按需换成 LRU)
type BoundedCache[K comparable, V any] struct {
mu sync.Mutex
max int
order []K
data map[K]V
}
func NewBoundedCache[K comparable, V any](max int) *BoundedCache[K, V] {
return &BoundedCache[K, V]{max: max, data: make(map[K]V)}
}
func (c *BoundedCache[K, V]) Put(k K, v V) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.data[k]; !ok {
c.order = append(c.order, k)
if len(c.order) > c.max {
delete(c.data, c.order[0])
c.order = c.order[1:]
}
}
c.data[k] = v
}
```
系统快照脚本和日志分析脚本见 [examples/log-analysis](../examples/log-analysis/)。
## 取舍
| 做法 | 优点 | 缺点 |
| --- | --- | --- |
| 直接重启 | 恢复最快 | 丢失现场,问题会再次发生 |
| **先采集现场再重启(本方案)** | 能定位根因 | 多花一两分钟 |
| 加内存 | 立即缓解 | 掩盖泄漏,只是延后 |
| systemd `MemoryMax` 限制 | 防止拖垮整机、问题早暴露 | 进程会更早被杀,需要配合告警 |
## 易错点
- 只看应用日志,没看内核日志:OOM 在应用日志里没有任何痕迹。
- 把“自己恢复了”当成没问题:其实是 systemd 自动重启掩盖了崩溃。
- 删除正在写的日志文件释放空间:空间不会释放,用 `copytruncate` 或让程序重新打开文件。
- 复盘只写“已修复”:要写时间线、根因、为什么没有提前发现、改进项和负责人。
## 面试怎么讲
> “有一次服务间歇性 502,自己又恢复。我先看 Nginx 错误日志,是连接后端被拒绝,说明进程挂了;再查内核日志发现是 OOM 被杀,systemd 自动重启所以看起来自己恢复了。用 pprof 看 heap,发现导出结果一直放在内存 map 里没清理。修复后又加了缓存上限、日志切割、内存和 5xx 告警,并写了复盘。”
如果你在前端岗位做过类似的线上排查(比如内存泄漏导致页面卡死、静态资源 404),也可以用同样的结构讲:现象 → 证据 → 根因 → 修复 → 预防。
+87
View File
@@ -0,0 +1,87 @@
---
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
est_hours: 2
related: [devops.nginx.https-stream-limit, devops.nginx.interview]
week: [3, 7]
order: 1
---
# 反向代理、负载均衡和静态资源:location 匹配、proxy_pass、SPA 回退
## 解释说明
- **正向代理 vs 反向代理**:正向代理代表客户端(翻墙代理),服务器不知道真实客户端;反向代理代表服务端,客户端只看到 Nginx,不知道后面有几台服务器。
- **配置结构**:`http` → `server`(按域名和端口区分站点)→ `location`(按路径匹配)。
- **location 匹配优先级**:`=` 精确匹配最高;然后找最长的前缀匹配,如果它带 `^~` 就直接用;否则按配置顺序检查正则(`~` 区分大小写、`~*` 不区分),第一个匹配的正则胜出;都不匹配就用最长前缀。
- **`proxy_pass` 末尾的斜杠**:`location /api/ { proxy_pass http://backend; }` 会把 `/api/users` 原样转发;`proxy_pass http://backend/;`(带路径)会把匹配的 `/api/` 替换成 `/`,后端收到 `/users`。
- **转发真实客户端信息**:后端看到的来源 IP 是 Nginx,要通过 `X-Real-IP`、`X-Forwarded-For`、`X-Forwarded-Proto` 传递;后端框架要配置信任这个代理(Gin 的 `SetTrustedProxies`、Uvicorn 的 `--forwarded-allow-ips`)。
- **负载均衡**:`upstream` 中列多个 server,默认轮询;还有 `weight` 权重、`least_conn` 最少连接、`ip_hash` 同一 IP 固定到同一台。`max_fails` / `fail_timeout` 做被动健康检查。
- **静态资源**:`root` 拼接路径(`root /var/www;` + `/a.js` → `/var/www/a.js`),`alias` 替换路径;SPA 用 `try_files $uri $uri/ /index.html;` 把前端路由回退到 index.html;带哈希的静态文件设长缓存,`index.html` 不缓存;开启 `gzip`。
## 案例
前后端分离部署在一台服务器:`/` 是 Vue / React 打包后的静态文件,`/api/` 转发到两台 Go API 实例,`/ai/` 转发到 Python AI 服务。刷新 `/orders/42` 时出现 404,原因是没有配置 `try_files` 回退。
## 代码示例
```nginx
upstream api {
least_conn;
server 127.0.0.1:8081 max_fails=3 fail_timeout=10s; # 示例:两个后端实例
server 127.0.0.1:8082 max_fails=3 fail_timeout=10s;
keepalive 32; # 和后端保持长连接
}
server {
listen 80;
server_name example.com;
root /var/www/app/dist;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location / {
try_files $uri $uri/ /index.html; # SPA 前端路由回退
}
location = /index.html {
add_header Cache-Control "no-cache"; # 入口文件不缓存,发版立即生效
}
location /assets/ {
expires 1y; # 文件名带哈希,可以长缓存
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://api; # 不带路径:/api/users 原样转发
proxy_http_version 1.1;
proxy_set_header Connection ""; # 配合 keepalive
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
}
```
可在本机用真实 Nginx 验证的完整配置和测试见 [examples/gateway](examples/gateway/)。修改配置后先 `nginx -t` 检查语法,再 `nginx -s reload` 平滑重载。
## 易错点
- **`proxy_pass` 带不带斜杠搞混**:后端收到的路径多了或少了 `/api`,表现为 404。
- **`root` 和 `alias` 搞混**:`location /static/ { alias /data/files/; }` 中 alias 末尾的斜杠要和 location 一致。
- **SPA 没配 `try_files`**:刷新非首页路由 404。
- **`index.html` 被长缓存**:发版后用户还在用旧页面、引用已删除的旧资源。
- **后端拿到的 IP 全是 Nginx 的**:没传 `X-Forwarded-For` 或后端没配置可信代理;反过来,盲目信任该头会被伪造。
- **改完配置直接 reload 不先 `nginx -t`**:语法错误时 reload 失败(旧配置继续运行),但如果是 restart 就直接起不来了。
## 面试常问
见 [Nginx 面试题](interview.md):正向和反向代理、location 优先级、负载均衡策略、proxy_pass 斜杠问题。
@@ -0,0 +1,113 @@
---
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]
est_hours: 3
related: [devops.nginx.scenario.ai-gateway-sse-canary, devops.nginx.interview]
week: [7, 14]
order: 2
---
# HTTPS、SSE / WebSocket、限流和灰度
## 解释说明
**HTTPS**
- 证书可以用 Let's Encrypt(免费,certbot 或 acme.sh 自动续期)或云厂商证书。
- 配置 `ssl_certificate`、`ssl_certificate_key`,只开启 TLS 1.2 / 1.3;80 端口 301 跳转到 443。
- TLS 在 Nginx 终止,Nginx 到内网后端用 HTTP(`X-Forwarded-Proto: https` 告诉后端原始协议)。
**SSE(大模型流式输出)**
- SSE 是普通 HTTP 响应(`Content-Type: text/event-stream`),服务端不断写入 `data: ...\n\n`。
- Nginx 默认开启 `proxy_buffering`,会把后端响应先攒进缓冲区,导致用户看到的“打字机效果”变成一大段一起出现。**关闭缓冲**:`proxy_buffering off;`,或者后端返回响应头 `X-Accel-Buffering: no`。
- 生成时间长,`proxy_read_timeout` 要调大(它是两次读之间的间隔,不是总时长);也可以让后端定期发心跳注释 `: ping`。
- 如果开启了 gzip,确认流式响应不被压缩缓冲(`text/event-stream` 不在 `gzip_types` 中即可)。
**WebSocket**:需要协议升级头:
```nginx
map $http_upgrade $connection_upgrade { default upgrade; "" close; }
location /ws/ {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
```
**限流**:`limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;` 定义按 IP 的漏桶;`limit_req zone=api burst=20 nodelay;` 允许突发 20 个且不排队延迟;超出返回 `limit_req_status`(默认 503,建议改成 429)。`limit_conn` 限制并发连接数。
**灰度发布**:
- 按比例:`split_clients "${remote_addr}" $variant { 10% canary; * stable; }`;
- 按用户:`map $cookie_canary $backend { default stable; "1" canary; }`,内部员工或白名单用户先体验;
- 按权重:`upstream` 里新旧实例设置不同 `weight`。
## 案例
AI 问答页面上线后用户反馈“回答要等很久才一下子全部出来”:浏览器直连后端是逐字输出,经过 Nginx 就变成整段。原因是 `proxy_buffering` 默认开启。关闭后恢复逐字输出;同时把 `proxy_read_timeout` 从 60 秒调到 300 秒,避免长回答被中途断开(504)。
## 代码示例
```nginx
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
map $cookie_canary $api_backend {
default api_stable;
"1" api_canary;
}
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+ 的写法;旧版本写在 listen 后面
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /api/ {
limit_req zone=api_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://$api_backend;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ai/stream {
proxy_pass http://api_stable;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # 流式输出必须关闭缓冲
proxy_cache off;
proxy_read_timeout 300s;
}
}
```
[examples/gateway](examples/gateway/) 的测试里验证了:关闭缓冲时第一条 SSE 事件在约 0.3 秒内到达,而整个响应约 0.9 秒结束;打开缓冲时第一条事件要等整个响应结束才到达。
## 易错点
- **SSE 没关缓冲**:流式变整段。
- **`proxy_read_timeout` 太短**:长回答被 504 中断。
- **WebSocket 没加 Upgrade / Connection 头**:握手失败,前端报 400 或连接立即关闭。
- **限流按 `$remote_addr` 但前面还有一层负载均衡**:所有请求的地址都是负载均衡器,一起被限流。要用 `real_ip` 模块从可信代理的头里取真实 IP。
- **证书过期**:自动续期任务失败没人发现。加监控告警。
- **灰度只切了后端没切前端资源**(或反过来):前后端版本不匹配。灰度规则要一致,接口要向后兼容。
## 面试常问
见 [Nginx 面试题](interview.md):SSE 代理要注意什么、WebSocket 代理配置、限流原理、灰度发布怎么做、502 和 504 的区别。
@@ -0,0 +1,63 @@
# 网关示例:静态前端 + /api 反向代理 + /ai/stream SSE 流式代理 + 限流 + 按 Cookie 灰度
# 运行 test_gateway.py 时会把 {{...}} 占位符替换成临时端口和目录
worker_processes 1;
error_log {{PREFIX}}/error.log warn;
pid {{PREFIX}}/nginx.pid;
events { worker_connections 256; }
http {
access_log off;
# 非 root 运行时,把临时文件目录放到可写位置
client_body_temp_path {{PREFIX}}/client_body;
proxy_temp_path {{PREFIX}}/proxy;
fastcgi_temp_path {{PREFIX}}/fastcgi;
uwsgi_temp_path {{PREFIX}}/uwsgi;
scgi_temp_path {{PREFIX}}/scgi;
# 每个 IP 每秒 5 个请求,突发最多 5 个
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
limit_req_status 429;
upstream api_stable { server 127.0.0.1:{{STABLE_PORT}}; keepalive 16; }
upstream api_canary { server 127.0.0.1:{{CANARY_PORT}}; keepalive 16; }
# Cookie canary=1 的用户走新版本,其余走稳定版本
map $cookie_canary $api_backend {
default api_stable;
"1" api_canary;
}
server {
listen 127.0.0.1:{{LISTEN_PORT}};
# 前端单页应用:找不到文件时回退到 index.html,交给前端路由
location / {
root {{PREFIX}}/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
limit_req zone=api_limit burst=5 nodelay;
proxy_pass http://$api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
# 大模型流式输出(SSE):关闭缓冲,否则 Nginx 攒满缓冲区才发给浏览器
location /ai/stream {
proxy_pass http://api_stable;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s; # 生成时间长,读超时要放宽
add_header X-Accel-Buffering no;
}
}
}
@@ -0,0 +1,130 @@
"""用真实的 Nginx 验证网关配置:反向代理、SPA 回退、SSE 不缓冲、限流、Cookie 灰度。
运行:python -m pytest -q test_gateway.py(需要本机安装 nginx;没有安装时自动跳过)
测试会启动两个 Python 后端(稳定版 / 灰度版)和一个非 root 的 Nginx,全部使用临时端口和临时目录。
"""
import http.client
import shutil
import socket
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
NGINX = shutil.which("nginx") or ("/usr/sbin/nginx" if Path("/usr/sbin/nginx").exists() else None)
pytestmark = pytest.mark.skipif(NGINX is None, reason="没有安装 nginx")
HERE = Path(__file__).resolve().parent
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def backend(version: str) -> ThreadingHTTPServer:
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): # 安静
pass
def do_GET(self):
if self.path.startswith("/ai/stream"):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
for i in range(3):
self.wfile.write(f"data: token{i}\n\n".encode())
self.wfile.flush()
time.sleep(0.3)
self.close_connection = True
return
body = f'{{"version":"{version}","xff":"{self.headers.get("X-Forwarded-For", "")}"}}'.encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
@pytest.fixture(scope="module")
def gateway(tmp_path_factory):
prefix = tmp_path_factory.mktemp("nginx")
(prefix / "html").mkdir()
(prefix / "html" / "index.html").write_text("<div id=app>SPA</div>")
stable, canary = backend("stable"), backend("canary")
port = free_port()
conf = (HERE / "nginx.conf.template").read_text()
for k, v in {"PREFIX": str(prefix), "STABLE_PORT": stable.server_address[1],
"CANARY_PORT": canary.server_address[1], "LISTEN_PORT": port}.items():
conf = conf.replace("{{" + k + "}}", str(v))
(prefix / "nginx.conf").write_text(conf)
check = subprocess.run([NGINX, "-t", "-p", str(prefix), "-c", str(prefix / "nginx.conf")],
capture_output=True, text=True)
assert check.returncode == 0, check.stderr
proc = subprocess.Popen([NGINX, "-p", str(prefix), "-c", str(prefix / "nginx.conf"), "-g", "daemon off;"],
stderr=subprocess.PIPE)
for _ in range(50):
try:
socket.create_connection(("127.0.0.1", port), timeout=0.1).close()
break
except OSError:
time.sleep(0.1)
yield port
proc.terminate()
proc.wait(5)
stable.shutdown()
canary.shutdown()
def get(port: int, path: str, headers: dict | None = None) -> tuple[int, str]:
c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
c.request("GET", path, headers=headers or {})
r = c.getresponse()
body = r.read().decode()
c.close()
return r.status, body
def test_spa_fallback(gateway):
assert get(gateway, "/orders/42") == (200, "<div id=app>SPA</div>")
def test_proxy_and_canary(gateway):
status, body = get(gateway, "/api/hello")
assert status == 200 and '"version":"stable"' in body and '"xff":"127.0.0.1"' in body
time.sleep(1.2) # 等限流桶恢复
status, body = get(gateway, "/api/hello", {"Cookie": "canary=1"})
assert '"version":"canary"' in body
def test_sse_not_buffered(gateway):
c = http.client.HTTPConnection("127.0.0.1", gateway, timeout=5)
start = time.monotonic()
c.request("GET", "/ai/stream")
r = c.getresponse()
first = r.readline() # 读到第一条事件就返回
first_at = time.monotonic() - start
rest = r.read()
total = time.monotonic() - start
c.close()
assert first.startswith(b"data: token0")
assert b"token2" in rest
# 不缓冲时,第一条事件远早于整个响应结束(后端共耗时约 0.9 秒)
assert first_at < 0.5 < total, (first_at, total)
def test_rate_limit(gateway):
time.sleep(1.5)
codes = [get(gateway, "/api/hello")[0] for _ in range(20)]
assert codes.count(200) >= 5 and 429 in codes, codes
+30 -3
View File
@@ -4,12 +4,39 @@ title: Nginx
type: index type: index
domain: devops domain: devops
stack: [nginx] stack: [nginx]
tags: [] tags: [Nginx, 反向代理, HTTPS, 负载均衡, SSE]
week: [3, 7, 14] week: [3, 7, 14]
--- ---
# Nginx # Nginx
反向代理、静态资源、HTTPS、SSE / WebSocket、限流、灰度。 前端出身的你对 Nginx 应该不陌生(部署 SPA、配置代理)。转后端 / AI 应用后要再往前走一步:反向代理和负载均衡、HTTPS、**大模型流式输出(SSE)的代理配置**、限流、灰度发布,以及常见 502/504 问题的排查。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 ## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [反向代理、负载均衡和静态资源:location 匹配、proxy_pass、SPA 回退](01-proxy-static.md) | 基础 | 2 小时 |
| 2 | [HTTPS、SSE / WebSocket、限流和灰度](02-https-stream-limit.md) | 进阶 | 3 小时 |
## 企业场景
- [AI 应用网关:流式输出不卡顿、按用户灰度新版本](scenarios/ai-gateway-sse-canary.md)
## 面试题
- [Nginx 面试题](interview.md)
## 示例代码
- [examples/gateway](examples/gateway/):网关配置模板 + pytest 测试,用真实 Nginx 验证反向代理、SPA 回退、SSE 不缓冲、限流、Cookie 灰度(本机没有 nginx 时测试自动跳过)
## 在 16 周计划中
- [第 3 周:项目一部署](../../../plan/week03-project1.md)、[第 7 周:前端部署](../../../plan/week07-frontend.md)、[第 14 周:部署](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- Nginx 文档:https://nginx.org/en/docs/
- 代理模块参数:https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- 限流模块:https://nginx.org/en/docs/http/ngx_http_limit_req_module.html
+59
View File
@@ -0,0 +1,59 @@
---
id: devops.nginx.interview
title: Nginx 面试题
type: interview
domain: devops
stack: [nginx]
tags: [Nginx, 反向代理, 负载均衡, 面试]
level: basic
related: [devops.nginx.proxy-static, devops.nginx.https-stream-limit]
week: [7, 14, 16]
---
# Nginx 面试题
## 基础
### 【基础】正向代理和反向代理的区别?
**参考答案**:正向代理代表客户端访问外部服务器,服务器看到的是代理(例如公司上网代理);反向代理代表服务器接收客户端请求,客户端看到的是代理,不知道后端有哪些服务器。Nginx 常用作反向代理,做负载均衡、TLS 终止、静态资源、限流。
### 【基础】Nginx 的负载均衡策略有哪些?
**参考答案**:轮询(默认)、加权轮询(`weight`)、最少连接(`least_conn`)、IP 哈希(`ip_hash`,同一客户端固定到同一后端)、通用哈希(`hash $key`,可配合一致性哈希)。通过 `max_fails`、`fail_timeout` 做被动健康检查。
### 【基础】location 的匹配优先级是怎样的?
**参考答案**:先看 `=` 精确匹配;再找最长前缀匹配,如果它带 `^~` 就直接使用;否则按顺序匹配正则(`~`、`~*`),第一个匹配的生效;没有正则匹配就用最长前缀匹配。
### 【基础】前端单页应用部署到 Nginx 后刷新页面 404,为什么?
**参考答案**:前端路由(history 模式)的路径在服务器上没有对应文件。用 `try_files $uri $uri/ /index.html;` 让找不到的路径都返回 index.html,交给前端路由处理。
## 进阶
### 【进阶】502 和 504 有什么区别?分别怎么排查?
**参考答案**:502 Bad Gateway 是 Nginx 连接后端失败或后端返回了无效响应——后端没启动、端口错、进程崩溃、连接被拒绝,看 Nginx 错误日志里的 `connect() failed` 等信息。504 Gateway Timeout 是后端在超时时间内没有响应——后端处理慢(慢查询、调用外部服务卡住),或者 `proxy_read_timeout` 设置太短(例如大模型长回答)。
### 【进阶】代理 SSE 和 WebSocket 分别要注意什么?
**参考答案**:SSE 要关闭代理缓冲(`proxy_buffering off` 或后端返回 `X-Accel-Buffering: no`),否则流式内容被攒成一大段;读超时要放宽或者后端发心跳。WebSocket 需要 `proxy_http_version 1.1` 并传递 `Upgrade` 和 `Connection` 头完成协议升级,读超时也要调大。
### 【进阶】Nginx 怎么限流?
**参考答案**:`limit_req_zone` 定义按某个 key(如 `$binary_remote_addr`)的请求速率,基于漏桶算法;`limit_req` 应用到 location,`burst` 允许排队的突发量,`nodelay` 让突发请求不排队立即处理;超出返回 `limit_req_status`(默认 503,常改为 429)。`limit_conn` 限制并发连接数。前面还有负载均衡时要用 `real_ip` 模块取真实客户端 IP。
## 场景题
### 【场景题】AI 问答的流式输出经过 Nginx 后变成一次性返回,怎么解决?
**参考答案**:Nginx 默认对代理响应做缓冲。给流式接口单独 location,设置 `proxy_buffering off`、`proxy_cache off`,HTTP/1.1 长连接,`proxy_read_timeout` 调大;后端响应头加 `X-Accel-Buffering: no`;确认 gzip 没有压缩 `text/event-stream`;如果前面还有 CDN 或云负载均衡,也要确认它们支持流式。详见[AI 应用网关场景](scenarios/ai-gateway-sse-canary.md)。
### 【场景题】要让 10% 的用户先用新版本,Nginx 怎么做?
**参考答案**:用 `split_clients` 根据用户标识(如用户 ID Cookie 或 IP)哈希出 10% 映射到新版本 upstream;或者 `upstream` 中用 `weight` 按比例分配;指定用户体验用 `map $cookie_xxx`。注意同一用户要稳定落在同一版本(按用户标识哈希,而不是随机)、新旧版本接口兼容、能一键回滚。
### 【场景题】怎么做到发版时用户无感知(不中断请求)?
**参考答案**:后端多实例滚动发布:先把一台实例从 upstream 摘除(或标记 `down`)并 reload Nginx,等它处理完存量请求后升级,再加回去,逐台进行;后端程序要支持优雅退出。Nginx 自身修改配置用 `nginx -s reload`,旧 worker 处理完已有连接再退出,不会中断请求。
@@ -0,0 +1,109 @@
---
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]
est_hours: 2
related: [devops.nginx.interview, devops.docker.scenario.compose-dev-env]
week: [7, 13, 14]
---
# AI 应用网关:流式输出不卡顿、按用户灰度新版本
## 业务背景与问题
企业内部的 AI 问答助手:前端单页应用,后端 Python FastAPI 调用大模型并以 SSE 流式返回。上线后遇到三个问题:
1. 经过 Nginx 后,回答不再逐字出现,而是等十几秒后整段出现;长回答偶尔 504。
2. 新版本的检索策略想先给内部 20 个同事试用,没有灰度手段,只能全量发布。
3. 有人写脚本刷接口,模型调用费用突增。
## 约束
- 只有一台入口服务器上的 Nginx,不引入新的网关产品。
- 灰度要能按用户指定,也要能随时关闭。
- 正常用户的体验不能受限流影响。
## 方案设计
```mermaid
flowchart LR
B[浏览器] --> N[Nginx]
N -->|/ 静态资源| S[dist/]
N -->|/api/ 限流 + 按 Cookie 选择| ST[AI 服务 稳定版]
N -->|canary=1| CA[AI 服务 灰度版]
N -->|/ai/stream 关闭缓冲| ST
```
1. **流式**:`/ai/stream` 单独一个 location,`proxy_buffering off`、`proxy_cache off`、`proxy_read_timeout 300s`;后端每 15 秒发一条 `: ping` 心跳,防止中间设备断开空闲连接。
2. **灰度**:`map $cookie_canary $api_backend`,内部同事访问一个“加入体验”页面,由后端为其设置 `canary=1` 的 Cookie;关闭灰度只需把 map 默认值都指向稳定版并 reload。也可以用 `split_clients` 按比例放量。
3. **限流**:Nginx 按 IP 做第一道粗粒度限流(`limit_req`,返回 429);后端再按用户 / 租户做精细的配额(Redis,见 [Redis 限流](../../../backend/redis/03-lock-ratelimit.md)),并记录每个用户的模型调用量。
## 关键代码
完整配置模板和自动化测试见 [examples/gateway](../examples/gateway/):
```nginx
map $cookie_canary $api_backend {
default api_stable;
"1" api_canary;
}
location /api/ {
limit_req zone=api_limit burst=5 nodelay;
proxy_pass http://$api_backend;
}
location /ai/stream {
proxy_pass http://api_stable;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
```
FastAPI 端的 SSE 响应头和心跳(示意):
```python
from fastapi.responses import StreamingResponse
async def event_stream(chunks):
async for text in chunks: # chunks:大模型返回的增量文本
yield f"data: {text}\n\n"
yield "data: [DONE]\n\n"
def sse(chunks) -> StreamingResponse:
return StreamingResponse(event_stream(chunks), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
```
测试用真实 Nginx 验证了四点:SPA 回退、反向代理并传递 `X-Forwarded-For`、带 `canary=1` Cookie 的请求进入灰度后端、SSE 第一条事件不被缓冲、超过限流返回 429。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| **Nginx map / split_clients(本方案)** | 无新组件,配置简单 | 规则复杂时难维护;改规则要 reload |
| API 网关产品(APISIX、Kong 等) | 动态配置、插件丰富 | 多一套系统要运维 |
| 应用内特性开关 | 粒度最细(按功能) | 需要改代码,新旧逻辑并存 |
| Kubernetes + 服务网格 | 按权重、按头部灰度都很方便 | 需要 K8s 和网格基础设施 |
## 易错点
- 只在 `/api/` 关了缓冲,SSE 走的其实是另一个 location。
- 开了 gzip 且把 `text/event-stream` 加进 `gzip_types`,流式又被攒起来。
- `proxy_read_timeout` 理解成总时长:它是两次读之间的最大间隔,心跳能避免超时。
- 灰度 Cookie 由前端随意设置:任何人都能进灰度。应由后端校验身份后设置。
- 限流只在 Nginx 按 IP:公司出口是同一个 IP,所有同事一起被限。内部系统应按用户维度在后端限流。
## 面试怎么讲
> “AI 助手经过 Nginx 后流式输出变成整段,原因是 Nginx 默认缓冲代理响应。我给流式接口单独配了 location,关闭缓冲、放宽读超时,后端加心跳;用 map 按 Cookie 把内部同事路由到灰度版本;在 Nginx 做按 IP 的粗限流、后端按用户做配额。我还写了自动化测试,用真实 Nginx 验证第一条事件不被缓冲、灰度和限流生效。”