feat(infra): p6 hardening - observability and deploy compose
- 5 NestJS services add /metrics endpoint via app.getHttpAdapter() - prometheus.yml scales to 8 services with rule_files and alertmanager - monitoring compose replaces blackbox with Loki+Promtail - Grafana datasource adds Loki - docker-compose.deploy.yml scales to 11 services - deploy.env.example completes Neo4j/ES/ClickHouse/LLM vars - teacher-bff adds health.controller - CI removes continue-on-error on lint step - teacher-portal lint script changed to eslint src
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -45,14 +45,13 @@ jobs:
|
||||
|
||||
- name: Lint
|
||||
run: pnpm -r run lint
|
||||
continue-on-error: true # P1: ESLint 9 flat config 迁移未完成
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r run typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm -r run test
|
||||
continue-on-error: true # P1: 部分服务无 test 脚本
|
||||
continue-on-error: true # P6: 部分服务无 test 脚本,待补全
|
||||
|
||||
- name: Build
|
||||
run: pnpm -r run build
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -19,6 +19,7 @@
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
@@ -51,7 +51,6 @@ export default function ClassesPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -56,7 +56,6 @@ export default function ExamsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [classId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -52,7 +52,6 @@ export default function GradesPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [examId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -54,7 +54,6 @@ export default function HomeworkPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [classId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
### 1.6 可观测性(OTel + Prometheus + Loki)
|
||||
|
||||
| 场景 | 技术/规则 |
|
||||
| --------------- | -------------------------------------------------------------------------------- |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) |
|
||||
| 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) |
|
||||
| traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 |
|
||||
@@ -95,6 +95,11 @@
|
||||
| Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) |
|
||||
| 采样策略 | P1 全量 trace,P6 引入采样率降低开销 |
|
||||
| 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` |
|
||||
| P6 /metrics 端点 | NestJS 用 `app.getHttpAdapter().get('/metrics', handler)`,不能用 `app.get()`(会被解析为 DI 容器 get) |
|
||||
| P6 prometheus.yml | 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身;rule_files 引用 rules.yml;alerting 关联 alertmanager |
|
||||
| P6 Grafana 数据源 | provisioning/datasources 同时声明 Prometheus(默认)和 Loki(uid=loki) |
|
||||
| P6 Promtail 采集 | docker_sd_configs + relabel_configs 仅采集 `edu-*` 前缀容器日志,避免无关日志 |
|
||||
| P6 service label | static_configs.labels.service 给所有抓取目标打服务标签,告警规则按 service 聚合 |
|
||||
|
||||
### 1.7 微前端 Module Federation
|
||||
|
||||
@@ -328,6 +333,7 @@
|
||||
|
||||
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
|
||||
| ---------- | ---- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**:(1) 可观测性栈完善:5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法);prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联;monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporter;Grafana datasource 新增 Loki;新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展:docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway),每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络;deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts(原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-error(ESLint 9 flat config 已配置完成),test 保留 continue-on-error(部分服务无 test 脚本)。**学到**:NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由;Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀;docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 |
|
||||
| 2026-07-09 | 下午 | 全局 | **P6 硬化:ESLint 9 flat config 配置**:(1) 根目录创建 `eslint.config.js`(ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`(flat config 不需要 --ext)。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证:classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**:ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除,ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 |
|
||||
| 2026-07-09 | 中午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**:(1) msg 服务修复:database.ts 导出 db 常量;env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URL;elasticsearch.ts ES 降级(esClient=null 时 safeIndex/safeSearch 跳过);notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用(try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善:hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争;handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点;config.go 加 DevMode/RedisURL。(3) ai 服务完善:config.py 加 openai_api_key/base_url/dev_mode;新建 llm_client.py(httpx 异步调 OpenAI REST API);main.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true)+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg,/ai → ai 服务。**学到**:gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入;FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*path);LLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
|
||||
| 2026-07-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**:(1) content 服务系统性修复:database.ts 导出 db 常量;env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODE;neo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000;health/lifecycle 改用 Drizzle;global-error.filter 移除 @types/express 依赖;textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型;textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块:chapters(CRUD + 按 textbook 查询)、knowledge-points(CRUD + Neo4j 前置依赖图非阻塞查询)、questions(CRUD + 4 种题型校验)。(3) Gateway 路由扩展:textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库:content-init.sql 4 张表。(5) E2E 验证:POST /textbooks 201 → POST /chapters 201(字段用 order 非 orderNum)→ POST /knowledge-points 201(Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**:Drizzle schema TS 字段名与 DB 列名解耦(order→order_num),API 请求体用 TS 字段名;Neo4j 不可用时必须 driver=null(不设 NEO4J_URL),否则每次请求尝试连接拖慢响应;neo4j-driver safeCreateNode 用 try/catch 非阻塞,MySQL 数据始终先落库。 |
|
||||
|
||||
@@ -27,12 +27,29 @@ JWT_AUDIENCE=next-edu-cloud
|
||||
API_GATEWAY_PORT=8080
|
||||
TEACHER_PORTAL_PORT=3000
|
||||
|
||||
# ============ Kafka(P3 阶段启用,P1 留空)============
|
||||
# ============ Kafka(P3+ 启用,留空则 Outbox publisher 持续重试)============
|
||||
KAFKA_BROKERS=
|
||||
|
||||
# ============ 可观测性(P6 阶段启用,P1 留空)============
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
# ============ 可观测性(P6 启用)============
|
||||
# OTLP collector 端点,留空则服务跳过 trace 上报
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ============ 镜像 tag(由 CI 注入,手动部署时可改)============
|
||||
# IMAGE_TAG=latest ← 默认 latest,CI 通过 export IMAGE_TAG 覆盖
|
||||
# ============ Neo4j(content 服务,留空则降级模式)============
|
||||
NEO4J_URL=
|
||||
NEO4J_PASSWORD=
|
||||
|
||||
# ============ Elasticsearch(msg 服务,留空则降级模式)============
|
||||
ES_URL=
|
||||
|
||||
# ============ ClickHouse(data-ana 服务,留空则降级模式)============
|
||||
CLICKHOUSE_HOST=
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_DATABASE=edu_analytics
|
||||
CLICKHOUSE_USER=
|
||||
CLICKHOUSE_PASSWORD=
|
||||
|
||||
# ============ LLM 配置(ai 服务,留空则降级模式)============
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
name: edu
|
||||
|
||||
services:
|
||||
# ============================================================
|
||||
# 应用服务(10 个:api-gateway + 3 Go/Python + 6 NestJS)
|
||||
# ============================================================
|
||||
api-gateway:
|
||||
build:
|
||||
context: ./repo/services/api-gateway
|
||||
@@ -34,6 +37,11 @@ services:
|
||||
CLASSES_SERVICE_URL: http://classes:3001
|
||||
IAM_SERVICE_URL: http://iam:3002
|
||||
TEACHER_BFF_URL: http://teacher-bff:3003
|
||||
CORE_EDU_SERVICE_URL: http://core-edu:3004
|
||||
CONTENT_SERVICE_URL: http://content:3005
|
||||
DATA_ANA_SERVICE_URL: http://data-ana:3006
|
||||
MSG_SERVICE_URL: http://msg:3007
|
||||
AI_SERVICE_URL: http://ai:3008
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ports:
|
||||
@@ -59,7 +67,6 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3001
|
||||
# 连接服务器已有的 MySQL(容器名 edu-mysql,需在 edu-shared 网络)
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
|
||||
@@ -75,6 +82,214 @@ services:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
iam:
|
||||
build:
|
||||
context: ./repo
|
||||
dockerfile: services/iam/Dockerfile
|
||||
container_name: edu-iam
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3002
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-next-edu-cloud}
|
||||
JWT_AUDIENCE: ${JWT_AUDIENCE:-next-edu-cloud}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3002/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 5
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
teacher-bff:
|
||||
build:
|
||||
context: ./repo
|
||||
dockerfile: services/teacher-bff/Dockerfile
|
||||
container_name: edu-teacher-bff
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3003
|
||||
IAM_SERVICE_URL: http://iam:3002
|
||||
CLASSES_SERVICE_URL: http://classes:3001
|
||||
CORE_EDU_SERVICE_URL: http://core-edu:3004
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
iam:
|
||||
condition: service_healthy
|
||||
classes:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3003/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
core-edu:
|
||||
build:
|
||||
context: ./repo
|
||||
dockerfile: services/core-edu/Dockerfile
|
||||
container_name: edu-core-edu
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3004
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
NODE_ENV: production
|
||||
DEV_MODE: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3004/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 5
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
content:
|
||||
build:
|
||||
context: ./repo
|
||||
dockerfile: services/content/Dockerfile
|
||||
container_name: edu-content
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3005
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
NEO4J_URL: ${NEO4J_URL:-}
|
||||
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-}
|
||||
ES_URL: ${ES_URL:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
NODE_ENV: production
|
||||
DEV_MODE: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3005/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 5
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
msg:
|
||||
build:
|
||||
context: ./repo
|
||||
dockerfile: services/msg/Dockerfile
|
||||
container_name: edu-msg
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3007
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
|
||||
ES_URL: ${ES_URL:-}
|
||||
PUSH_GATEWAY_URL: http://push-gateway:8081
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
NODE_ENV: production
|
||||
DEV_MODE: "false"
|
||||
depends_on:
|
||||
push-gateway:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3007/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 5
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
ai:
|
||||
build:
|
||||
context: ./repo/services/ai
|
||||
dockerfile: Dockerfile
|
||||
container_name: edu-ai
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3008
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
DEV_MODE: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3008/healthz')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
data-ana:
|
||||
build:
|
||||
context: ./repo/services/data-ana
|
||||
dockerfile: Dockerfile
|
||||
container_name: edu-data-ana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3006
|
||||
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-}
|
||||
CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123}
|
||||
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-edu_analytics}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-}
|
||||
OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
DEV_MODE: "false"
|
||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3006/healthz')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
push-gateway:
|
||||
build:
|
||||
context: ./repo/services/push-gateway
|
||||
dockerfile: Dockerfile
|
||||
container_name: edu-push-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PUSH_GATEWAY_PORT: 8081
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
DEV_MODE: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8081/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- edu-net
|
||||
- edu-shared
|
||||
|
||||
teacher-portal:
|
||||
build:
|
||||
context: ./repo
|
||||
|
||||
@@ -14,6 +14,8 @@ volumes:
|
||||
name: edu-alertmanager-data
|
||||
grafana-data:
|
||||
name: edu-grafana-data
|
||||
loki-data:
|
||||
name: edu-loki-data
|
||||
|
||||
services:
|
||||
# ============================================================
|
||||
@@ -104,16 +106,35 @@ services:
|
||||
- edu-network
|
||||
|
||||
# ============================================================
|
||||
# blackbox-exporter - 黑盒探测(HTTP / TCP / ICMP)
|
||||
# Loki - 日志聚合(P6 硬化新增)
|
||||
# ============================================================
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.25.0
|
||||
container_name: edu-blackbox-exporter
|
||||
loki:
|
||||
image: grafana/loki:3.2.1
|
||||
container_name: edu-loki
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
ports:
|
||||
- "9115:9115"
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||
- loki-data:/loki
|
||||
networks:
|
||||
- edu-network
|
||||
|
||||
# ============================================================
|
||||
# Promtail - 日志采集(收集 Docker 容器日志发送到 Loki)
|
||||
# ============================================================
|
||||
promtail:
|
||||
image: grafana/promtail:3.2.1
|
||||
container_name: edu-promtail
|
||||
profiles: ["monitoring"]
|
||||
restart: unless-stopped
|
||||
command: -config.file=/etc/promtail/config.yml
|
||||
volumes:
|
||||
- ./promtail/config.yml:/etc/promtail/config.yml:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- edu-network
|
||||
depends_on:
|
||||
- loki
|
||||
|
||||
@@ -13,3 +13,12 @@ datasources:
|
||||
timeInterval: "15s"
|
||||
httpMethod: POST
|
||||
manageAlerts: false
|
||||
|
||||
- name: Loki
|
||||
type: loki
|
||||
uid: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
editable: false
|
||||
jsonData:
|
||||
maxLines: 1000
|
||||
|
||||
@@ -2,11 +2,93 @@ global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'api-gateway'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:8080']
|
||||
# 告警规则文件
|
||||
rule_files:
|
||||
- /etc/prometheus/rules.yml
|
||||
|
||||
# 告警管理器
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets:
|
||||
- alertmanager:9093
|
||||
|
||||
scrape_configs:
|
||||
# ============================================================
|
||||
# 应用服务(NestJS / FastAPI 暴露 /metrics)
|
||||
# ============================================================
|
||||
- job_name: 'classes-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3001']
|
||||
labels:
|
||||
service: classes
|
||||
|
||||
- job_name: 'iam-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3002']
|
||||
labels:
|
||||
service: iam
|
||||
|
||||
- job_name: 'teacher-bff'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3003']
|
||||
labels:
|
||||
service: teacher-bff
|
||||
|
||||
- job_name: 'core-edu-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3004']
|
||||
labels:
|
||||
service: core-edu
|
||||
|
||||
- job_name: 'content-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3005']
|
||||
labels:
|
||||
service: content
|
||||
|
||||
- job_name: 'data-ana-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3006']
|
||||
labels:
|
||||
service: data-ana
|
||||
|
||||
- job_name: 'msg-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3007']
|
||||
labels:
|
||||
service: msg
|
||||
|
||||
- job_name: 'ai-service'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:3008']
|
||||
labels:
|
||||
service: ai
|
||||
|
||||
# ============================================================
|
||||
# 基础设施(docker-compose.minimal/minimal.override)
|
||||
# ============================================================
|
||||
- job_name: 'mysql'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:9104']
|
||||
labels:
|
||||
service: mysql
|
||||
|
||||
- job_name: 'redis'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:9121']
|
||||
labels:
|
||||
service: redis
|
||||
|
||||
# ============================================================
|
||||
# 监控栈自身
|
||||
# ============================================================
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
- job_name: 'node-exporter'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
labels:
|
||||
service: node-exporter
|
||||
|
||||
28
infra/promtail/config.yml
Normal file
28
infra/promtail/config.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
# 采集 Docker 容器日志
|
||||
- job_name: docker
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
relabel_configs:
|
||||
# 只采集带 edu- 前缀的容器日志
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '/(edu-.*).*'
|
||||
action: keep
|
||||
# 提取容器名作为 label
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '/(.*)'
|
||||
target_label: container_name
|
||||
# 提取镜像名
|
||||
- source_labels: ['__meta_docker_container_log_stream']
|
||||
target_label: stream
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -69,6 +69,9 @@ importers:
|
||||
autoprefixer:
|
||||
specifier: ^10.4.0
|
||||
version: 10.5.2(postcss@8.5.16)
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
version: 9.39.4(jiti@1.21.7)
|
||||
postcss:
|
||||
specifier: ^8.4.0
|
||||
version: 8.5.16
|
||||
@@ -5906,6 +5909,11 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
@@ -8306,6 +8314,47 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.4(jiti@1.21.7):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.5
|
||||
'@eslint/js': 9.39.4
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@humanfs/node': 0.16.8
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.9
|
||||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.7.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
ignore: 5.3.2
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.39.4(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './shared/observability/logger.js';
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, 'Classes service started');
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
app.getHttpAdapter().get("/metrics", async (req, res) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "Classes service started");
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
logger.error({ err }, 'Failed to start classes service');
|
||||
logger.error({ err }, "Failed to start classes service");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from "./config/env.js";
|
||||
import { closeDb } from "./config/database.js";
|
||||
import { closeNeo4j } from "./config/neo4j.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
@@ -18,6 +19,13 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
app.getHttpAdapter().get("/metrics", async (req, res) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "Content service started");
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { registry } from "./shared/observability/metrics.js";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
@@ -14,6 +15,13 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
app.getHttpAdapter().get("/metrics", async (req, res) => {
|
||||
res.set("Content-Type", registry.contentType);
|
||||
res.end(await registry.metrics());
|
||||
});
|
||||
|
||||
// Connect Kafka producer/consumer before starting the outbox publisher.
|
||||
// Non-blocking: if Kafka is unavailable, service still starts; outbox
|
||||
// publisher will retry sends and messages stay pending until Kafka recovers.
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { GlobalErrorFilter } from './shared/errors/global-error.filter.js';
|
||||
import { initTracer, shutdownTracer } from './shared/observability/tracer.js';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './shared/observability/logger.js';
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['log', 'error', 'warn'],
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, 'IAM service started');
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
app.getHttpAdapter().get("/metrics", async (req, res) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "IAM service started");
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
logger.error({ err }, 'Failed to start IAM service');
|
||||
logger.error({ err }, "Failed to start IAM service");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from "./config/env.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
|
||||
import { closeDb } from "./config/database.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
@@ -22,6 +23,13 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
app.getHttpAdapter().get("/metrics", async (req, res) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "Msg service started");
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TeacherModule } from './teacher/teacher.module.js';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TeacherModule } from "./teacher/teacher.module.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [TeacherModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
30
services/teacher-bff/src/health.controller.ts
Normal file
30
services/teacher-bff/src/health.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活
|
||||
* - GET /readyz:readiness,无外部依赖可直接返回 ok
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。
|
||||
*/
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get("healthz")
|
||||
liveness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: "ok",
|
||||
service: "teacher-bff",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("readyz")
|
||||
readiness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: "ok",
|
||||
service: "teacher-bff",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user