diff --git a/.revision-progress b/.revision-progress index 888f13c..78c1124 100644 --- a/.revision-progress +++ b/.revision-progress @@ -10,3 +10,4 @@ phase4 done (7a32d2c) phase5a done (frontend ts/vue/react/ssr) phase5b done (frontend vite/webpack/fe-testing-perf) phase5c done (backend mongodb/elasticsearch/data-pipeline/nodejs) +phase5d done (java-spring awareness, k8s, run_examples java support) diff --git a/catalog.json b/catalog.json index 6e4fb52..d07af57 100644 --- a/catalog.json +++ b/catalog.json @@ -280,14 +280,16 @@ "domain": "backend", "title": "Java / Spring Boot(了解级)", "index": "topics/backend/java-spring/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 1, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 3, + "进阶": 3, + "场景题": 2 }, - "examples": [] + "examples": [ + "topics/backend/java-spring/examples/java-basics" + ] }, { "stack": "mongodb", @@ -481,14 +483,16 @@ "domain": "devops", "title": "Kubernetes 基础", "index": "topics/devops/k8s/index.md", - "lessons": 0, - "scenarios": 0, + "lessons": 1, + "scenarios": 1, "interview_questions": { - "基础": 0, - "进阶": 0, - "场景题": 0 + "基础": 3, + "进阶": 3, + "场景题": 2 }, - "examples": [] + "examples": [ + "topics/devops/k8s/examples/agent-api" + ] }, { "stack": "linux", @@ -3800,6 +3804,47 @@ "面试怎么讲" ] }, + { + "id": "backend.java-spring.awareness", + "title": "Java / Spring Boot(了解级):读懂项目结构、IoC 与依赖注入、分层、事务注解和与 Go / Python 的对照", + "type": "lesson", + "domain": "backend", + "stack": [ + "java-spring" + ], + "tags": [ + "Java", + "Spring Boot", + "IoC", + "依赖注入", + "注解", + "事务", + "JPA", + "MyBatis", + "虚拟线程" + ], + "level": "basic", + "prerequisites": [ + "backend.go.index" + ], + "related": [ + "backend.java-spring.scenario.call-java-service", + "backend.java-spring.interview" + ], + "est_hours": 2, + "week": [ + 14 + ], + "order": 1, + "path": "topics/backend/java-spring/01-java-spring-awareness.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, { "id": "backend.java-spring.index", "title": "Java / Spring Boot(了解级)", @@ -3808,7 +3853,11 @@ "stack": [ "java-spring" ], - "tags": [], + "tags": [ + "Java", + "Spring Boot", + "了解级" + ], "level": null, "prerequisites": [], "related": [], @@ -3818,7 +3867,94 @@ ], "order": null, "path": "topics/backend/java-spring/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中" + ] + }, + { + "id": "backend.java-spring.interview", + "title": "Java / Spring Boot(了解级)面试题", + "type": "interview", + "domain": "backend", + "stack": [ + "java-spring" + ], + "tags": [ + "Java", + "Spring Boot", + "IoC", + "事务", + "面试" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "backend.java-spring.awareness" + ], + "est_hours": null, + "week": [ + 14, + 16 + ], + "order": null, + "path": "topics/backend/java-spring/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 3, + "进阶": 3, + "场景题": 2 + } + }, + { + "id": "backend.java-spring.scenario.call-java-service", + "title": "Python Agent 调用公司已有的 Java 业务系统:读接口、封装工具、错误映射和联调", + "type": "scenario", + "domain": "backend", + "stack": [ + "java-spring", + "function-calling", + "fastapi" + ], + "tags": [ + "系统对接", + "工具封装", + "错误码映射", + "OpenAPI", + "联调", + "幂等" + ], + "level": "intermediate", + "prerequisites": [ + "backend.java-spring.awareness", + "ai.function-calling.tool-loop" + ], + "related": [ + "backend.java-spring.interview", + "ai.function-calling.scenario.safe-business-tools" + ], + "est_hours": 2, + "week": [ + 14 + ], + "order": null, + "path": "topics/backend/java-spring/scenarios/call-java-service.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] }, { "id": "backend.mongodb.document-model", @@ -5651,6 +5787,51 @@ "面试怎么讲" ] }, + { + "id": "devops.k8s.core-objects", + "title": "Kubernetes 基础:Pod / Deployment / Service / Ingress、配置与密钥、探针、滚动更新和扩缩容", + "type": "lesson", + "domain": "devops", + "stack": [ + "k8s" + ], + "tags": [ + "Kubernetes", + "Pod", + "Deployment", + "Service", + "Ingress", + "ConfigMap", + "Secret", + "探针", + "滚动更新", + "HPA", + "PDB", + "kubectl" + ], + "level": "basic", + "prerequisites": [ + "devops.docker.image-dockerfile" + ], + "related": [ + "devops.k8s.scenario.agent-rollout", + "devops.nginx.scenario.ai-gateway-sse-canary", + "devops.k8s.interview" + ], + "est_hours": 3, + "week": [ + 14 + ], + "order": 1, + "path": "topics/devops/k8s/01-core-objects.md", + "sections": [ + "解释说明", + "案例", + "代码示例", + "易错点", + "面试常问" + ] + }, { "id": "devops.k8s.index", "title": "Kubernetes 基础", @@ -5659,7 +5840,12 @@ "stack": [ "k8s" ], - "tags": [], + "tags": [ + "Kubernetes", + "部署", + "探针", + "滚动更新" + ], "level": null, "prerequisites": [], "related": [], @@ -5669,7 +5855,97 @@ ], "order": null, "path": "topics/devops/k8s/index.md", - "sections": [] + "sections": [ + "学习路线", + "企业场景", + "面试题", + "示例代码", + "在 16 周计划中", + "官方资料" + ] + }, + { + "id": "devops.k8s.interview", + "title": "Kubernetes 基础面试题", + "type": "interview", + "domain": "devops", + "stack": [ + "k8s" + ], + "tags": [ + "Kubernetes", + "探针", + "滚动更新", + "排查", + "面试" + ], + "level": "basic", + "prerequisites": [], + "related": [ + "devops.k8s.core-objects" + ], + "est_hours": null, + "week": [ + 14, + 16 + ], + "order": null, + "path": "topics/devops/k8s/interview.md", + "sections": [ + "基础", + "进阶", + "场景题" + ], + "questions": { + "基础": 3, + "进阶": 3, + "场景题": 2 + } + }, + { + "id": "devops.k8s.scenario.agent-rollout", + "title": "AI Agent 服务上 K8s:发布时流式回答不中断、慢启动、密钥管理和按负载扩容", + "type": "scenario", + "domain": "devops", + "stack": [ + "k8s", + "docker", + "nginx", + "cicd" + ], + "tags": [ + "滚动更新", + "优雅退出", + "SSE", + "探针", + "HPA", + "Secret", + "回滚" + ], + "level": "intermediate", + "prerequisites": [ + "devops.k8s.core-objects", + "devops.docker.image-dockerfile" + ], + "related": [ + "devops.k8s.interview", + "devops.cicd.release-rollback" + ], + "est_hours": 2.5, + "week": [ + 14 + ], + "order": null, + "path": "topics/devops/k8s/scenarios/agent-rollout.md", + "sections": [ + "业务背景与问题", + "约束", + "方案设计", + "关键代码", + "取舍", + "易错点", + "面试怎么讲" + ] }, { "id": "devops.linux.commands", diff --git a/tools/run_examples.py b/tools/run_examples.py index 72d725b..5dab062 100644 --- a/tools/run_examples.py +++ b/tools/run_examples.py @@ -4,6 +4,7 @@ - Go:含 go.mod 的目录执行 `go vet ./... && go test ./...` - Python:*.py 直接运行(文件开头可写 `# requires: 包1, 包2`,缺包时跳过);test_*.py 用 pytest 运行 - TypeScript:*.ts 用 Node.js 直接运行(需要 Node 23.6+ 的类型剥离,推荐 Node 24 LTS) +- Java:*.java 用 `java 文件名.java` 单文件运行(需要 JDK 21+;可用环境变量 JAVA 指定 java 可执行文件) - 文件开头写 `# 不自动运行` / `// 不自动运行` 的跳过(需要外部服务的示例) - 运行时设置环境变量 `RUN_EXAMPLES=1`:会长期运行的服务(如 MCP stdio 服务)据此只做导入检查、不启动 @@ -56,7 +57,7 @@ def main() -> int: targets = [ex] if ex.is_file() else sorted(ex.rglob("*")) for f in targets: frel = f.relative_to(ROOT).as_posix() - if f.suffix not in (".py", ".ts") or "node_modules" in f.parts: + if f.suffix not in (".py", ".ts", ".java") or "node_modules" in f.parts: continue head = f.read_text(encoding="utf-8")[:400] if "不自动运行" in head: @@ -72,6 +73,12 @@ def main() -> int: cmd = [sys.executable, "-m", "pytest", "-q", f.name] if f.name.startswith("test_") \ else [sys.executable, f.name] ok, out = run(cmd, f.parent) + elif f.suffix == ".java": + java = os.environ.get("JAVA") or shutil.which("java") + if not java: + results.append(("SKIP", frel, "没有安装 JDK")) + continue + ok, out = run([java, f.name], f.parent) else: if not has_node: results.append(("SKIP", frel, "需要 Node 23.6+")) diff --git a/topics/backend/java-spring/01-java-spring-awareness.md b/topics/backend/java-spring/01-java-spring-awareness.md new file mode 100644 index 0000000..dc59855 --- /dev/null +++ b/topics/backend/java-spring/01-java-spring-awareness.md @@ -0,0 +1,110 @@ +--- +id: backend.java-spring.awareness +title: Java / Spring Boot(了解级):读懂项目结构、IoC 与依赖注入、分层、事务注解和与 Go / Python 的对照 +type: lesson +domain: backend +stack: [java-spring] +tags: [Java, Spring Boot, IoC, 依赖注入, 注解, 事务, JPA, MyBatis, 虚拟线程] +level: basic +prerequisites: [backend.go.index] +est_hours: 2 +related: [backend.java-spring.scenario.call-java-service, backend.java-spring.interview] +week: [14] +order: 1 +--- + +# Java / Spring Boot(了解级):读懂项目结构、IoC 与依赖注入、分层、事务注解和与 Go / Python 的对照 + +> 定位:**能读懂、能对接、面试能聊概念**,不作为主线。BOSS直聘 2026-10-23 的调研中,有 AI 应用岗位写了“熟悉 Java / Go / Python 其中之一”,或者团队已有 Java 业务系统需要 Agent 去调用。示例只用 JDK,在 JDK 26(JDK 21+ 即可)上实际运行通过;Spring 代码为示意,未编译。以官方文档为准:https://docs.spring.io/spring-boot/ 。 + +## 解释说明 + +### 与你熟悉的技术对照 + +| 概念 | Java / Spring Boot | Go | Python / FastAPI | +| --- | --- | --- | --- | +| 数据类 | `record` | struct | `dataclass` / Pydantic 模型 | +| 路由 | `@RestController` + `@GetMapping` | Gin 路由 | `@app.get` | +| 依赖注入 | IoC 容器 + 构造器注入 | 手动在 main 中组装 | `Depends` | +| 参数校验 | `@Valid` + Bean Validation | binding 标签 | Pydantic | +| 数据访问 | Spring Data JPA / MyBatis | database/sql、sqlc、GORM | SQLAlchemy | +| 事务 | `@Transactional` | 手动 `tx.Begin/Commit` | `session.begin()` | +| 并发 | 线程池、虚拟线程(JDK 21) | goroutine | asyncio | +| 构建 | Maven / Gradle | go build | uv / pip | + +### Spring 的核心概念 + +- **IoC 容器**:对象(Bean)由容器创建和管理,你声明依赖,容器注入。推荐**构造器注入**(依赖不可变、便于测试)。 +- **分层**:Controller(接收请求)→ Service(业务逻辑、事务)→ Repository(数据访问)。 +- **自动配置**:Spring Boot 根据依赖和配置自动创建常用组件(数据源、Web 服务器),配置在 `application.yml`。 +- **`@Transactional` 的坑**:基于代理实现,同一个类内部方法调用不经过代理,事务不生效;默认只对运行时异常回滚。 +- **AI 相关**:Spring AI、LangChain4j 等框架让 Java 项目也能接入大模型(版本变化快,以官方文档为准)。 + +### 怎么快速读懂一个 Spring Boot 项目 + +1. 看 `pom.xml` / `build.gradle`:依赖了什么。 +2. 找带 `@SpringBootApplication` 的启动类。 +3. 看 `application.yml`:数据库、端口、外部服务地址。 +4. 从 Controller 顺着调用链读到 Service 和 Repository。 +5. 看全局异常处理(`@RestControllerAdvice`)和拦截器 / 过滤器(鉴权)。 + +## 案例 + +你负责的 Python Agent 需要调用公司的 Java 订单服务:读懂它的接口定义(Controller 和 DTO)、错误码、鉴权方式、分页规则,把它封装成 Agent 的工具。 + +## 代码示例 + +只用 JDK 的对照示例 [examples/java-basics/OrderDemo.java](examples/java-basics/OrderDemo.java)(`java OrderDemo.java` 运行通过):record、接口 + 构造器注入、Optional、Stream 分组汇总、BigDecimal 金额、虚拟线程。 + +```text +跨租户查询被拒绝: 订单不存在: B001 +t1 按状态汇总: {paid=20.00, refunded=5.00} +用 double 计算金额的问题: 0.1 + 0.2 = 0.30000000000000004 +1000 个虚拟线程计数结果: 1000 +``` + +对应的 Spring Boot 写法(示意,未编译): + +```java +@RestController +@RequestMapping("/orders") +class OrderController { + private final OrderService service; + OrderController(OrderService service) { this.service = service; } // 构造器注入,无需 @Autowired + + @GetMapping("/{id}") + OrderDto get(@PathVariable String id, @RequestHeader("X-Tenant-Id") String tenant) { + return OrderDto.from(service.get(tenant, id)); + } +} + +@Service +class OrderService { + private final OrderRepository repo; + OrderService(OrderRepository repo) { this.repo = repo; } + + @Transactional + public void refund(String tenant, String id) { /* 修改订单 + 写退款记录,在一个事务中 */ } +} + +@RestControllerAdvice +class GlobalErrors { + @ExceptionHandler(NotFoundException.class) + ResponseEntity> notFound(NotFoundException e) { + return ResponseEntity.status(404).body(Map.of("code", "NOT_FOUND", "message", e.getMessage())); + } +} +``` + +## 易错点 + +- 金额用 `double`。 +- 同类内部调用带 `@Transactional` 的方法,以为事务生效。 +- 受检异常被捕获后吞掉,事务没有回滚。 +- 在面试中把“了解”说成“精通”:如实说明能读懂并对接,主语言是 Go / Python。 + +## 面试常问 + +- 什么是 IoC 和依赖注入? +- `@Transactional` 什么情况下会失效? +- 你不会 Java,如何和 Java 团队协作? diff --git a/topics/backend/java-spring/examples/java-basics/OrderDemo.java b/topics/backend/java-spring/examples/java-basics/OrderDemo.java new file mode 100644 index 0000000..08697f3 --- /dev/null +++ b/topics/backend/java-spring/examples/java-basics/OrderDemo.java @@ -0,0 +1,87 @@ +// Java 基础对照示例(不依赖 Spring,只用 JDK):record、接口 + 构造器注入、Optional、Stream、异常、并发 +// 运行(JDK 21+):java OrderDemo.java —— 单文件源码直接运行,不需要先 javac +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +public class OrderDemo { + // record:不可变数据类,自动生成构造器、getter、equals/hashCode/toString(类似 Go struct / Python dataclass(frozen)) + record Order(String id, String tenant, String status, BigDecimal amount) {} + + // 接口 + 实现:Spring 中会用 @Repository 标注,由容器创建并注入 + interface OrderRepository { + Optional findById(String id); + List findByTenant(String tenant); + } + + static class InMemoryOrderRepository implements OrderRepository { + private final Map data = new ConcurrentHashMap<>(); + InMemoryOrderRepository(List seed) { seed.forEach(o -> data.put(o.id(), o)); } + public Optional findById(String id) { return Optional.ofNullable(data.get(id)); } + public List findByTenant(String tenant) { + return data.values().stream().filter(o -> o.tenant().equals(tenant)).toList(); + } + } + + static class NotFoundException extends RuntimeException { + NotFoundException(String msg) { super(msg); } + } + + // 构造器注入:Spring 推荐的方式(@Service),依赖是 final 的,测试时可以直接传入假实现 + static class OrderService { + private final OrderRepository repo; + OrderService(OrderRepository repo) { this.repo = repo; } + + Order get(String tenant, String id) { + return repo.findById(id) + .filter(o -> o.tenant().equals(tenant)) // 租户隔离:别的租户的订单当作不存在 + .orElseThrow(() -> new NotFoundException("订单不存在: " + id)); + } + + Map sumByStatus(String tenant) { // Stream 分组汇总;金额用 BigDecimal,不用 double + return repo.findByTenant(tenant).stream() + .collect(Collectors.groupingBy(Order::status, + Collectors.reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add))); + } + } + + public static void main(String[] args) throws Exception { + var repo = new InMemoryOrderRepository(List.of( + new Order("A001", "t1", "paid", new BigDecimal("19.90")), + new Order("A002", "t1", "paid", new BigDecimal("0.10")), + new Order("A003", "t1", "refunded", new BigDecimal("5.00")), + new Order("B001", "t2", "paid", new BigDecimal("99.00")))); + var service = new OrderService(repo); + + check(service.get("t1", "A001").amount().equals(new BigDecimal("19.90")), "查询订单"); + try { + service.get("t1", "B001"); + check(false, "跨租户应当查不到"); + } catch (NotFoundException e) { + System.out.println("跨租户查询被拒绝: " + e.getMessage()); + } + var sums = service.sumByStatus("t1"); + check(sums.get("paid").equals(new BigDecimal("20.00")), "BigDecimal 精确求和"); + System.out.println("t1 按状态汇总: " + new java.util.TreeMap<>(sums)); + System.out.println("用 double 计算金额的问题: 0.1 + 0.2 = " + (0.1 + 0.2)); + + // 并发:虚拟线程(JDK 21 正式特性)+ 原子计数,类比 Go 的 goroutine + var counter = new AtomicInteger(); + try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { + for (int i = 0; i < 1000; i++) pool.submit(counter::incrementAndGet); + } // try-with-resources:等待全部任务完成 + check(counter.get() == 1000, "虚拟线程并发计数"); + System.out.println("1000 个虚拟线程计数结果: " + counter.get()); + System.out.println("全部检查通过"); + } + + static void check(boolean ok, String name) { + if (!ok) throw new AssertionError("检查失败: " + name); + } +} diff --git a/topics/backend/java-spring/index.md b/topics/backend/java-spring/index.md index 2604f6e..22150cb 100644 --- a/topics/backend/java-spring/index.md +++ b/topics/backend/java-spring/index.md @@ -4,12 +4,32 @@ title: Java / Spring Boot(了解级) type: index domain: backend stack: [java-spring] -tags: [] +tags: [Java, Spring Boot, 了解级] week: [14] --- # Java / Spring Boot(了解级) -了解级:全栈岗位常见要求,能读懂代码、知道分层和常用注解。 +能读懂、能对接、面试能聊概念。BOSS直聘 2026-10-23 的调研中有岗位要求“Java / Go / Python 其中之一”或需要对接已有 Java 系统,不作为主线投入。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [Java / Spring Boot(了解级):读懂项目结构、IoC 与依赖注入、分层、事务注解和与 Go / Python 的对照](01-java-spring-awareness.md) | 基础 | 2 小时 | + +## 企业场景 + +- [Python Agent 调用公司已有的 Java 业务系统:读接口、封装工具、错误映射和联调](scenarios/call-java-service.md) + +## 面试题 + +- [Java / Spring Boot(了解级)面试题](interview.md) + +## 示例代码 + +- [examples/java-basics](examples/java-basics/):只用 JDK 的对照示例(`java OrderDemo.java`,JDK 21+;已运行通过)。Spring 代码为示意,未编译。 + +## 在 16 周计划中 + +- [第 14 周:后端扩展与部署](../../../plan/week14-backend-extend-deploy.md) diff --git a/topics/backend/java-spring/interview.md b/topics/backend/java-spring/interview.md new file mode 100644 index 0000000..cbc0c56 --- /dev/null +++ b/topics/backend/java-spring/interview.md @@ -0,0 +1,67 @@ +--- +id: backend.java-spring.interview +title: Java / Spring Boot(了解级)面试题 +type: interview +domain: backend +stack: [java-spring] +tags: [Java, Spring Boot, IoC, 事务, 面试] +level: basic +related: [backend.java-spring.awareness] +week: [14, 16] +--- + +# Java / Spring Boot(了解级)面试题 + +## 基础 + +### 【基础】什么是 IoC 和依赖注入? + +**参考答案**:控制反转:对象的创建和依赖组装由容器负责,而不是对象自己 `new`;依赖注入是实现方式,通过构造器、setter 或字段注入。好处是解耦、便于替换实现和测试。 + +**追问**:为什么推荐构造器注入?→ 依赖可以声明为 final、不会出现未初始化的状态、单元测试时直接传入假实现。 + +### 【基础】Spring Boot 项目通常怎么分层? + +**参考答案**:Controller 处理 HTTP 和参数校验,Service 写业务逻辑和事务边界,Repository 负责数据访问;DTO 与实体分开,全局异常处理统一返回格式。 + +**追问**:和你写的 Go 项目怎么对应?→ handler / service / repository 三层,思路一致,只是 Go 中依赖在 main 中手动组装。 + +### 【基础】Java 中金额用什么类型? + +**参考答案**:`BigDecimal`(或以分为单位的 `long`),不能用 `double`,二进制浮点数无法精确表示 0.1 等小数。 + +**追问**:`BigDecimal` 比较相等要注意什么?→ `equals` 会比较精度(`2.0` 和 `2.00` 不相等),数值比较用 `compareTo`。 + +## 进阶 + +### 【进阶】`@Transactional` 在什么情况下会失效? + +**参考答案**:同类内部方法调用(不经过代理)、方法不是 public(取决于代理方式和版本)、异常被捕获没有抛出、抛出受检异常而未配置 `rollbackFor`、数据库引擎不支持事务。 + +**追问**:Go 中怎么做类似的事?→ 显式开启事务并把 `tx` 传入各个仓储方法,或者封装一个 `WithTx(func(tx) error)` 帮助函数。 + +### 【进阶】虚拟线程是什么?和 goroutine 有什么相似之处? + +**参考答案**:JDK 21 正式提供的轻量级线程,由 JVM 调度到少量平台线程上,阻塞 I/O 时让出载体线程,可以创建大量并发任务;与 goroutine 一样让“同步写法 + 高并发”成为可能。 + +**追问**:有什么需要注意?→ 不适合 CPU 密集任务;某些同步块或本地调用会“钉住”载体线程(不同 JDK 版本有改进,以官方说明为准);不要池化虚拟线程。 + +### 【进阶】Java 团队说接口“已经幂等”,你怎么验证? + +**参考答案**:看实现:是否有唯一约束或幂等键表、状态机是否只允许合法转换;做测试:同一请求并发和重复发送,检查数据只产生一条、返回结果一致。 + +**追问**:幂等键应该谁生成?→ 调用方生成并在重试时复用;服务端存储键和结果,设定过期时间。 + +## 场景题 + +### 【场景题】你的 Python Agent 需要调用公司的 Java 订单系统,怎么做? + +**参考答案**:读 Controller / DTO 明确契约;Python 侧封装工具,透传用户身份;错误码映射为可重试、告知用户、转人工;非幂等接口推动增加幂等键;契约测试和 request id 联调。详见[调用 Java 服务场景](scenarios/call-java-service.md)。 + +**追问**:对方接口很慢怎么办?→ 设置超时,查询类接口加缓存,长操作改为异步(提交后轮询或回调),Agent 告诉用户“已提交,稍后通知”。 + +### 【场景题】面试官问“你会 Java 吗”,你怎么回答? + +**参考答案**:如实说明:主语言是 Go / Python,能读懂 Spring Boot 项目的结构和代码,理解 IoC、分层、事务等核心概念,做过(或能做)与 Java 服务的对接;如果岗位需要,可以快速上手。不夸大。 + +**追问**:举个例子说明你读懂过 Java 代码?→ 讲清一次具体的对接:从哪个 Controller 读出了什么契约、发现了什么问题(例如非幂等)、怎么解决。 diff --git a/topics/backend/java-spring/scenarios/call-java-service.md b/topics/backend/java-spring/scenarios/call-java-service.md new file mode 100644 index 0000000..ae776ec --- /dev/null +++ b/topics/backend/java-spring/scenarios/call-java-service.md @@ -0,0 +1,82 @@ +--- +id: backend.java-spring.scenario.call-java-service +title: Python Agent 调用公司已有的 Java 业务系统:读接口、封装工具、错误映射和联调 +type: scenario +domain: backend +stack: [java-spring, function-calling, fastapi] +tags: [系统对接, 工具封装, 错误码映射, OpenAPI, 联调, 幂等] +level: intermediate +prerequisites: [backend.java-spring.awareness, ai.function-calling.tool-loop] +est_hours: 2 +related: [backend.java-spring.interview, ai.function-calling.scenario.safe-business-tools] +week: [14] +--- + +# Python Agent 调用公司已有的 Java 业务系统:读接口、封装工具、错误映射和联调 + +## 业务背景与问题 + +公司的订单、售后系统是多年的 Spring Boot 服务。新做的 AI 客服 Agent(Python)需要“查订单、查物流、发起退款申请”。问题: + +1. 接口文档过期,字段含义要读代码才知道; +2. Java 服务的错误码体系复杂,Agent 不知道哪些错误可以重试、哪些要告诉用户; +3. 退款接口不是幂等的,Agent 重试导致重复申请; +4. 联调时双方互相指责。 + +## 约束 + +- 不能大改 Java 服务,只能小范围加接口或参数。 +- Agent 不能拥有超过用户本人的权限。 + +## 方案设计 + +1. **读接口**:从 Controller 和 DTO 读出真实的字段、校验规则、错误码;如果服务集成了 OpenAPI(springdoc 一类工具),导出规范生成 Python 客户端;否则自己写 Pydantic 模型。 +2. **封装工具**:每个业务能力一个工具,入参严格校验;工具层负责鉴权透传(用户令牌或服务账号 + 用户 id)、超时、重试(只对查询类和幂等接口)。 +3. **错误映射**:把 Java 错误码分为三类——可重试(超时、限流)、需要告诉用户(订单不存在、超过退款期限)、需要转人工(系统异常);返回给模型的是结构化的可读信息,而不是堆栈。 +4. **幂等**:和 Java 团队约定退款接口增加 `Idempotency-Key` 请求头(服务端用唯一约束实现),Agent 在一次会话中对同一操作使用同一个 key。 +5. **联调**:先写契约测试(请求 / 响应样例),双方各自对着样例开发;用 request id 串起两边日志。 + +## 关键代码 + +```python +class JavaErrorKind(str, Enum): + RETRYABLE = "retryable" + USER_FACING = "user_facing" + HANDOFF = "handoff" + +ERROR_MAP = { + "ORDER_NOT_FOUND": (JavaErrorKind.USER_FACING, "没有找到这个订单,请核对订单号"), + "REFUND_EXPIRED": (JavaErrorKind.USER_FACING, "已超过可退款期限"), + "RATE_LIMITED": (JavaErrorKind.RETRYABLE, "系统繁忙"), +} + +def call_refund(client: httpx.Client, user: User, order_id: str, reason: str, idem_key: str) -> dict: + r = client.post("/api/refunds", json={"orderId": order_id, "reason": reason}, + headers={"Authorization": f"Bearer {user.token}", "Idempotency-Key": idem_key}, timeout=5) + if r.is_success: + return {"ok": True, "refund_id": r.json()["data"]["refundId"]} + code = r.json().get("code", "UNKNOWN") + kind, msg = ERROR_MAP.get(code, (JavaErrorKind.HANDOFF, "系统异常,已为你转人工")) + return {"ok": False, "kind": kind, "message": msg} # 给模型的是结构化结果,不是堆栈 +``` + +工具调用安全的完整讨论见[安全的业务工具](../../../ai/function-calling/scenarios/safe-business-tools.md)。 + +## 取舍 + +| 选择 | 优点 | 缺点 | +| --- | --- | --- | +| **Python 工具层直接调用 Java 接口(本方案)** | 不改动 Java 服务主体 | 需要维护错误映射和模型定义 | +| Java 团队提供专门给 Agent 的聚合接口 | 语义更清晰 | 依赖对方排期 | +| 用 Spring AI 在 Java 侧实现 Agent | 技术栈统一 | 团队 AI 生态以 Python 为主 | + +## 易错点 + +- Agent 使用高权限服务账号,绕过了用户级权限。 +- 对非幂等接口自动重试。 +- 把 Java 的异常堆栈直接交给模型,既泄露内部信息又让模型困惑。 +- 金额字段在 JSON 中是字符串还是数字没有约定,精度丢失。 + +## 面试怎么讲 + +> “我不是 Java 开发,但能读懂 Spring Boot 项目:从 Controller 和 DTO 读出真实的接口契约。对接时我在 Python 侧封装工具,鉴权透传用户身份;把 Java 的错误码分成可重试、告知用户、转人工三类,给模型结构化结果;和 Java 团队约定退款接口支持幂等键。联调前先写契约样例,用 request id 串联日志。” diff --git a/topics/devops/k8s/01-core-objects.md b/topics/devops/k8s/01-core-objects.md new file mode 100644 index 0000000..37efede --- /dev/null +++ b/topics/devops/k8s/01-core-objects.md @@ -0,0 +1,118 @@ +--- +id: devops.k8s.core-objects +title: Kubernetes 基础:Pod / Deployment / Service / Ingress、配置与密钥、探针、滚动更新和扩缩容 +type: lesson +domain: devops +stack: [k8s] +tags: [Kubernetes, Pod, Deployment, Service, Ingress, ConfigMap, Secret, 探针, 滚动更新, HPA, PDB, kubectl] +level: basic +prerequisites: [devops.docker.image-dockerfile] +est_hours: 3 +related: [devops.k8s.scenario.agent-rollout, devops.nginx.scenario.ai-gateway-sse-canary, devops.k8s.interview] +week: [14] +order: 1 +--- + +# Kubernetes 基础:Pod / Deployment / Service / Ingress、配置与密钥、探针、滚动更新和扩缩容 + +> 定位:**能读懂和修改清单、能用 kubectl 排查**,集群搭建和运维不在范围内。BOSS直聘 2026-10-23 的调研中有岗位把 K8s 列为要求或加分项。示例清单通过 kubeconform 结构校验(6 个资源全部有效)和团队规范测试(5 个测试通过),**没有在真实集群中部署**。以官方文档为准:https://kubernetes.io/zh-cn/docs/home/ 。 + +## 解释说明 + +### 核心对象 + +| 对象 | 作用 | 类比 | +| --- | --- | --- | +| **Pod** | 最小调度单位,一个或几个容器共享网络和存储 | 一组 `docker run` | +| **Deployment** | 声明“要几个什么样的 Pod”,负责滚动更新和回滚 | Compose 的 service + 副本数 | +| **Service** | 给一组 Pod 一个稳定的虚拟 IP / DNS 名,做负载均衡 | 内部的负载均衡器 | +| **Ingress** | 集群入口的 HTTP 路由(域名、路径、TLS) | Nginx 反向代理配置 | +| **ConfigMap / Secret** | 配置 / 敏感信息,以环境变量或文件注入 | `.env` | +| **HPA** | 按指标自动调整副本数 | — | +| **PDB** | 主动驱逐(节点维护)时至少保留多少可用 Pod | — | +| Namespace | 资源隔离和配额 | — | + +**声明式**:你提交“期望状态”(YAML),控制器不断把“实际状态”调整为期望状态。Pod 挂了会被重建,而不是由你手动重启。 + +**标签和选择器**:Deployment、Service 通过标签选择 Pod,标签不一致是新手最常见的问题(Service 没有后端)。 + +### 三种探针 + +| 探针 | 失败的后果 | 检查什么 | +| --- | --- | --- | +| startupProbe | 启动阶段超时则重启 | 启动是否完成(给慢启动的应用留时间) | +| readinessProbe | 从 Service 的后端中摘除,不重启 | 能否处理请求(可检查关键依赖) | +| livenessProbe | 重启容器 | 进程是否卡死(**不要检查外部依赖**) | + +### 滚动更新和优雅退出 + +- `maxSurge` / `maxUnavailable` 控制更新节奏;`kubectl rollout undo` 回滚。 +- Pod 终止流程:从 Service 端点中移除 + 执行 `preStop` → 发送 SIGTERM → 等待 `terminationGracePeriodSeconds` → SIGKILL。应用要处理 SIGTERM:停止接收新请求、完成进行中的请求(AI 流式回答可能持续几十秒)。 + +### 常用排查命令 + +```bash +kubectl get pods -l app.kubernetes.io/name=agent-api -o wide +kubectl describe pod # 看事件:拉取镜像失败、探针失败、OOMKilled、调度失败 +kubectl logs --previous # 看上一次崩溃前的日志 +kubectl get endpoints agent-api # Service 是否有后端 +kubectl rollout status deploy/agent-api +kubectl rollout undo deploy/agent-api +``` + +## 案例 + +把 AI Agent API 部署到公司的 K8s 集群:3 副本、零不可用的滚动更新、启动慢时用 startupProbe 保护、密钥由 Secret 注入、Ingress 关闭缓冲以支持 SSE、HPA 按负载扩到 10 个、PDB 保证节点维护时至少 2 个可用。 + +## 代码示例 + +清单:[examples/agent-api](examples/agent-api/)(`deployment.yaml`、`service.yaml`、`ingress.yaml`、`configmap.yaml`、`hpa.yaml`),校验结果: + +```text +$ kubeconform -strict -summary *.yaml +Summary: 6 resources found in 5 files - Valid: 6, Invalid: 0, Errors: 0, Skipped: 0 +$ pytest -q test_manifests.py +5 passed +``` + +Deployment 关键部分: + +```yaml +strategy: + type: RollingUpdate + rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } +template: + spec: + terminationGracePeriodSeconds: 90 + containers: + - name: api + image: registry.example.com/agent-api:1.4.2 + env: + - name: LLM_API_KEY + valueFrom: { secretKeyRef: { name: agent-api-secrets, key: llm-api-key } } + resources: + requests: { cpu: "250m", memory: "256Mi" } + limits: { memory: "512Mi" } + startupProbe: { httpGet: { path: /healthz, port: http }, periodSeconds: 5, failureThreshold: 24 } + readinessProbe: { httpGet: { path: /readyz, port: http }, periodSeconds: 5 } + livenessProbe: { httpGet: { path: /healthz, port: http }, periodSeconds: 10 } +``` + +团队规范测试([test_manifests.py](examples/agent-api/test_manifests.py))检查:选择器与标签一致、镜像固定版本、有资源和探针、密钥不明文、PDB 小于最小副本数、仓库中没有 Secret 对象。 + +## 易错点 + +- Service 选择器和 Pod 标签不一致。 +- 存活探针检查数据库,数据库抖动时所有 Pod 被重启,雪上加霜。 +- 没有 startupProbe,慢启动的应用被存活探针反复杀掉(CrashLoopBackOff)。 +- 镜像用 `latest`,无法确定线上跑的是哪个版本,回滚也不可靠。 +- 内存没有限制或限制过小(OOMKilled)。 +- 应用不处理 SIGTERM,滚动更新时正在进行的流式回答被切断。 +- 把 Secret 的 YAML(只是 base64,不是加密)提交到仓库。 + +## 面试常问 + +- Pod、Deployment、Service 的关系? +- 三种探针的区别? +- 滚动更新怎么做到不中断服务? +- Pod 一直 CrashLoopBackOff 怎么排查? diff --git a/topics/devops/k8s/examples/agent-api/configmap.yaml b/topics/devops/k8s/examples/agent-api/configmap.yaml new file mode 100644 index 0000000..8d9d1b1 --- /dev/null +++ b/topics/devops/k8s/examples/agent-api/configmap.yaml @@ -0,0 +1,11 @@ +# 非敏感配置。敏感信息(模型 API Key、数据库密码)放 Secret,并且不要提交到仓库 +apiVersion: v1 +kind: ConfigMap +metadata: + name: agent-api-config + labels: + app.kubernetes.io/name: agent-api +data: + LOG_LEVEL: "info" + MODEL_TIMEOUT_SECONDS: "60" + REDIS_URL: "redis://redis.default.svc.cluster.local:6379/0" diff --git a/topics/devops/k8s/examples/agent-api/deployment.yaml b/topics/devops/k8s/examples/agent-api/deployment.yaml new file mode 100644 index 0000000..490049b --- /dev/null +++ b/topics/devops/k8s/examples/agent-api/deployment.yaml @@ -0,0 +1,75 @@ +# AI Agent API 的 Deployment:滚动更新、三种探针、资源限制、非 root 运行、优雅退出 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agent-api + labels: + app.kubernetes.io/name: agent-api +spec: + replicas: 3 + revisionHistoryLimit: 5 + selector: + matchLabels: + app.kubernetes.io/name: agent-api + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 # 更新时最多多出 1 个 Pod + maxUnavailable: 0 # 更新过程中可用 Pod 数不减少 + template: + metadata: + labels: + app.kubernetes.io/name: agent-api + spec: + terminationGracePeriodSeconds: 90 # 留时间让正在进行的流式回答结束 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + containers: + - name: api + image: registry.example.com/agent-api:1.4.2 # 使用固定版本,不用 latest + ports: + - name: http + containerPort: 8000 + envFrom: + - configMapRef: + name: agent-api-config + env: + - name: LLM_API_KEY + valueFrom: + secretKeyRef: + name: agent-api-secrets # 由运维或密钥管理系统创建,不在仓库中 + key: llm-api-key + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + memory: "512Mi" # 内存设上限防止拖垮节点;CPU 不设上限避免被限流(按团队规范) + startupProbe: # 启动慢(加载模型配置、预热连接)时,避免被存活探针误杀 + httpGet: + path: /healthz + port: http + periodSeconds: 5 + failureThreshold: 24 + readinessProbe: # 未就绪时不接收流量(例如依赖的 Redis 不可用) + httpGet: + path: /readyz + port: http + periodSeconds: 5 + failureThreshold: 3 + livenessProbe: # 进程卡死时重启;不要检查外部依赖,否则依赖故障会引发全体重启 + httpGet: + path: /healthz + port: http + periodSeconds: 10 + failureThreshold: 3 + lifecycle: + preStop: # 先等负载均衡摘除,再收到 SIGTERM。sleep 动作需要较新的 Kubernetes(1.30+,以官方文档为准),旧版本用 exec sleep + sleep: + seconds: 5 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] diff --git a/topics/devops/k8s/examples/agent-api/hpa.yaml b/topics/devops/k8s/examples/agent-api/hpa.yaml new file mode 100644 index 0000000..2f72136 --- /dev/null +++ b/topics/devops/k8s/examples/agent-api/hpa.yaml @@ -0,0 +1,33 @@ +# 按 CPU 自动扩缩容。AI 服务多为 I/O 等待,CPU 不一定能反映负载,可改用自定义指标(并发请求数、队列长度) +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: agent-api +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: agent-api + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 # 缩容慢一点,避免抖动 +--- +# 节点维护(驱逐)时至少保留 2 个可用 Pod +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: agent-api +spec: + minAvailable: 2 + selector: + matchLabels: + app.kubernetes.io/name: agent-api diff --git a/topics/devops/k8s/examples/agent-api/ingress.yaml b/topics/devops/k8s/examples/agent-api/ingress.yaml new file mode 100644 index 0000000..1e90ce5 --- /dev/null +++ b/topics/devops/k8s/examples/agent-api/ingress.yaml @@ -0,0 +1,24 @@ +# 以 ingress-nginx 为例:SSE 流式接口要关闭缓冲、加长读超时。注解名称以所用 Ingress 控制器的文档为准 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: agent-api + annotations: + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-read-timeout: "300" +spec: + ingressClassName: nginx + tls: + - hosts: ["agent.example.com"] + secretName: agent-example-tls + rules: + - host: agent.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: agent-api + port: + name: http diff --git a/topics/devops/k8s/examples/agent-api/service.yaml b/topics/devops/k8s/examples/agent-api/service.yaml new file mode 100644 index 0000000..5a34edc --- /dev/null +++ b/topics/devops/k8s/examples/agent-api/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: agent-api + labels: + app.kubernetes.io/name: agent-api +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: agent-api # 必须和 Pod 的标签一致,否则 Service 没有后端 + ports: + - name: http + port: 80 + targetPort: http diff --git a/topics/devops/k8s/examples/agent-api/test_manifests.py b/topics/devops/k8s/examples/agent-api/test_manifests.py new file mode 100644 index 0000000..014dde0 --- /dev/null +++ b/topics/devops/k8s/examples/agent-api/test_manifests.py @@ -0,0 +1,63 @@ +# requires: yaml +"""对清单做团队规范检查(kubeconform 只校验结构,这里检查“写对了但不合规范”的问题)。 + +运行:pytest -q test_manifests.py +结构校验(可选):kubeconform -strict -summary *.yaml +""" +from pathlib import Path + +import yaml + +HERE = Path(__file__).parent + + +def load_all() -> list[dict]: + docs = [] + for f in sorted(HERE.glob("*.yaml")): + docs += [d for d in yaml.safe_load_all(f.read_text(encoding="utf-8")) if d] + return docs + + +DOCS = load_all() + + +def by_kind(kind: str) -> list[dict]: + return [d for d in DOCS if d["kind"] == kind] + + +def test_selector_matches_pod_labels(): + for dep in by_kind("Deployment"): + sel = dep["spec"]["selector"]["matchLabels"] + labels = dep["spec"]["template"]["metadata"]["labels"] + assert sel.items() <= labels.items() + for svc in by_kind("Service"): + assert svc["spec"]["selector"].items() <= labels.items(), "Service 选择器和 Pod 标签不一致" + + +def test_containers_follow_rules(): + for dep in by_kind("Deployment"): + for c in dep["spec"]["template"]["spec"]["containers"]: + image = c["image"] + assert ":" in image.rsplit("/", 1)[-1] and not image.endswith(":latest"), "镜像必须固定版本" + assert "requests" in c["resources"] and "memory" in c["resources"].get("limits", {}) + for probe in ("readinessProbe", "livenessProbe"): + assert probe in c, f"缺少 {probe}" + assert c["securityContext"]["allowPrivilegeEscalation"] is False + for e in c.get("env", []): + assert "value" not in e or "KEY" not in e["name"].upper(), "密钥不能明文写在清单中" + + +def test_rolling_update_keeps_capacity(): + for dep in by_kind("Deployment"): + ru = dep["spec"]["strategy"]["rollingUpdate"] + assert ru["maxUnavailable"] == 0 + + +def test_pdb_lower_than_min_replicas(): + hpa_min = min(h["spec"]["minReplicas"] for h in by_kind("HorizontalPodAutoscaler")) + for pdb in by_kind("PodDisruptionBudget"): + assert pdb["spec"]["minAvailable"] < hpa_min, "PDB 要求的可用数不小于副本数会导致节点无法驱逐" + + +def test_no_secret_objects_in_repo(): + assert not by_kind("Secret"), "Secret 不应提交到仓库" diff --git a/topics/devops/k8s/index.md b/topics/devops/k8s/index.md index eefe3ad..95d8783 100644 --- a/topics/devops/k8s/index.md +++ b/topics/devops/k8s/index.md @@ -4,12 +4,37 @@ title: Kubernetes 基础 type: index domain: devops stack: [k8s] -tags: [] +tags: [Kubernetes, 部署, 探针, 滚动更新] week: [14] --- # Kubernetes 基础 -Pod、Deployment、Service、Ingress、配置与密钥、探针、滚动发布。 +能读懂、修改清单,能用 kubectl 排查;理解探针、滚动更新和优雅退出(AI 流式服务的关键)。BOSS直聘 2026-10-23 的调研中有岗位把 K8s 列为要求或加分项。 -> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。 +## 学习路线 + +| 顺序 | 课程 | 级别 | 预计 | +| --- | --- | --- | --- | +| 1 | [Kubernetes 基础:Pod / Deployment / Service / Ingress、配置与密钥、探针、滚动更新和扩缩容](01-core-objects.md) | 基础 | 3 小时 | + +## 企业场景 + +- [AI Agent 服务上 K8s:发布时流式回答不中断、慢启动、密钥管理和按负载扩容](scenarios/agent-rollout.md) + +## 面试题 + +- [Kubernetes 基础面试题](interview.md) + +## 示例代码 + +- [examples/agent-api](examples/agent-api/):Deployment / Service / Ingress / ConfigMap / HPA / PDB 清单(kubeconform 校验 6 个资源有效;规范测试 5 个通过;未在真实集群部署) + +## 在 16 周计划中 + +- [第 14 周:后端扩展与部署](../../../plan/week14-backend-extend-deploy.md) + +## 官方资料 + +- https://kubernetes.io/zh-cn/docs/home/ +- kubeconform:https://github.com/yannh/kubeconform diff --git a/topics/devops/k8s/interview.md b/topics/devops/k8s/interview.md new file mode 100644 index 0000000..9740dac --- /dev/null +++ b/topics/devops/k8s/interview.md @@ -0,0 +1,67 @@ +--- +id: devops.k8s.interview +title: Kubernetes 基础面试题 +type: interview +domain: devops +stack: [k8s] +tags: [Kubernetes, 探针, 滚动更新, 排查, 面试] +level: basic +related: [devops.k8s.core-objects] +week: [14, 16] +--- + +# Kubernetes 基础面试题 + +## 基础 + +### 【基础】Pod、Deployment、Service 是什么关系? + +**参考答案**:Pod 运行容器;Deployment 管理一组相同的 Pod(副本数、滚动更新、回滚),通过 ReplicaSet 实现;Service 通过标签选择器找到这些 Pod,提供稳定的访问地址和负载均衡。 + +**追问**:Pod 的 IP 能直接用吗?→ 不建议,Pod 重建后 IP 会变,应通过 Service 名访问。 + +### 【基础】readinessProbe 和 livenessProbe 的区别? + +**参考答案**:就绪探针失败时 Pod 从 Service 端点中移除但不重启,用于“暂时不能处理请求”;存活探针失败时重启容器,用于“进程卡死”。慢启动应用再加 startupProbe。 + +**追问**:存活探针为什么不能检查数据库?→ 数据库故障时所有 Pod 会被同时重启,问题不能解决反而扩大。 + +### 【基础】ConfigMap 和 Secret 的区别? + +**参考答案**:都用于注入配置;Secret 用于敏感信息,有单独的权限控制,可以配置静态加密,但其 YAML 中的数据只是 base64 编码,不能提交到仓库。 + +**追问**:生产环境密钥怎么管理?→ 使用外部密钥管理系统同步到集群,或平台提供的加密方案;做好轮换和访问审计。 + +## 进阶 + +### 【进阶】滚动更新如何做到不中断服务? + +**参考答案**:`maxUnavailable: 0` 保证可用数量;就绪探针确保新 Pod 准备好才接流量;旧 Pod 优雅退出:先摘除端点(preStop 等待),应用处理 SIGTERM 完成进行中的请求,宽限期足够长。 + +**追问**:发布后发现问题怎么办?→ `kubectl rollout undo` 回滚到上个版本;更好的做法是灰度发布(金丝雀)观察指标后再全量。 + +### 【进阶】Pod 一直 CrashLoopBackOff,怎么排查? + +**参考答案**:`kubectl describe pod` 看事件和退出码;`kubectl logs --previous` 看崩溃前日志;常见原因:配置或密钥缺失、启动命令错误、依赖连不上、存活探针过早失败、OOMKilled(退出码 137)。 + +**追问**:镜像拉取失败显示什么状态?→ `ErrImagePull` / `ImagePullBackOff`,检查镜像名、标签和拉取凭证。 + +### 【进阶】requests 和 limits 有什么区别? + +**参考答案**:requests 是调度依据(保证分配的资源),limits 是上限:超过内存上限会被 OOMKill,超过 CPU 上限会被限流。两者关系决定 QoS 等级。 + +**追问**:CPU 一定要设 limits 吗?→ 有争议:设置后可能出现限流导致延迟抖动;很多团队只设 CPU requests 和内存 limits,按团队规范执行。 + +## 场景题 + +### 【场景题】AI 服务每次发布,用户的流式回答都会中断,怎么解决? + +**参考答案**:优雅退出:就绪探针在退出时立即失败、停止接新请求、等待流结束;preStop 留出摘除时间;宽限期大于最长回答时间;`maxUnavailable: 0`;Ingress 关闭缓冲。前端也可以实现断线提示和重试。详见 [Agent 上 K8s 场景](scenarios/agent-rollout.md)。 + +**追问**:怎么验证改好了?→ 在测试环境持续发起长回答请求的同时执行滚动更新,统计中断数为 0。 + +### 【场景题】HPA 配置了 CPU 70% 扩容,但高峰期 AI 服务很慢却没有扩容,为什么? + +**参考答案**:AI 服务大部分时间在等待模型响应(I/O),CPU 不高;应该用反映负载的指标:进行中请求数、队列长度、首 token 延迟,通过自定义指标扩容;同时检查是否被上游模型的限流卡住(扩容也没用)。 + +**追问**:扩容太慢怎么办?→ 提高最小副本、缩短启动时间(镜像体积、预热)、根据可预期的高峰提前扩容。 diff --git a/topics/devops/k8s/scenarios/agent-rollout.md b/topics/devops/k8s/scenarios/agent-rollout.md new file mode 100644 index 0000000..c807982 --- /dev/null +++ b/topics/devops/k8s/scenarios/agent-rollout.md @@ -0,0 +1,89 @@ +--- +id: devops.k8s.scenario.agent-rollout +title: AI Agent 服务上 K8s:发布时流式回答不中断、慢启动、密钥管理和按负载扩容 +type: scenario +domain: devops +stack: [k8s, docker, nginx, cicd] +tags: [滚动更新, 优雅退出, SSE, 探针, HPA, Secret, 回滚] +level: intermediate +prerequisites: [devops.k8s.core-objects, devops.docker.image-dockerfile] +est_hours: 2.5 +related: [devops.k8s.interview, devops.cicd.release-rollback] +week: [14] +--- + +# AI Agent 服务上 K8s:发布时流式回答不中断、慢启动、密钥管理和按负载扩容 + +## 业务背景与问题 + +Agent API 从虚拟机迁移到公司的 K8s 集群后: + +1. 每次发布,正在进行中的流式回答被切断,用户看到“网络错误”; +2. 新版本启动需要预热(加载提示词模板、建立连接池),偶尔被重启循环; +3. 有人把模型 API Key 直接写在 Deployment 的环境变量里提交到了仓库; +4. 高峰期响应变慢,但 CPU 使用率并不高,HPA 没有扩容。 + +## 约束 + +- 集群由平台团队维护,应用团队只能修改自己的清单。 +- 回答可能持续 60 秒以上。 + +## 方案设计 + +1. **优雅退出**: + - 应用收到 SIGTERM 后:就绪检查立即返回失败、不再接受新请求、等待进行中的流结束(最长 80 秒)后退出; + - `preStop` 短暂等待,给 Ingress / Service 摘除端点的时间; + - `terminationGracePeriodSeconds: 90` 大于最长回答时间; + - 滚动更新 `maxUnavailable: 0`。 +2. **慢启动**:startupProbe 给足启动时间;就绪探针在预热完成后才返回成功。 +3. **密钥**:轮换已泄露的 Key;清单中只引用 Secret;Secret 由密钥管理系统同步(或平台提供的方案);CI 中加密钥扫描。 +4. **扩容指标**:AI 服务主要在等待模型返回,CPU 低但并发连接数高。改用“每个 Pod 的进行中请求数”作为自定义指标(需要平台支持指标适配器),或者先按经验提高副本下限。 +5. **发布与回滚**:镜像固定版本;CI 中先 `kubeconform` + 规范测试;发布后观察错误率和首 token 延迟,异常时 `kubectl rollout undo`。 + +## 关键代码 + +FastAPI 应用的优雅退出(示意): + +```python +import asyncio, signal + +active_streams = 0 +shutting_down = False + +@app.get("/readyz") +async def readyz(): + if shutting_down: + raise HTTPException(503) # 让 K8s 尽快摘除这个 Pod + return {"ok": True} + +async def drain(timeout: float = 80): + global shutting_down + shutting_down = True + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while active_streams > 0 and loop.time() < deadline: + await asyncio.sleep(0.5) # 等待进行中的流式回答结束 +``` + +(Uvicorn 自身也会在收到 SIGTERM 后停止接收新连接并等待请求完成,具体参数如 `--timeout-graceful-shutdown` 以官方文档为准。) + +完整清单:[examples/agent-api](../examples/agent-api/)。SSE 在 Nginx 层的配置见 [AI 网关场景](../../nginx/scenarios/ai-gateway-sse-canary.md)。 + +## 取舍 + +| 选择 | 优点 | 缺点 | +| --- | --- | --- | +| **优雅退出 + 长宽限期(本方案)** | 用户无感知 | 发布时间变长 | +| 前端断线自动续传(从断点继续) | 对任何中断都有效 | 后端要保存生成进度,复杂 | +| 发布时间放在低峰期 | 简单 | 治标不治本 | + +## 易错点 + +- 宽限期比最长回答时间短,SIGKILL 依然切断。 +- 应用作为 shell 脚本的子进程启动,收不到 SIGTERM(使用 exec 形式的启动命令)。 +- 就绪探针在退出阶段仍然返回成功,新请求继续进来。 +- 以为 Secret 是加密的。 + +## 面试怎么讲 + +> “迁移到 K8s 后发布会切断流式回答。我做了优雅退出:收到 SIGTERM 后就绪探针立即失败,停止接新请求,等进行中的流结束;preStop 留出摘除端点的时间,宽限期设为 90 秒,大于最长回答;滚动更新不减少可用副本。慢启动用 startupProbe。泄露的 Key 立即轮换,清单只引用 Secret,CI 加密钥扫描。扩容方面,AI 服务 CPU 不高但并发高,改用进行中请求数作为指标。”