知识广场

按学科筛选:计算机科学
清除筛选

«计算机科学» 分类下共 256 篇帖子

ArgoCD:把 K8s 部署做成 GitOps(git 是单一真理)

## 起因 K8s 部署演化: 1. `kubectl apply -f` 手动(不知道现在集群什么状态) 2. CI script `kubectl apply` 自动化(但 git 跟 cluster 不一致仍可能) 3. **GitOps**:git 是 source of truth,controller 自动同步到 cluster ArgoCD 是 GitOps controller。修改 yaml + push git → cluster 自动 apply。 diff / rollback / approval flow 全自动化。 ## 装 ```bash kubectl create namespace argocd kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml # UI kubectl port-forward svc/argocd-server -n argocd 8080:443 # https://localhost:8080 # 默认密码: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d ``` ## 第一个 Application ```yaml # argocd-apps/web.yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: web namespace: argocd spec: project: default source: repoURL: https://github.com/myorg/k8s-manifests targetRevision: main path: apps/web destination: server: https://kubernetes.default.svc namespace: web syncPolicy: automated: prune: true # git 删了 yaml → cluster 也删 selfHeal: true # 手动改 cluster → 自动 revert 回 git 状态 syncOptions: - CreateNamespace=true ``` ```bash kubectl apply -f argocd-apps/web.yaml ``` argocd 会: 1. clone git repo 2. apply `apps/web/*.yaml` 到 `web` namespace 3. 持续监听 git → 有 change pull + apply 4. 持续监听 cluster → drift 自动修复 ## UI ArgoCD UI 显示: - 所有 Application 健康状态 - 每 Application 的 K8s resource 图(service → deploy → pod) - 跟 git desired state 的 diff - sync 历史 + commit message ``` ┌─ Application: web ──────────────────────────────────┐ │ Status: Synced Healthy │ │ Last sync: 2 min ago (commit abc123) │ │ ┌─Service──┐ ┌─Deployment──┐ ┌─Pod ×3─┐ │ │ │ web │ → │ web (3/3) │ → │ ... │ │ │ └──────────┘ └─────────────┘ └────────┘ │ └─────────────────────────────────────────────────────┘ ``` ## kustomize / helm 支持 ```yaml # Application 用 helm spec: source: repoURL: https://github.com/myorg/charts path: charts/web helm: values: | replicaCount: 3 image: tag: v1.2.3 ``` 或者 kustomize: ```yaml spec: source: path: overlays/production kustomize: images: - myorg/web:v1.2.3 ``` ## ApplicationSet(多 cluster / 多 env) ```yaml apiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: name: web-multi-env spec: generators: - list: elements: - cluster: prod url: https://prod.k8s values: values-prod.yaml - cluster: staging url: https://staging.k8s values: values-staging.yaml template: metadata: name: 'web-{{cluster}}' spec: destination: server: '{{url}}' namespace: web source: repoURL: ... helm: valueFiles: - '{{values}}' ``` 一份 template → 给 prod / staging 生成两个 Application。 git 改 → 两个 env 同步。 ## 部署流程 ``` 1. dev 提 PR 改 image tag (v1.2.3 → v1.2.4) 2. PR review + merge to main 3. ArgoCD 检测 main 改动 (default 3 min polling 或 webhook 即时) 4. ArgoCD diff: deployment image change 5. ArgoCD apply: rolling update deployment 6. 健康 check 通过 → Sync success ``` 整个过程 git 是源 + UI 可见。 错了 git revert → ArgoCD 自动 revert cluster。 ## image automation (argocd-image-updater) 不想手改 image tag → image-updater 监听 registry,新 tag 自动 commit git: ```yaml metadata: annotations: argocd-image-updater.argoproj.io/image-list: web=myorg/web argocd-image-updater.argoproj.io/web.update-strategy: semver argocd-image-updater.argoproj.io/write-back-method: git ``` dev 推 image → image-updater 改 git → ArgoCD 同步 cluster。 全流程自动。 ## sync wave ```yaml metadata: annotations: argoproj.io/sync-wave: "1" # 先 apply(如 namespace / CRD) ``` ```yaml metadata: annotations: argoproj.io/sync-wave: "2" # 后 apply(如 deployment) ``` 控制 apply 顺序:CRD 先 → operator 后 → custom resource 最后。 ## sync hook ```yaml metadata: annotations: argoproj.io/hook: PreSync # apply 之前跑 argoproj.io/hook-delete-policy: HookSucceeded spec: template: spec: containers: - name: migrate image: myorg/migrate command: ['./migrate.sh'] ``` PreSync = 部署前跑 db migrate。 PostSync = 部署后跑 smoke test。 SyncFail = 失败时通知。 类似 helm hook。 ## 与 FluxCD 对比 | | ArgoCD | FluxCD | |---|---|---| | 哲学 | UI + CLI first | CLI + GitOps Toolkit | | UI | 强 | 弱(无官方 UI) | | 配置 | Application CRD | Kustomization / HelmRelease | | 多 cluster | ApplicationSet | 不擅长 | | 学习 | 简单 UI 上手 | CLI 思维 | | CNCF | graduated | graduated | ArgoCD 适合 visual ops + 多 cluster。 Flux 适合 git-pure / 自动化 first。 我们用 ArgoCD(UI 让 dev 也能看部署状态,减少 ops 沟通)。 ## 安全 / RBAC ```yaml # AppProject 限制 apiVersion: argoproj.io/v1alpha1 kind: AppProject metadata: name: dev-team spec: sourceRepos: - https://github.com/myorg/dev-manifests.git destinations: - namespace: 'dev-*' server: '*' clusterResourceWhitelist: - group: '' kind: Namespace namespaceResourceWhitelist: - group: 'apps' kind: Deployment ``` dev team 只能从特定 git repo 部署到 dev-* namespace,不能改 cluster-wide 资源。 ## SSO 集成 ArgoCD 接 OIDC(Okta / Google / GitHub)→ 用 SSO 登录 UI + CLI。 ```yaml # argocd-cm ConfigMap data: oidc.config: | name: GitHub issuer: https://github.com/login/oauth clientID: ... clientSecret: ... ``` ## 真实部署 case 我们 prod + staging + 多个 dev cluster: - 1 个 manifest repo(gitops/) - 每 env 一个 ApplicationSet - 每 app 在 manifest repo 下 apps/<name>/{base,overlays/{prod,staging}}/ - image-updater 自动 promotion staging(main branch tag) - prod promotion 手动(PR + approval) ops 改东西 = PR 改 yaml。dev 看 ArgoCD UI 知道部署进度。 不需要 dev 学 kubectl。 ## 缺点 - Application CRD 增加学习 - ArgoCD 自身要运维(HA setup) - bug:自动 sync 时偶有 race condition ## 踩过的坑 1. **selfHeal 改不动 cluster**:dev 手 kubectl edit 救火 → 5 秒被 ArgoCD revert。临时 disable selfHeal 或者改 git。 2. **diff 噪声**:cluster 自动加 annotation(如 deployment.kubernetes.io/revision) → ArgoCD 看到 diff。`ignoreDifferences` 配置过滤。 3. **CRD 顺序**:先 install operator 再 apply custom resource。 sync-wave 控制。 4. **大 manifest repo 慢**:几千 yaml → ArgoCD slow。拆多 repo 或者 多 Application。 5. **secret 不放 git**:用 sealed-secret / external-secret operator, git 存加密版本。

CSS anchor positioning:原生写 tooltip / popover(不再用 Popper.js)

## 起因 每个 React 项目都装 `@floating-ui/react` 或 `popper.js` 解决 tooltip / dropdown / popover 的"跟着元素,但溢出视口时自动翻转" 这类位置计算。 代码体积 + 学习曲线 + 抽象层都有成本。 Chrome 125+(2024 中)开始支持 **CSS Anchor Positioning**, 原生 CSS 写定位浮动元素。Firefox / Safari 跟进中。 ## 解决方案 ### 最简单的 tooltip ```html <button class="btn">Hover me</button> <div class="tooltip">I am a tooltip</div> <style> .btn { anchor-name: --my-btn; /* 命名锚点 */ } .tooltip { position: absolute; position-anchor: --my-btn; /* 指定锚点 */ position-area: top; /* 在锚点上方 */ margin: 4px; } </style> ``` 效果:tooltip 自动定位到 btn 正上方 4px。 ### 自动翻转避免溢出 ```css .tooltip { position: absolute; position-anchor: --my-btn; position-area: top; /* 如果上方放不下,自动 fallback 到下方 */ position-try-fallbacks: --bottom; } @position-try --bottom { position-area: bottom; } ``` 视口顶部空间不够 tooltip → 自动跑到下面。Popper.js 的 "flip middleware" 原生实现。 ### 多 fallback ```css .tooltip { position: anchor: --my-btn; position-area: top; position-try-fallbacks: top right, bottom right, bottom left, top left; } ``` 按顺序尝试位置,第一个能完整显示的胜出。 ### dropdown menu ```html <button id="trigger" popovertarget="menu">Menu ↓</button> <menu id="menu" popover> <li><button>编辑</button></li> <li><button>删除</button></li> </menu> <style> #trigger { anchor-name: --trigger; } #menu { position-anchor: --trigger; position-area: bottom span-right; min-width: anchor-size(width); /* menu 至少跟 trigger 一样宽 */ margin: 4px 0; } </style> ``` `popover` 是 HTML 新属性(2023+),让任意元素能 toggle 显示, 配 anchor positioning 写下拉菜单几行搞定。 **完全无 JavaScript**。 ### popover API ```html <button popovertarget="dialog">Open</button> <div id="dialog" popover> <h2>对话框</h2> <button popovertarget="dialog" popovertargetaction="hide">关闭</button> </div> ``` `popover` 属性让元素自动有: - ESC 关闭 - 点击外部关闭(auto 模式) - 出现在 top layer(覆盖任何 z-index) - 焦点管理 ```css #dialog { /* 默认显示在浏览器中心 */ margin: auto; /* 进入动画 */ &:popover-open { animation: fade-in 0.2s; } } @keyframes fade-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } ``` modal 风格对话框: ```html <dialog id="modal"> <p>真的删除吗?</p> <button onclick="modal.close()">取消</button> <button>确认</button> </dialog> <button onclick="modal.showModal()">删除</button> ``` `<dialog>` 元素 + `showModal()` 自动焦点 trap + backdrop + ESC 关闭。 零依赖 modal。 ## 跟 Popper.js / floating-ui 对比 | | CSS anchor + popover | Popper.js / floating-ui | |---|---|---| | 浏览器支持 | Chrome/Edge 125+ / Safari/FF 跟进 | 全支持 | | bundle 影响 | 0 | 5-15 KB | | 灵活度 | 中(CSS 限制) | 高(JS 任意) | | 实施 | CSS only | JS hook + ref | | Modal trap | popover 自动 | 第三方库 | 2025-2026 浏览器覆盖率到位后可以全切。 **现在写新项目**:渐进增强——支持的浏览器用 CSS,老的退化用 JS 库。 ### 渐进增强 ```js if (CSS.supports('position-anchor: --x')) { // 啥都不做,CSS 处理 } else { // fallback 到 floating-ui await import('@floating-ui/dom').then(...) } ``` ## React 集成 只要 React 渲染对应 HTML: ```tsx function Tooltip({ children, label }) { return ( <> <span className="tooltip-target">{children}</span> <span className="tooltip-content" role="tooltip">{label}</span> </> ) } ``` ```css .tooltip-target { anchor-name: --tt; } .tooltip-content { position: absolute; position-anchor: --tt; position-area: top; } ``` 不需要 ref / 不需要 useState 控制位置。CSS 自动。 注意:每个 tooltip 实例需要唯一 anchor-name 否则冲突。 React 里: ```tsx const id = useId() <span style={{ anchorName: `--tt-${id}` }}>...</span> <span style={{ positionAnchor: `--tt-${id}` }}>...</span> ``` ## 实战:context menu 右键菜单: ```html <div id="cm-target">右键我</div> <menu id="ctxmenu" popover> <li><button>复制</button></li> <li><button>粘贴</button></li> <li><button>删除</button></li> </menu> <script> const target = document.getElementById('cm-target') const menu = document.getElementById('ctxmenu') target.addEventListener('contextmenu', (e) => { e.preventDefault() // 把 menu 定位到鼠标位置 menu.style.left = `${e.clientX}px` menu.style.top = `${e.clientY}px` menu.style.position = 'fixed' menu.showPopover() }) </script> ``` `showPopover()` API 触发显示。点外面 / ESC 自动关闭。 ## 与 dialog / modal 区别 | | dialog (showModal) | popover (showPopover) | |---|---|---| | 用法 | 阻塞性对话框 | 任意浮动元素 | | backdrop | ::backdrop 默认黑半透 | 无(popover=manual 时) | | inert | 背景内容自动 inert | 看模式 | | ESC | 自动关 | 自动关 | | 嵌套 | 多个 dialog 栈 | popover 也可栈 | 模态对话框用 `<dialog>`;非模态浮层用 `popover`。 ## 等待中的浏览器支持 ``` Chrome 125+ ✅ (2024-05) Edge 125+ ✅ Safari 17.4+ 部分 (popover 完整,anchor positioning 部分) Firefox 134+ anchor 部分 ``` 2026 末预计全支持。在那之前生产用 Popper.js 兜底; 个人项目 / Chrome 内部工具立刻可用。 ## 浏览器特性检测 ```css @supports (position-anchor: --x) { /* 新 API */ } @supports not (position-anchor: --x) { /* fallback:用 absolute + JS 控制 */ } ``` ## 我的实验感受 试用一周: - 简单 tooltip / dropdown:CSS 写 5 行 = floating-ui 50 行 - 复杂多步定位 / 动画 / 拖拽 → 还是 JS 灵活 - popover API 替代了 90% modal use case,不再需要 Headless UI Dialog 未来 1-2 年逐步迁移;现在新组件优先用 CSS 方案 + fallback。 ## 踩过的坑 1. **anchor-name 唯一性**:同 anchor-name 多个 target 时只第一个起效。 动态生成的组件用 useId 保证唯一。 2. **popover vs dialog 选错**:modal 阻塞场景仍是 dialog 的设计意图; popover 给非阻塞浮层。混用导致 UX 怪。 3. **position-try-fallbacks 顺序敏感**:写错顺序 fallback 选不优。 测视口各种尺寸验证。 4. **animation 不工作**:popover 进出动画需要 `:popover-open` + `@starting-style` 配 transition。CSS 语法新,文档要看 latest。 5. **Tailwind class 没原生支持**:要写自定义 utility。Tailwind v4 预计支持 anchor positioning utility。

PromQL recording rules:让贵的 query 提前算 + cache

## 起因 Grafana dashboard 30+ panel,每 panel 一个 PromQL query。 打开一次 dashboard:Prometheus 跑 30 query → CPU 飙 → 慢。 某些复杂 query: ```promql histogram_quantile(0.95, sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))) ``` 每次跑要扫几百万 sample 再算 percentile。慢且重复。 **Recording rules**:把贵 query 周期性提前算 → 存为新 metric → dashboard 查这新 metric → 快。 ## 配 recording rule `/etc/prometheus/rules/api.yml`: ```yaml groups: - name: api_recording interval: 30s rules: - record: api:http_request_duration_seconds:p95 expr: | histogram_quantile(0.95, sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))) - record: api:http_requests:rate5m expr: | sum by (service, status) (rate(http_requests_total[5m])) ``` ```yaml # prometheus.yml rule_files: - 'rules/*.yml' ``` reload prometheus → 每 30 秒算一次 → 结果存为 metric `api:http_request_duration_seconds:p95`。 dashboard query 改成: ```promql api:http_request_duration_seconds:p95 ``` 立刻返回(已算好)。 ## 命名规范 ``` <level>:<metric>:<aggregation> ``` 例: - `api:http_requests:rate5m` - `node:cpu:usage_pct` - `cluster:pod_count` 避免跟原 metric 冲突 + 一眼知道是 recording rule。 ## 何时用 recording rule 适合: - 复杂 query(histogram_quantile / 多 join) - dashboard 频繁查 - alert 用同 query 多次 - 跨 metric 计算(A + B / C 等) 不适合: - 简单查询(ratio / count) - 一次性临时 query - high cardinality(recording 后存空间炸) ## alerting rule 类似语法: ```yaml - alert: HighErrorRate expr: api:http_requests:rate5m{status=~"5.."} > 10 for: 5m labels: { severity: warning } annotations: summary: "High 5xx rate on {{ $labels.service }}" ``` `expr` 用 recording rule 结果 → alert evaluation 也快。 ## 性能数据 我们一个 cluster 100 node / 50 service: dashboard 打开(30 panel): | | latency | Prometheus CPU | |---|---|---| | 无 recording rule | 8s | 80% | | 全 recording rule | 0.5s | 20% | dashboard 数据可能稍滞后(recording interval 30s)。 trade-off:实时性 vs 性能。 ## external_labels ```yaml # prometheus.yml global: external_labels: cluster: prod region: us-east ``` recording rule 结果自动带 cluster / region label → 多集群 federation。 ## federation (多集群汇总) 多个 prometheus 互相拉: ```yaml scrape_configs: - job_name: 'federate' metrics_path: '/federate' params: match[]: - '{__name__=~"job:.+"}' # 只拉 recording rule static_configs: - targets: - 'prom-us-east:9090' - 'prom-eu-west:9090' ``` 中央 prom 拉所有 region recording rule → 全局 dashboard。 只拉 recording rule(不是 raw metric)→ 数据量小 + 标准化。 ## 与 Mimir / VictoriaMetrics 对比 老 prometheus 单机: - 数据存本地,几亿 sample 内存 - recording rule 提速但仍单机瓶颈 Grafana Mimir / VictoriaMetrics 是 prometheus 兼容的分布式 TSDB: - 多节点存储 + 查询 - 长期保留(年级别) - 内置 recording rule 跑 大 scale 必上。中小 scale prom 单机 + recording rule 够。 ## 真实 case:dashboard 优化 某客户 SRE dashboard: - 10 个 service × 4 SLI(latency p50/p95/p99 + error rate)= 40 panel - 打开慢 15 秒 - prom CPU 周期 spike 优化: 1. 建 recording rule 算每 SLI metric 2. dashboard query 改用 recording rule 3. recording rule interval 跟 dashboard auto-refresh 对齐(30s) 效果: - dashboard 打开 1 秒 - prom CPU 平均 -50% - alert evaluation 同样加速 ## 与 cortex / thanos 对比 | | self-host prom | Mimir | Thanos | VictoriaMetrics | |---|---|---|---|---| | 部署 | 简单 | 复杂 | 复杂 | 中 | | 长期存储 | 弱 | S3 | S3 | 本地/S3 | | 多集群 | federation | native | native | native | | 性能 | 单机 | 横向扩展 | 横向扩展 | 高 | 中小项目 self-host prom + recording rule + 远程 write 备份。 > 几亿 series → Mimir / VictoriaMetrics。 ## subquery (PromQL 4) ```promql max_over_time(rate(http_requests_total[5m])[1h:1m]) ``` `[1h:1m]` = 1h 窗口里每 1m 取 sample → over rate → max。 复杂但强大。recording rule 提前算更友好。 ## debug 不出数 ```promql # 看原 metric http_request_duration_seconds_bucket # 看 recording rule 结果 api:http_request_duration_seconds:p95 ``` Grafana Explore 直接查。 `/api/v1/rules` 看 rule 状态: ```bash curl http://prom:9090/api/v1/rules ``` `health: ok / err / unknown` 显示 rule 是否在跑。 ## 踩过的坑 1. **rule cycle**:rule A 依赖 rule B 依赖 rule A → 报错 + 数据空。 严格 layer:raw → level 1 → level 2,单向。 2. **label cardinality 爆**:recording rule 加 high cardinality label → 新 metric 几百万 series → TSDB OOM。`sum without` 合并。 3. **interval 太短**:1s interval rule 比 raw scrape 还频繁 → 反而 增负载。30s-1m 合理。 4. **alert 旧数据**:recording rule 跑得慢 → alert 用旧值 → 错过 真实 spike。监控 `prometheus_rule_evaluation_duration_seconds`。 5. **federation 漏 label**:federation 默认不带 external_label → 多 region 看不出来源。`honor_labels: true`。

Django Channels:给 Django 加 WebSocket(不引入 Node)

## 起因 Django 项目要加"实时通知"功能(用户 like 时其他用户立刻看到 count 更新)。 两种思路: 1. 起独立 Node.js WebSocket server + Django 通过 Redis pub/sub 协调 2. Django Channels:在 Django 内部加 WebSocket / async 不想多维护一个 Node 服务 → Channels 直接。 ## Channels 是什么 Django 4.0+ 原生支持 ASGI,可以跑 async view。 Channels 是 Django Software Foundation 的官方扩展,处理: - WebSocket 协议(HTTP 升级) - 多 worker 间消息分发(channel layer,用 Redis) - SSE / 长连接 ## 装 ```bash uv add channels channels-redis daphne ``` ## 配置 ASGI `asgi.py`: ```python import os from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack from django.urls import path os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') django_asgi_app = get_asgi_application() from chat.consumers import ChatConsumer from notifications.consumers import NotifyConsumer application = ProtocolTypeRouter({ 'http': django_asgi_app, # 普通 HTTP 走原本 Django 'websocket': AuthMiddlewareStack( # WebSocket 路由 URLRouter([ path('ws/chat/<str:room>/', ChatConsumer.as_asgi()), path('ws/notify/', NotifyConsumer.as_asgi()), ]) ), }) ``` `settings.py`: ```python INSTALLED_APPS = [ 'daphne', # 替代 staticfiles 的 ASGI server,必须排前面 'django.contrib.staticfiles', 'channels', # ... 你的 apps ] ASGI_APPLICATION = 'myapp.asgi.application' # channel layer:跨 worker 消息 CHANNEL_LAYERS = { 'default': { 'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': {'hosts': [('redis', 6379)]}, }, } ``` ## 写一个简单 Consumer ```python # chat/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class ChatConsumer(AsyncWebsocketConsumer): async def connect(self): self.room = self.scope['url_route']['kwargs']['room'] self.group_name = f'chat_{self.room}' self.user = self.scope['user'] if not self.user.is_authenticated: await self.close() return # 加入房间 group(Redis pub/sub) await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def receive(self, text_data): # 客户端发来消息 data = json.loads(text_data) message = data['message'] # 广播给房间所有人 await self.channel_layer.group_send( self.group_name, { 'type': 'chat.message', # 调下面方法 'message': message, 'user': self.user.username, } ) async def chat_message(self, event): # 房间收到消息时(包括自己发的) await self.send(text_data=json.dumps({ 'message': event['message'], 'user': event['user'], })) ``` `channel_layer.group_send` 通过 Redis 通知**所有 worker** 的对应 consumer。3 个 daphne worker / 100 个客户端,消息正确广播。 ## 客户端 JS ```js const ws = new WebSocket(`wss://example.com/ws/chat/${roomId}/`) ws.onmessage = (e) => { const data = JSON.parse(e.data) addMessageToUI(data.user, data.message) } ws.onopen = () => { console.log('connected') } document.getElementById('send').addEventListener('click', () => { const text = document.getElementById('input').value ws.send(JSON.stringify({ message: text })) }) ``` ## 跑 ```bash # 开发用 runserver(自动支持 ASGI / Channels) python manage.py runserver # 生产用 daphne daphne -b 0.0.0.0 -p 8000 myapp.asgi:application # 多 worker gunicorn -k uvicorn.workers.UvicornWorker myapp.asgi:application --workers 4 ``` Redis 必须跑(channel layer 依赖)。 nginx 反代要支持 WebSocket: ```nginx location /ws/ { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400; # 长连接不要超时 } ``` ## 从 Django view 推 message 业务 view 里触发广播(非 WebSocket 路径): ```python # views.py from channels.layers import get_channel_layer from asgiref.sync import async_to_sync def like_post(request, post_id): post = Post.objects.get(pk=post_id) post.likes += 1 post.save() # 通知所有连接到这帖子的客户端 channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f'post_{post_id}', { 'type': 'post.update', # 调用 consumer 的 post_update 方法 'likes': post.likes, } ) return JsonResponse({'likes': post.likes}) ``` ```python class PostNotifyConsumer(AsyncWebsocketConsumer): async def connect(self): post_id = self.scope['url_route']['kwargs']['post_id'] self.group_name = f'post_{post_id}' await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def post_update(self, event): await self.send(text_data=json.dumps({ 'likes': event['likes'], })) ``` 通用模式:**业务 view 改 DB → group_send 通知 → 所有 WebSocket 客户端 收到**。 ## ORM in async consumer WebSocket consumer 是 async,但 Django ORM 默认同步。 用 `sync_to_async`: ```python from asgiref.sync import sync_to_async class MyConsumer(AsyncWebsocketConsumer): async def receive(self, text_data): post = await sync_to_async(Post.objects.get)(pk=1) ``` 或者用 Django 4.1+ 的 async ORM: ```python post = await Post.objects.aget(pk=1) ``` ## 测试 Consumer ```python from channels.testing import WebsocketCommunicator from chat.consumers import ChatConsumer async def test_chat(): communicator = WebsocketCommunicator( ChatConsumer.as_asgi(), '/ws/chat/test/') connected, _ = await communicator.connect() assert connected await communicator.send_json_to({'message': 'hello'}) response = await communicator.receive_json_from() assert response['message'] == 'hello' await communicator.disconnect() ``` ## 性能 / 规模 Channels + Redis 配置 4 worker 在小服务器上: - 1000 并发 WebSocket connection 轻松 - 10000 取决于 Redis 配置 + 网络 - > 10w → 考虑专用 WebSocket server(Centrifugo / Phoenix Channels) ## 与替代品对比 | | Django Channels | 单独 Node WebSocket | Centrifugo | Phoenix LiveView | |---|---|---|---|---| | 语言 | Python | Node.js | Go | Elixir | | 集成 Django | 原生 | 需 Redis 中介 | 同 | N/A | | 并发上限 | 中(万级) | 高 | 极高(百万) | 极高 | | 复杂度 | 中 | 高(双栈) | 中 | 高(学语言) | | 适合 | Django app + 适量 WebSocket | 极致并发 + 现有 Node | 中大规模 push | 完全重 stack | ## 真实部署 case 我们一个内部协作工具: - Django 4.2 + Channels 4 - 500 实时在线协作 user - 用 1 台机器 (4 vCPU / 8 GB) + Redis - daphne 4 worker - nginx 反代 - 6 个月 0 down 足够。如果上 5000 在线考虑 Centrifugo。 ## 替代:HTMX + SSE 如果只是"服务端推" 不需要双向,用 SSE 替代(前面有篇): ```python @app.get('/sse/notifications') async def sse(): async def gen(): async for event in get_events(): yield f'data: {json.dumps(event)}\n\n' return StreamingHttpResponse(gen(), content_type='text/event-stream') ``` 更简单 / 更标准 HTTP / Django 5 原生支持。 WebSocket 仍是双向场景(聊天 / 实时协作)的更优选。 ## 踩过的坑 1. **daphne 没加进 INSTALLED_APPS**:staticfiles 没替换 → runserver 不识别 ASGI。 2. **channel layer 用 InMemory**:单 worker OK;多 worker 时消息 收不到(每 worker 独立 in-memory)。生产必用 RedisChannelLayer。 3. **WebSocket 鉴权**:默认 scope['user'] 是 AnonymousUser。 `AuthMiddlewareStack` 让 Django session cookie 生效。 JWT 等其它 auth 要自己写 middleware。 4. **客户端长时间不发消息断**:默认 keepalive 没设,nginx / proxy 一小时空闲 → 断。客户端定期发 ping:"" 空消息或者 protocol ping。 5. **`async_to_sync` + `sync_to_async` 混用** → ASGI ↔ WSGI 转换贵。 尽量整个 path 同 async / 同 sync。

本地开发环境:用 mise + direnv 还是用 docker compose

## 起因 新项目要装:PostgreSQL 16 + Redis 7 + Node 20 + Python 3.12 + ImageMagick + ffmpeg。同事的机器版本各异,"在我机器上能跑"频繁出现。 两种主流策略: A. `mise` + `direnv` 安装到 host B. `docker compose` 把依赖装容器 我俩都试过几次,下面记录适用场景 + 优缺点。 ## 方案 A:mise + direnv(本地原生) `.tool-versions`: ``` python 3.12.5 nodejs 20.10.0 go 1.22.1 postgres 16.4 redis 7.4.0 ``` `.envrc`: ```bash use mise dotenv .env PATH_add ./bin export DATABASE_URL=postgresql://localhost/myapp ``` ```bash cd myapp mise install # 装 Python 3.12.5 + Node 20.10 + Go ... # 第一次几分钟,之后秒级 ``` DB / Redis 还是装 host: ```bash brew install postgresql@16 redis brew services start postgresql@16 redis ``` 或者 host 上跑 docker 单独管 DB: ```bash docker run -d --name pg -p 5432:5432 -v pg-data:/var/lib/postgresql/data \ -e POSTGRES_PASSWORD=dev postgres:16 docker run -d --name redis -p 6379:6379 redis:7 ``` **优点**: - IDE 直接看到源码 + linter / 跳定义 / debugger 都丝滑 - 启动快(mise install 几秒;之后开机即可用) - 不依赖 docker daemon(笔记本 RAM 友好) - 测试 / lint / build 直接跑 host CPU 速度 **缺点**: - ImageMagick / ffmpeg 之类系统依赖每个 OS 装法不同 - C 扩展可能因 OS / glibc 版本兼容问题 - 多个项目用不同 Node 版本时偶尔切版本(mise 自动) ## 方案 B:docker compose(容器化) `docker-compose.yml`: ```yaml services: web: build: . volumes: - .:/app - node-modules:/app/node_modules # 不让 host 的覆盖 ports: ["3000:3000"] environment: DATABASE_URL: postgresql://app:dev@db/myapp REDIS_URL: redis://redis:6379 depends_on: - db - redis db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: dev POSTGRES_DB: myapp volumes: - pg-data:/var/lib/postgresql/data ports: ["5432:5432"] redis: image: redis:7 volumes: - redis-data:/data volumes: pg-data: redis-data: node-modules: ``` `Dockerfile`: ```dockerfile FROM node:20-slim RUN apt update && apt install -y python3 build-essential libpq-dev \ ffmpeg imagemagick WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . CMD ["npm", "run", "dev"] ``` ```bash docker compose up ``` **优点**: - 跨 OS 完全一致(Mac / Linux / Windows 同结果) - 系统依赖 (ffmpeg 等) 在 Dockerfile,新人 onboarding `docker compose up` 完事 - 接近生产环境 - 多项目隔离(每个项目自己一组 service) **缺点**: - 启动慢(image build 几分钟;首次 download image 几 GB) - IDE 集成稍复杂(要让 IDE 知道 venv 在容器里) - Mac / Windows 上 docker desktop 占内存(默认 4-8 GB) - 文件系统 bind mount 在 macOS 上有性能损失(npm install / build 慢 3-5x) ## 我的选择矩阵 | 场景 | 推荐 | |---|---| | 个人项目 + Linux 桌面 | mise + direnv | | 个人项目 + Mac/Win | mise + Docker for DB only | | 团队多人多 OS | docker compose | | 复杂多服务(5+ container) | docker compose(基本必须) | | 需要 IDE 强 debug | mise(host 跑 Python/Node 直 debug) | | onboarding 时间宝贵 | docker compose | | CI 跑测试 | docker compose 镜像 + GitHub Action | ## 混合方案(我个人用) 主要业务代码跑 host(mise 装好 Node/Python),仅 DB / Redis / LocalStack 等 service 跑 docker。 `docker-compose.dev.yml`: ```yaml services: db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: dev POSTGRES_DB: myapp ports: ["127.0.0.1:5432:5432"] volumes: [pg-data:/var/lib/postgresql/data] redis: image: redis:7-alpine ports: ["127.0.0.1:6379:6379"] volumes: pg-data: ``` ```bash docker compose -f docker-compose.dev.yml up -d mise install direnv allow npm run dev # 跑 host ``` **两者优点结合**:IDE debug 顺滑 + 数据库环境一致。 ## 痛点解决 ### Mac docker 文件慢 ```yaml volumes: - .:/app:delegated # macOS 优化(让容器写少同步给 host) ``` 或用 `mutagen-compose`:rsync 风格双向同步代替 bind mount, 性能 10x。 ### node_modules 不同步 ```yaml volumes: - .:/app - /app/node_modules # named volume 让容器内的 node_modules 不被 host 空目录覆盖 ``` ### 多项目端口冲突 每个项目用不同端口: ```yaml ports: ["127.0.0.1:5433:5432"] # 项目 A ports: ["127.0.0.1:5434:5432"] # 项目 B ``` 或者用 Traefik 共享一个反代(前面有篇)。 ## 完全云端 dev:Codespaces / Gitpod ```json // .devcontainer/devcontainer.json { "image": "mcr.microsoft.com/devcontainers/python:3.12", "features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" }, "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, "postCreateCommand": "uv sync" } ``` GitHub Codespaces 一键起一个云端 VSCode(已配齐所有依赖)。新人 30 秒 能 contribute,不需要本地装任何东西。 ## 效果 我们团队(5 人,Mac/Linux 混用)现在用"混合方案": - 新人 onboarding 从 2 天 → 30 分钟(mise install + docker compose up) - IDE 调试体验保持原生 - 数据库版本统一(postgres 16)跨所有人无差异 - CI 用同样 docker image 测试 → 部署 - "我机器能跑" 类问题归零 ## 踩过的坑 1. **mise 装的 Python 链接 libssl 失败**:openssl 升级后老 Python 坏。`mise reshim` 重新链接,或 `mise uninstall python && mise install`。 2. **docker compose volume 跨用户权限**:root 跑了 docker,host 非 root 用户读不了 volume 文件。改 docker-compose `user: "1000:1000"` 或 chown。 3. **`.env` 进 git**:永远 `.env.example` 进 git,`.env` 进 gitignore。 4. **依赖装到全局**:用 mise 跑 `npm install -g` 会装到 mise 管的 Node 里。换 Node 版本就丢。改 `npx some-tool` 或本地 dev dep。 5. **VSCode 用错 venv**:`.vscode/settings.json` 指明 `"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python"`。

just(justfile):替代 Makefile 做项目脚本入口

## 起因 每个项目都有一堆需要跑的命令:测试、lint、构建、迁移、部署。 用 npm script 写 → 只适合 Node 项目;写在 README → 复制粘贴慢; 用 Makefile → tab 缩进诡异、`.PHONY` 麻烦、shell quoting 各种坑、 Windows 上 make 不一定有。 `just` 是 Rust 写的命令运行器,专为"项目级 task runner"设计, 没有 make 的历史包袱。 ## 安装 ```bash # macOS brew install just # Debian / Ubuntu sudo apt install just # Ubuntu 24.04+ # 或下载二进制 curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \ | bash -s -- --to /usr/local/bin # Windows scoop install just just --version ``` ## justfile 例 放在项目根目录 `justfile`(无后缀): ```just # 默认 recipe(just 不带参数时跑) default: list # 列所有 recipe list: @just --list # 测试 test: pytest -x # 跑某个测试 test-one TEST: pytest -x -v {{TEST}} # 启动开发服务器 dev: uvicorn app.main:app --reload # Lint + format lint: ruff check . mypy src/ fmt: ruff format . ruff check --fix . # 跑数据库迁移 migrate: alembic upgrade head migrate-create MESSAGE: alembic revision --autogenerate -m "{{MESSAGE}}" # 多步组合 ci: lint test @echo "✅ all green" # 部署到生产 deploy ENV='staging': @echo "Deploying to {{ENV}}" ./scripts/deploy.sh {{ENV}} # 用 docker 构建镜像 build VERSION: docker build -t myapp:{{VERSION}} . docker tag myapp:{{VERSION}} myapp:latest # 清理 clean: rm -rf dist build .pytest_cache .ruff_cache __pycache__ ``` ```bash just # 同 `just default` just test just test-one tests/test_user.py::test_create just migrate-create "add email field" just ci just deploy prod ``` ## 与 Makefile 对比 ```makefile # Makefile .PHONY: test lint deploy test: pytest -x lint: ruff check . mypy src/ deploy: @echo "Deploying" ./scripts/deploy.sh $(ENV) ``` ```bash make test make deploy ENV=prod # 注意要写 ENV=prod,just 是位置参数 deploy prod ``` just 的胜出: - 不需要 .PHONY(recipe 永远跑) - 不需要 tab(空格即可,4 / 2 都行) - 命令默认 echo(不需要 `@`) - 多行字符串 / heredoc 简单 - 参数语法直接(位置 + 默认值) - 跨平台(Windows / Linux / Mac 一致) ## 高级特性 ### 多 shell ```just # 默认 sh -cu hello: echo "from sh" # 用 bash [shell: bash] fancy: echo $BASH_VERSION arr=(1 2 3); echo "${arr[@]}" # 用 python [shell: python] math: print(2 ** 10) # 用 powershell [shell: pwsh -c] greet: Write-Host "from PowerShell" ``` ### 跨平台条件 ```just [unix] clean: rm -rf build/ [windows] clean: Remove-Item -Recurse -Force build/ ``` ### 依赖 ```just build: lint test cargo build --release release VERSION: build git tag v{{VERSION}} git push origin v{{VERSION}} ``` `just release 1.2.3` → 自动先跑 build → build 先跑 lint + test。 ### 变量 ```just version := `git describe --tags --always` docker_repo := "ghcr.io/myorg/myapp" build: docker build -t {{docker_repo}}:{{version}} . docker push {{docker_repo}}:{{version}} ``` ### env 读 .env ```just set dotenv-load dev: echo "Connecting to $DATABASE_URL" ``` `set dotenv-load` 让 just 自动读 `.env`。 ### 分组 ```just [group('test')] test-unit: pytest tests/unit [group('test')] test-e2e: playwright test [group('deploy')] deploy-staging: ./deploy.sh staging ``` ```bash just --list --groups # test: # test-unit # test-e2e # deploy: # deploy-staging ``` ### private recipes ```just [private] _setup: pip install -r requirements.txt dev: _setup flask run ``` `_` 开头 = private(不出现在 `just --list`),但能被依赖。 ## 嵌套 justfile monorepo 里每个子目录一个 justfile: ``` myapp/ ├── justfile # 顶层(全局 recipe) ├── backend/ │ └── justfile # backend 特定 └── frontend/ └── justfile # frontend 特定 ``` 顶层调用子项目: ```just test-all: just backend/test just frontend/test ``` 或者用 `just --justfile` 指定: ```bash just --justfile backend/justfile test just -f backend/justfile test # 简写 ``` ## tab 补全 ```bash # bash just --completions bash > /usr/local/etc/bash_completion.d/just # zsh just --completions zsh > ~/.zsh/completions/_just # fish just --completions fish > ~/.config/fish/completions/just.fish ``` 之后 tab 补全所有 recipe 名 + 参数。 ## 与 CI GitHub Actions: ```yaml - uses: extractions/setup-just@v2 - run: just ci ``` ## 效果 - 新人 clone 仓库 → `just --list` 一眼看到所有可用命令 - README 不再充满"how to run tests / lint / deploy",一句"see justfile" - Makefile 时代的 tab/space 翻车问题归零 - 跨平台一致,Windows 同事不再单独写 .ps1 脚本 ## 踩过的坑 1. **recipe 名带 `_`、`-`**:just 都支持,但 shell tab 补全有时不识别。 recipe 名优先短 + 不带 `_`。 2. **不在 git 根**:默认 just 在当前目录找 justfile。子目录里跑根的 recipe 需要 `cd` 上去或 `just -f ../justfile recipe`。`just` 也支持 `.justfile`(隐藏)让 ls 不见。 3. **shell 退出码处理**:单条 recipe 里某命令失败默认整个 recipe 停。 要继续:`-` 前缀(同 make): ```just deploy: -kill -9 $(pidof oldservice) systemctl start newservice ``` 4. **变量 `$var` vs `{{var}}`**:`$var` 是 shell 变量(运行时展开), `{{var}}` 是 just 变量(justfile 解析时展开)。混了会报错。 5. **CI 装 just 加二进制 cache**:每次 CI build 都装一次浪费时间, `extractions/setup-just` action 自带 cache。

实现一个可访问性(a11y)合规的模态框(focus trap + ESC + 滚动锁)

90% 的模态框 / 抽屉组件不合规——键盘用户 / 屏幕阅读器用户用得很痛苦。 正确实现并不复杂,主要是几个细节都必须做对。 ## 一个合规模态必须做到 1. **打开时焦点自动移入** —— 屏幕阅读器才知道有新内容 2. **关闭时焦点回到触发按钮** 3. **Tab 焦点循环在模态内**(focus trap) 4. **ESC 关闭** 5. **背景内容不可滚动**(防 iOS 弹起键盘时背景滚走) 6. **背景 `aria-hidden` 或 `inert`**,屏幕阅读器不读背景 7. **`role="dialog"` + `aria-modal="true"` + `aria-labelledby`** ## React 实现 ```tsx import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' interface ModalProps { open: boolean onClose: () => void title: string children: React.ReactNode } export function Modal({ open, onClose, title, children }: ModalProps) { const dialogRef = useRef<HTMLDivElement>(null) const lastFocusRef = useRef<HTMLElement | null>(null) // 打开时保存当前焦点 + 把焦点移入对话框 useEffect(() => { if (!open) return lastFocusRef.current = document.activeElement as HTMLElement // 锁背景滚动 const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' // 标记背景 inert(modern 浏览器) const root = document.getElementById('root') if (root) root.setAttribute('inert', '') // 找第一个可聚焦元素并聚焦 queueMicrotask(() => { const focusable = dialogRef.current?.querySelector<HTMLElement>( FOCUSABLE ) focusable?.focus() }) return () => { document.body.style.overflow = prevOverflow root?.removeAttribute('inert') lastFocusRef.current?.focus() } }, [open]) // ESC 关闭 useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open, onClose]) // Tab focus trap useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key !== 'Tab' || !dialogRef.current) return const els = dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE) if (els.length === 0) return const first = els[0] const last = els[els.length - 1] if (e.shiftKey && document.activeElement === first) { last.focus() e.preventDefault() } else if (!e.shiftKey && document.activeElement === last) { first.focus() e.preventDefault() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open]) if (!open) return null return createPortal( <div className="modal-backdrop" onClick={onClose}> <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" className="modal" onClick={e => e.stopPropagation()} > <h2 id="modal-title">{title}</h2> {children} <button onClick={onClose} aria-label="关闭对话框">×</button> </div> </div>, document.body ) } const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), ' + 'input:not([disabled]):not([type="hidden"]), select:not([disabled]), ' + '[tabindex]:not([tabindex="-1"])' ``` ## CSS ```css .modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, .5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal { background: #fff; border-radius: 8px; padding: 24px; max-width: 480px; width: 90vw; max-height: 90vh; overflow: auto; position: relative; } .modal h2 { margin-top: 0; } ``` ## 使用 ```tsx function App() { const [open, setOpen] = useState(false) return ( <> <button onClick={() => setOpen(true)}>打开</button> <Modal open={open} onClose={() => setOpen(false)} title="确认"> <p>真的要删除吗?</p> <button onClick={() => setOpen(false)}>取消</button> <button onClick={handleConfirm}>确认</button> </Modal> </> ) } ``` ## `inert` 属性 `inert` 是 2024 主流浏览器都支持的属性,给元素加上后该子树: - 不接受任何焦点 - 不响应任何鼠标点击 - 屏幕阅读器跳过 完美替代过去的 "set tabindex=-1 on every focusable" + `aria-hidden` hack。 老浏览器(IE / 老 Safari)回退到 `aria-hidden="true"` + 手动管理 tabindex, 但 2026 年基本没必要考虑。 ## 用现成的库 实现 1 个模态花一下午是 OK 的,做 5 种弹层组件就别造轮子了。 推荐: - **Radix UI**(无样式 primitives)—— 工业级 a11y 实现 - **Headless UI**(Tailwind 出品)—— 类似 - **react-aria**(Adobe)—— 更细粒度 直接用上面任何一个,配自己的样式。 ## 测试 ```bash npm i -D @axe-core/playwright ``` ```ts import { test } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test('modal has no a11y violations', async ({ page }) => { await page.goto('/') await page.click('text=打开') const results = await new AxeBuilder({ page }).analyze() expect(results.violations).toEqual([]) }) ``` axe-core 是事实标准的 a11y 自动测试工具。 ## 踩过的坑 - 没把焦点还回触发按钮 → 键盘用户按 ESC 后焦点跳到 body 开头,要重新 Tab 几十次回来。 - 用 `aria-hidden="true"` 包裹背景 + 没用 inert:iOS VoiceOver 会跳过 aria-hidden 但 Android TalkBack 仍能 "swipe to" 选中背景元素。 `inert` 两端都靠谱。 - focus trap 实现里查 focusable 元素,querySelector 一次太死板: 对话框内动态变化元素时要每次 keydown 时重新查(如上代码)。 - iOS Safari 上 `overflow:hidden` 锁滚不够稳,需要再加 `position: fixed; top: -${scrollY}px`。生产建议用 `body-scroll-lock` 库。

Loki + Promtail 做日志聚合(轻量、与 Grafana 同生态)

ELK Stack 重量级(Java + Elasticsearch 几个 GB 内存), 小规模团队用 Loki 更合适: - Go 写的,单机版几百 MB 内存 - 不全文索引,只按 label 索引(像 Prometheus) - 存储用对象存储(S3 / 本地磁盘) - Grafana 一等公民支持,UI 体验和 Prometheus 一致 ## 架构 ``` 节点 → promtail (agent) → Loki (中心) → Grafana ``` ## 1. 装 Loki ```bash sudo useradd -rs /bin/false loki sudo mkdir -p /var/lib/loki /etc/loki sudo chown -R loki:loki /var/lib/loki curl -fsSL https://github.com/grafana/loki/releases/latest/download/loki-linux-amd64.zip \ -o /tmp/loki.zip sudo unzip /tmp/loki.zip -d /usr/local/bin sudo mv /usr/local/bin/loki-linux-amd64 /usr/local/bin/loki sudo chmod +x /usr/local/bin/loki ``` 最简单的"all-in-one"配置 `/etc/loki/config.yml`: ```yaml auth_enabled: false server: http_listen_port: 3100 common: path_prefix: /var/lib/loki storage: filesystem: chunks_directory: /var/lib/loki/chunks rules_directory: /var/lib/loki/rules replication_factor: 1 ring: instance_addr: 127.0.0.1 kvstore: store: inmemory schema_config: configs: - from: 2024-01-01 store: tsdb object_store: filesystem schema: v13 index: prefix: index_ period: 24h limits_config: retention_period: 30d reject_old_samples: true reject_old_samples_max_age: 168h ``` systemd `/etc/systemd/system/loki.service`: ```ini [Unit] Description=Loki After=network.target [Service] User=loki ExecStart=/usr/local/bin/loki -config.file=/etc/loki/config.yml Restart=on-failure [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl enable --now loki curl localhost:3100/ready ``` ## 2. 装 promtail(每台被采集机器) ```bash curl -fsSL https://github.com/grafana/loki/releases/latest/download/promtail-linux-amd64.zip \ -o /tmp/promtail.zip sudo unzip /tmp/promtail.zip -d /usr/local/bin sudo mv /usr/local/bin/promtail-linux-amd64 /usr/local/bin/promtail sudo chmod +x /usr/local/bin/promtail ``` `/etc/promtail/config.yml`: ```yaml server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /var/lib/promtail/positions.yaml clients: - url: http://loki.example.com:3100/loki/api/v1/push scrape_configs: # systemd journal - job_name: journal journal: max_age: 12h labels: job: systemd-journal host: ${HOSTNAME} relabel_configs: - source_labels: ['__journal__systemd_unit'] target_label: unit # nginx access log - job_name: nginx static_configs: - targets: [localhost] labels: job: nginx host: ${HOSTNAME} __path__: /var/log/nginx/*.log # 自定义应用日志 - job_name: myapp static_configs: - targets: [localhost] labels: job: myapp host: ${HOSTNAME} __path__: /var/log/myapp/*.log ``` systemd 类似,启动: ```bash sudo systemctl enable --now promtail journalctl -u promtail -n 20 ``` ## 3. Grafana 加数据源 ``` Connections → Data sources → Loki URL: http://loki.example.com:3100 ``` ## 4. LogQL 查询语法 ``` # 看某 unit 的所有日志 {unit="nginx.service"} # 多 label 过滤 {job="nginx", host="server1"} |= "500" # 含 "500" # 排除 {job="nginx"} != "kube-probe" # 正则匹配 {job="myapp"} |~ "ERROR|FATAL" # JSON 日志解析后过滤 {job="myapp"} | json | level="error" | line_format "{{.timestamp}} {{.msg}}" # 错误率 / 量 sum by (host) (rate({job="nginx"} |= "500" [5m])) # Top N 出错的 unit topk(5, sum by (unit) (count_over_time({job="systemd-journal"} |= "ERROR" [1h]))) ``` `|=`、`!=`、`|~`、`!~` 是按字符串 / 正则过滤;`| json`、`| logfmt` 是 解析。 ## 5. 仪表盘 Grafana Explore 写 LogQL 查询;满意了用 "Add to dashboard" 保存。 社区仪表盘:搜 "Loki" 在 grafana.com/dashboards,有 nginx / docker / k8s 现成版本。 ## 6. 告警(log-based alerting) `rules/myapp.yml` (Loki rule): ```yaml groups: - name: myapp-alerts rules: - alert: HighErrorRate expr: | sum(rate({job="myapp"} |~ "ERROR|FATAL" [5m])) > 0.1 for: 5m labels: severity: warning annotations: summary: 'myapp 错误率高' ``` Loki 内置 Ruler 跑这个规则,触发告警发给 Alertmanager(同 Prometheus 那套)。 ## 7. retention / 存储 ```yaml limits_config: retention_period: 30d # 全局 30 天 per_stream_retention: - selector: '{job="audit"}' period: 1y # audit 日志保留 1 年 ``` 底层用 chunks(默认 filesystem)。生产建议 S3 / GCS: ```yaml common: storage: s3: bucketnames: my-loki-logs region: us-east-1 ``` 按需 prune 老 chunks 来控制成本。 ## 8. JSON 结构化日志(推荐) 应用直接输出 JSON 日志: ```python import structlog log = structlog.get_logger() log.info('user signed up', user_id=42, plan='pro') # {"event": "user signed up", "user_id": 42, "plan": "pro", "level": "info"} ``` Loki 里 `{job="myapp"} | json | user_id="42"` 直接过滤字段。 不要在 label 上加 user_id 这种高基数的 —— 用 query-time `| json` filter。 ## 9. 资源占用 Loki + Promtail 在 4 core / 8GB 机器上能处理 100k logs/sec 或 5GB/day。 比 ES 节省 5-10x 资源。 ## 10. 与 Prometheus 互补 Prometheus = 时序指标(数字) Loki = 日志(文本) 同一仪表盘里:上面是请求数(Prom),下面是错误日志(Loki)。 按时间对齐看故障。 ## 踩过的坑 - promtail 没权限读 `/var/log/...`:默认 root 才读;要么 user=root 跑 promtail,要么把日志 chmod。 - label cardinality:跟 Prometheus 一样,label 取 user_id 把 Loki 拖死。 日志的"高基数"信息应该在 line 里(用 `| json` 解析),不该在 label 上。 - 时区:Loki 内部 UTC;UI 按浏览器时区显示。日志原文里的时间戳格式不一 时需要 `pipeline_stages` 解析 timestamp。 - "all-in-one" 配置不适合多副本 / HA。生产规模上去后拆成 distributor / ingester / querier 三类组件。

React Error Boundary + Sentry:把"白屏" 救成"局部降级 + 自动上报"

## 起因 一个生产 bug:某个组件渲染时 throw 了未捕获错误 → 整页白屏 → 用户刷新无效 → 截图发给客服。 React 默认行为:子组件 throw 没 catch → 整个组件树卸载 → 白屏。 正确做法:用 ErrorBoundary 局部捕获 + 显示"出错了"卡片 + 上报错误到 Sentry。 ## 解决方案 ### 1. 基础 ErrorBoundary ```tsx // components/ErrorBoundary.tsx import { Component, ReactNode } from 'react' interface Props { children: ReactNode fallback?: ReactNode onError?: (error: Error, info: { componentStack: string }) => void } interface State { hasError: boolean error: Error | null } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null } static getDerivedStateFromError(error: Error): State { return { hasError: true, error } } componentDidCatch(error: Error, info: { componentStack: string }) { console.error('ErrorBoundary caught:', error, info) this.props.onError?.(error, info) } reset = () => { this.setState({ hasError: false, error: null }) } render() { if (this.state.hasError) { if (this.props.fallback) { return this.props.fallback } return ( <div role="alert" style={{ padding: 16, background: '#fee', borderRadius: 8 }}> <h3>出错了 😞</h3> <p>{this.state.error?.message}</p> <button onClick={this.reset}>重试</button> </div> ) } return this.props.children } } ``` ErrorBoundary 必须 class component(Hooks 不支持 componentDidCatch)。 ### 2. 用法:包裹"应该独立失败"的区域 ```tsx function Dashboard() { return ( <Layout> <ErrorBoundary> <PostList /> {/* 出错只这里降级 */} </ErrorBoundary> <ErrorBoundary> <RecommendedSidebar /> </ErrorBoundary> <ErrorBoundary> <CommentFeed /> </ErrorBoundary> </Layout> ) } ``` 任意一个组件挂了不影响其它,用户至少能用其它部分。 ### 3. 顶层兜底 ```tsx function App() { return ( <ErrorBoundary fallback={ <div style={{ padding: 40, textAlign: 'center' }}> <h1>应用出错</h1> <p>请刷新页面,或联系支持</p> <button onClick={() => location.reload()}>刷新</button> </div> } > <Router> <Routes>...</Routes> </Router> </ErrorBoundary> ) } ``` ### 4. Sentry 集成(生产必备) ```bash npm i @sentry/react ``` ```ts // main.tsx import * as Sentry from '@sentry/react' Sentry.init({ dsn: 'https://[email protected]/123', environment: import.meta.env.MODE, tracesSampleRate: 0.1, // 10% 性能采样 replaysSessionSampleRate: 0.1, // 10% 会话录制 replaysOnErrorSampleRate: 1.0, // 出错的 100% 录制 integrations: [ Sentry.browserTracingIntegration(), Sentry.replayIntegration({ maskAllText: false, blockAllMedia: true }), ], }) ``` 用 Sentry 的 ErrorBoundary 替代自己写的: ```tsx import * as Sentry from '@sentry/react' <Sentry.ErrorBoundary fallback={({ error, resetError }) => ( <ErrorCard error={error} onReset={resetError} /> )} showDialog // 弹用户反馈表单 > <PostList /> </Sentry.ErrorBoundary> ``` 任何 error throw → 自动上报 Sentry → 包含 stack trace + 浏览器版本 + URL + 用户 ID + session replay 视频回放。 ### 5. 异步错误:try/catch + Sentry.captureException ErrorBoundary 只抓 render / lifecycle 错误。**异步错误(fetch / setTimeout / Promise)抓不到**: ```tsx async function loadData() { try { const r = await fetch('/api/users') if (!r.ok) throw new Error(`status ${r.status}`) return await r.json() } catch (e) { Sentry.captureException(e) throw e // 让上层 React Query / useState 知道失败 } } ``` 或者 React Query 自动: ```tsx const { data, error } = useQuery({ queryKey: ['users'], queryFn: loadData, throwOnError: true, // throw 给最近的 ErrorBoundary }) ``` `throwOnError: true` 让 TanStack Query 把 error 抛给 ErrorBoundary, 两套机制串起来。 ### 6. 区分预期错误 vs 真 bug ```tsx // 预期错误:业务规则 async function transfer(amount) { const r = await fetch('/api/transfer', ...) if (r.status === 403) { throw new InsufficientBalanceError() // 不上报 Sentry } if (!r.ok) { throw new Error('transfer failed') // 上报 } } class InsufficientBalanceError extends Error { isExpected = true } // 包装 function captureIfUnexpected(e: Error) { if ((e as any).isExpected) return Sentry.captureException(e) } ``` Sentry 项目里大量"用户输错密码 401"的告警没价值,要区分。 ### 7. 全局未捕获错误 ```ts window.addEventListener('error', (event) => { Sentry.captureException(event.error) }) window.addEventListener('unhandledrejection', (event) => { Sentry.captureException(event.reason) }) ``` Sentry SDK 默认已经 hook 了这些,不需要自己写。 ### 8. Source maps 上传 生产 bundle 是 minified;Sentry 看 stack trace 全是 `a.b.c at xyz.js:1:1234`, 没法定位。Vite 配 sourcemap + 上传到 Sentry: ```ts // vite.config.ts import { sentryVitePlugin } from '@sentry/vite-plugin' export default defineConfig({ build: { sourcemap: true }, plugins: [ react(), sentryVitePlugin({ org: 'my-org', project: 'my-app', authToken: process.env.SENTRY_AUTH_TOKEN, }), ], }) ``` build 时自动上传 sourcemap 到 Sentry,error 在 dashboard 显示原始 TS 代码 + 行号。 ### 9. 用户反馈 ```tsx <Sentry.ErrorBoundary fallback={({ error, eventId }) => ( <div> <h3>出错了</h3> <p>已自动上报。错误 ID: <code>{eventId}</code></p> <button onClick={() => Sentry.showReportDialog({ eventId })}> 提供反馈 </button> </div> )} > ``` 用户能描述"我点什么按钮触发的",Sentry 把这个反馈跟 error 绑在一起。 ## 效果 - 白屏 bug 数从每月 10+ → 0(局部降级) - Sentry 收集到 95% bug,开发能主动修而不是等用户报 - session replay 让"为什么会出现这个 state" 一目了然 - 半年内 frontend error rate 从 2% → 0.3% ## 注意事项 ### 1. 不要包太细 每个 div 一个 ErrorBoundary:渲染开销 + 视觉细碎。 原则是"独立可用的功能块"为单位(侧栏 / 主内容 / 评论区)。 ### 2. PII 信息 Sentry 默认抓表单数据。可能包含密码、邮箱、信用卡。 ```ts Sentry.init({ beforeSend(event) { // 删 password 字段 if (event.request?.data) { delete event.request.data.password } return event }, }) ``` 或者 mask all replay:`replayIntegration({ maskAllText: true })`。 ### 3. quota Sentry 免费层每月 5k events。`tracesSampleRate: 0.1` 限性能采样。 真 error 全部上报;性能 / replay 采样。 ## 踩过的坑 1. **ErrorBoundary 必须是 class**:function component 用 React 19+ 的 `use()` 可以一定程度替代,但官方 ErrorBoundary 仍是 class。 2. **`getDerivedStateFromError` 不能调 setState**:用 return 新 state。 `componentDidCatch` 才能调副作用 / 上报。 3. **dev 模式 React 显示完整错误覆盖层**:不代表 ErrorBoundary 没工作。 生产 build 才看到 fallback。 4. **forgotten reset**:fallback 里写 "重试" 按钮要调 `resetError()`, 而不是 `setState({ hasError: false })`(state 在 boundary 里, 按钮在 fallback 里)。Sentry boundary 的 `resetError` prop 帮你 处理。 5. **SSR 时 ErrorBoundary**:getDerivedStateFromError 在 SSR 跑 → 服务端如果 catch 到错误就 render fallback HTML。客户端 hydrate 时如果不同会 mismatch warning。

ML feature store:自己搭轻量版(不上 Feast / Tecton)

## 起因 ML 项目 6 个月后症状: - train code 跑 feature engineering 一遍 - predict serve code 跑 feature engineering **另一遍** - 两套实现微妙不一致 → "线上线下不一致 (train-serve skew)" - 跨 model 重复 feature 算 N 次 Feature store 解决: 1. **feature 定义统一**(train 跟 serve 同源) 2. **离线(batch)+ 在线(low-latency)双重 storage** 3. **point-in-time 正确性**(避免 future leakage) 工业级方案:**Feast** / **Tecton** / **Hopsworks**。重 + 学习曲线陡。 中小项目自己搭一个轻量的够用。 ## 轻量设计 ``` [raw data 表] ↓ [feature transformation SQL / Python] ← 唯一定义源 ↓ batch ──→ [feature 表(PG / Snowflake)] ← train 时读 realtime → [Redis] ← serve 时读 ``` 关键:**transformation 写一次**,batch 和 realtime 两边都跑同样逻辑。 ## 实现:transformation as dbt model ```sql -- models/features/user_features.sql {{ config(materialized='incremental') }} SELECT user_id, -- profile DATE_PART('year', CURRENT_DATE - birthdate) AS age, country, plan, -- recent activity (30 days) (SELECT COUNT(*) FROM events WHERE events.user_id = u.user_id AND created_at > CURRENT_DATE - INTERVAL '30 days') AS events_30d, (SELECT AVG(amount) FROM orders WHERE orders.user_id = u.user_id AND created_at > CURRENT_DATE - INTERVAL '30 days') AS avg_order_30d, CURRENT_TIMESTAMP AS computed_at FROM users u {% if is_incremental() %} WHERE updated_at > (SELECT MAX(computed_at) FROM {{ this }}) {% endif %} ``` dbt run 每小时跑一次 → `user_features` 表更新。 ## sync to Redis ```python # tasks/sync_features_to_redis.py import pandas as pd import redis import json r = redis.Redis() df = pd.read_sql("SELECT * FROM user_features WHERE computed_at > NOW() - INTERVAL '1 hour'", pg) with r.pipeline() as pipe: for _, row in df.iterrows(): key = f"features:user:{row.user_id}" pipe.hset(key, mapping=row.drop(['user_id', 'computed_at']).to_dict()) pipe.expire(key, 86400 * 2) # 2 day TTL pipe.execute() ``` 每小时跑 → Redis 永远有"最新批次"的 feature。 预测时 Redis HGETALL 一下 → 几 ms。 ## train 时用 ```python # 离线 train features_df = pd.read_sql(""" SELECT * FROM user_features WHERE computed_at <= '2025-04-30' -- 训练数据截止日 """, pg) labels_df = pd.read_sql(""" SELECT user_id, churned FROM user_outcomes WHERE outcome_date BETWEEN '2025-04-30' AND '2025-05-30' """, pg) df = features_df.merge(labels_df, on='user_id') X, y = df.drop('churned', axis=1), df.churned model.fit(X, y) ``` ## serve 时用 ```python # 在线 predict def predict_churn(user_id): features = r.hgetall(f'features:user:{user_id}') features = {k.decode(): float(v) for k, v in features.items()} X = pd.DataFrame([features])[FEATURE_COLUMNS] return model.predict(X)[0] ``` `FEATURE_COLUMNS` 是 train 时保存的 column 顺序。 顺序一致 + Redis 来的 feature 跟 train 同源 → 无 skew。 ## point-in-time train 时不要让 feature 包含 label 之后的数据(leakage): ```sql -- bad SELECT user_id, COUNT(*) FROM events WHERE user_id = u.user_id -- 没限时间 → 包含 label 期之后 -- good SELECT user_id, COUNT(*) FROM events WHERE user_id = u.user_id AND created_at < {{ label_date }} ``` 更严格的 point-in-time:feature value 用 label 时点的 snapshot: ```sql SELECT user_id, label, (SELECT events_30d FROM user_features WHERE user_id = labels.user_id AND computed_at < labels.label_date ORDER BY computed_at DESC LIMIT 1) AS events_30d FROM labels; ``` 每个 label 取它"那时" 的 feature。 ## monitoring 每天对比 train vs serve feature 分布: ```python def check_skew(): train_df = pd.read_sql("SELECT * FROM user_features TABLESAMPLE 1%", pg) serve_keys = r.scan_iter('features:user:*', count=10000) serve_df = pd.DataFrame([...]) # 拉 Redis 几千 sample for col in FEATURE_COLUMNS: train_mean = train_df[col].mean() serve_mean = serve_df[col].mean() skew = abs(train_mean - serve_mean) / train_mean if skew > 0.1: alert(f'{col} skew {skew:.2%}') ``` > 10% 飘移 → 告警。 ## 何时升级到 Feast 我们轻量方案撑到: - ~50 feature - 1 个 model in production - batch update 频率小时级 - 单团队 超过这规模: - 多 model 共享 feature - 多团队 - 需要 streaming feature(秒级更新) - 严格 point-in-time correctness 自动验证 → 上 Feast。但 Feast 学习曲线 + 运维成本,不是必要别强上。 ## 与 in-line 计算对比 | | 自己算 | feature store | |---|---|---| | 实现 | 简单 | 复杂 | | skew 风险 | 高 | 低 | | latency | 中(每次 query DB) | 低(Redis) | | 复用 | 0 | 高 | | 适合 | < 5 model | 5+ model | 第一个 model 直接 in-line 算。 第二个 model 用相同 feature → 抽出 user_features 表。 第三个 model → 上轻量 store。 N 个 → Feast。 ## 真实 case churn model 上线半年: - 一开始 in-line 算 feature - predict serve 跑 30 秒 / user(heavy SQL JOIN) - 改 feature 表(dbt)+ Redis cache - 跌到 5 ms / user - 同时 train script 用相同表 → 删除重复 code - 半年后第二个 model(LTV prediction)用同 feature 表 → 立即 reuse ## 踩过的坑 1. **dtype 不一致**:Redis 存 string,train 时 pandas 是 int → cast 错 → predict 0% 。serve code 严格 cast + 类型检查。 2. **feature drift 没监控**:上线半年后 model 慢慢失效,原因 feature distribution 变了没察觉。每周 skew check 必要。 3. **feature 名字大小写**:Redis HSET key 大小写敏感。train 大写 serve 小写 → KeyError。规范一律 snake_case。 4. **Redis TTL**:feature 旧但不 expire → 用陈旧 feature 预测 → 越 错越多。明确 TTL + sync 频率 > TTL 1/2。 5. **新 feature 加 + 老 model 还要跑**:feature 表加列,老 model 的 FEATURE_COLUMNS 不变(model 还按老 schema 输入)。 model artifact 跟 schema 绑定。

SvelteKit vs Next.js:选型实战对比

## 起因 新项目要选前端 SSR framework。两个主流: - **Next.js**(React,Vercel):业界事实标准 - **SvelteKit**(Svelte,Vercel):更小 bundle,更直觉 最近用 SvelteKit 做一个新项目,Next.js 维护两个老项目。下面对比。 ## 文件结构 Next.js(app router): ``` app/ layout.tsx page.tsx blog/ [slug]/ page.tsx # /blog/:slug api/ posts/route.ts # GET/POST /api/posts ``` SvelteKit: ``` src/routes/ +layout.svelte +page.svelte # / blog/ [slug]/ +page.svelte # /blog/:slug +page.server.ts # server-side load api/ posts/+server.ts # GET/POST /api/posts ``` 类似的 file-based routing,约定不同。 ## 写法对比 ```tsx // Next.js page export default async function Page({ params }: { params: { slug: string } }) { const post = await db.posts.findFirst({ where: { slug: params.slug } }); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.body }} /> </article> ); } ``` ```svelte <!-- SvelteKit +page.svelte --> <script lang="ts"> export let data; // 从 +page.server.ts load 函数来 let { post } = data; </script> <article> <h1>{post.title}</h1> {@html post.body} </article> ``` ```ts // +page.server.ts export async function load({ params }) { const post = await db.posts.findFirst({ where: { slug: params.slug } }); return { post }; } ``` SvelteKit 强制分离 server load 跟 view,Next.js app router 同 file (async component)。 SvelteKit 模板语法更接近 HTML(`{@html}` `{#if}` `{#each}`)。 React 是 JS-first(JSX)。 ## bundle size baseline blank page: - Next.js: ~85 KB JS(React runtime + Next router) - SvelteKit: ~10 KB JS(Svelte compile 到 vanilla JS) Svelte 是编译时框架,runtime 极小。 React 是 runtime 框架,最小也几十 KB。 实际复杂 app: - Next.js: 200-500 KB - SvelteKit: 50-200 KB 老旧移动设备 / 网络差地区差异显著。 ## 性能 SSR / 静态生成性能两者接近(看 hosting / CDN)。 客户端 hydration: - React: ~150ms typical page - Svelte: ~30ms Svelte 编译时已生成 DOM 更新代码,hydration 简单。 React 要重新跑 component tree 对比 virtual DOM。 ## state 管理 React:useState / useReducer / Zustand / Redux / Jotai 等。 Svelte 5(runes): ```svelte <script> let count = $state(0); let doubled = $derived(count * 2); $effect(() => { console.log(`count: ${count}`); }); </script> <button on:click={() => count++}>+1</button> <p>doubled: {doubled}</p> ``` `$state` / `$derived` / `$effect` 内置 reactive primitive。 不需要库。 跨组件用 store: ```ts // stores.ts import { writable } from 'svelte/store'; export const user = writable(null); // 组件 import { user } from './stores'; $user // 自动 subscribe ``` React 同样需求要 Zustand / Jotai / Context。 ## ecosystem | | Next.js | SvelteKit | |---|---|---| | UI 库 | 极多(Material / Chakra / shadcn 等等) | 中(Skeleton / SVeltestrap / shadcn-svelte) | | component snippets | 几万 | 几千 | | 招聘 React | 海量 | 中 | | 第三方教程 | 海量 | 中 | | Vercel / 部署 | 一等公民 | 一等公民 | React 生态优势大。 ## 部署 两者都 Vercel friendly(一键 git push 部署)。 Cloudflare Pages / Netlify / 自托管也都 OK。 SvelteKit 的 adapter 概念:同 codebase 部署到不同 platform: ```ts // svelte.config.js import adapter from '@sveltejs/adapter-cloudflare'; // or node / vercel / netlify export default { kit: { adapter: adapter() }, }; ``` Next.js 也行但 vendor lock 严重些。 ## 学习曲线 Svelte 新人:HTML + JS 像,3-5 天上手。 React 新人:JSX + hook 概念多,1-2 周上手。 但 React 后续生态 / TypeScript 集成成熟,深入资料无限。 Svelte 5 runes 是新(2024)变化大,老教程过时。 ## 实战 case:从 React 迁 Svelte 我们一个内部 admin tool 原 React (CRA),bundle 800KB。 迁 SvelteKit: - 3 周重写 - bundle 180KB - 加载快 2-3x - 代码行数 -40% 主要节省: - 不用 useEffect 注意 dep array - 表单写法简洁(`bind:value`) - 不用 Redux(store 几行) 但缺点: - 团队学习 - 某些 React 专用库(react-flow 之类)没 Svelte 等价 → 自己写或者用 web component ## React 仍胜的场景 - 大型团队 / 已有 React 经验 - React Native 共享 UI - 极丰富的第三方组件依赖 - Server Components(React 19+)+ Next.js 强大 ## SvelteKit 仍胜的场景 - 性能 critical(移动 / 老设备) - 小团队 + 偏 web 标准 - 内容站 / 静态化重 + SSR - 想少写代码 ## 部署一个 demo ```bash npm create svelte@latest my-app cd my-app && npm install npm run dev # localhost:5173 ``` ```bash npx create-next-app@latest my-app cd my-app && npm run dev # localhost:3000 ``` 两个都 5 分钟跑起来。 ## 与其它框架对比 - **Astro**:内容站王者,多框架混用,islands 架构。SSR 比 Next / SvelteKit 简单 - **Nuxt**(Vue):Vue 圈的 Next,类似哲学 - **SolidStart**:Solid.js 的 framework,性能极好 - **Remix**:React 路线,更"web 标准 first"(已并入 React Router 7) 新项目 2026 视角: - 内容站 → Astro - 应用 + 团队 React → Next.js - 应用 + 性能 / 简洁 → SvelteKit - 应用 + Vue 团队 → Nuxt ## 踩过的坑 1. **SvelteKit prerender 错配**:以为静态生成,结果运行时还跑 → 用 `export const prerender = true` 显式标记。 2. **server-only code 漏到 client**:API key 写在 +page.svelte 里 → bundle 到 client。server code 放 +page.server.ts / +server.ts。 3. **Next.js app router 学习陡**:server components / client components 边界要清。误用 hook 报错难懂。 4. **Svelte 5 vs 4 转换**:runes 是新,老 reactive `let count = 0; $: doubled = count * 2` 仍兼容但官方推 runes。教程乱。 5. **TypeScript 集成**:SvelteKit 比 Next 略弱(template 内的类型推断)。 严格项目要小心。

CSS scroll-snap:用 CSS 写"翻页式"滚动(轮播 / 卡片流)

## 起因 要做一个商品轮播 + 滑到中间自动对齐 + 触屏滑动也丝滑。 传统做法用 swiper.js / react-slick 等库,bundle 几十 KB + 复杂 API。 `scroll-snap` CSS 几行原生实现,所有现代浏览器全支持,零 JS。 ## 1. 横向轮播 ```html <div class="carousel"> <div class="card">A</div> <div class="card">B</div> <div class="card">C</div> <div class="card">D</div> </div> <style> .carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; /* 横向 + 强制 snap */ scroll-padding: 16px; gap: 16px; padding: 16px; } .card { flex: 0 0 80%; /* 每个卡片占 80% 视口宽 */ scroll-snap-align: center; /* 滑动停在卡片中心 */ background: #eee; height: 200px; border-radius: 12px; } </style> ``` 效果: - 横向滑动 / swipe - 松手后自动对齐到最近卡片中心 - 触屏 momentum 滚动 + snap 一起 work - 鼠标 wheel / 键盘箭头都 work **完全 CSS**。 ## 2. `mandatory` vs `proximity` ```css scroll-snap-type: x mandatory; /* 强制:松手必停 snap 点 */ scroll-snap-type: x proximity; /* 接近:靠近 snap 点才 snap */ ``` `mandatory` 适合"看一个" 的轮播;`proximity` 适合"长列表偶尔对齐" 场景。 ## 3. snap-align 选项 ```css scroll-snap-align: start; /* 元素左边对齐 */ scroll-snap-align: center; /* 元素居中对齐 */ scroll-snap-align: end; /* 元素右边对齐 */ ``` ## 4. 全屏式纵向"翻页" ```html <main class="pages"> <section>第一屏</section> <section>第二屏</section> <section>第三屏</section> </main> <style> html { scroll-snap-type: y mandatory; } /* 整页 snap */ section { height: 100vh; scroll-snap-align: start; } </style> ``` 或者 `mandatory` 在 html 上: ```css html, body { height: 100%; scroll-snap-type: y mandatory; } section { scroll-snap-align: start; min-height: 100vh; } ``` 效果:滚轮 / 滑动按"屏" 翻页,落地页常用。 ## 5. snap-stop:必须 stop 在某些元素 ```css .important-card { scroll-snap-align: center; scroll-snap-stop: always; /* 不允许"飞越"这个元素 */ } ``` 普通卡片可以滚很远跳过;`always` 强制每个都停。 高 momentum 滑动时 prevent 飞过去。 ## 6. 进度指示器(dot / progress) scroll-snap 本身没有"当前第几页" 信息。要做 indicator dot: ```tsx function Carousel({ items }) { const ref = useRef<HTMLDivElement>(null) const [active, setActive] = useState(0) useEffect(() => { const el = ref.current if (!el) return const observer = new IntersectionObserver( (entries) => { entries.forEach(e => { if (e.isIntersecting) { const idx = Array.from(el.children).indexOf(e.target as HTMLElement) setActive(idx) } }) }, { root: el, threshold: 0.5 } ) Array.from(el.children).forEach(c => observer.observe(c)) return () => observer.disconnect() }, []) return ( <> <div ref={ref} className="carousel"> {items.map(item => <div key={item.id} className="card">{item.title}</div>)} </div> <div className="dots"> {items.map((_, i) => ( <span className={i === active ? 'active' : ''} /> ))} </div> </> ) } ``` `IntersectionObserver` 检测哪个卡片当前可见。 没 JS 也能 snap,只是没 indicator。 ## 7. 前进 / 后退按钮 ```tsx function Carousel({ items }) { const ref = useRef<HTMLDivElement>(null) function scroll(dir: number) { const el = ref.current if (!el) return const cardWidth = el.firstElementChild?.clientWidth ?? 0 el.scrollBy({ left: cardWidth * dir, behavior: 'smooth' }) } return ( <> <button onClick={() => scroll(-1)}>←</button> <button onClick={() => scroll(1)}>→</button> <div ref={ref} className="carousel"> {items.map(...)} </div> </> ) } ``` `scrollBy + smooth` 程序触发滚动 + snap 自动配合。 ## 8. 移动端 momentum iOS Safari 需要: ```css .carousel { -webkit-overflow-scrolling: touch; /* 老 iOS 启 momentum */ } ``` 现代 iOS 默认 momentum,但写上也无害。 ## 9. 隐藏 scrollbar 视觉上不要 scrollbar 但仍可滚: ```css .carousel { /* Firefox */ scrollbar-width: none; /* Chrome / Safari */ &::-webkit-scrollbar { display: none; } } ``` ## 完整示例:商品轮播 ```html <div class="product-carousel"> <div class="product">商品 1</div> <div class="product">商品 2</div> <div class="product">商品 3</div> <div class="product">商品 4</div> <div class="product">商品 5</div> </div> <style> .product-carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; gap: 12px; padding: 16px; scrollbar-width: none; } .product-carousel::-webkit-scrollbar { display: none; } .product { flex: 0 0 calc(50% - 18px); /* 移动端每屏 2 个 */ scroll-snap-align: start; background: white; border: 1px solid #ddd; border-radius: 8px; padding: 12px; } @media (min-width: 768px) { .product { flex: 0 0 calc(25% - 12px); /* 桌面每屏 4 个 */ } } </style> ``` 响应式 + snap + 美观 < 30 行 CSS。 ## 11. 跟 Swiper.js 对比 | | scroll-snap CSS | Swiper.js | |---|---|---| | bundle | 0 | 30-100 KB | | 学习成本 | 极低(CSS 属性) | 中(API + plugin) | | 灵活度 | 中(CSS 限制) | 极高(事件 / 插件) | | 动画 / parallax | 困难 | 简单 | | autoplay | 自己写 JS | 内置 | | nested swiper | 难 | 支持 | | 多 breakpoint | media query | 内置 | 简单轮播 / 卡片流 → scroll-snap。 复杂动画 / autoplay / parallax / 大量 feature → Swiper。 ## 12. 浏览器支持 scroll-snap 全 evergreen 浏览器支持(Safari 11+ / Firefox 39+ / Chrome 69+ / Edge 79+)。 **不需要 polyfill**。 老 IE 不支持但本来也没人 care。 ## 实战 use case 我们网站几处用 scroll-snap 后: - 删除 swiper.js 依赖 → bundle 减 60 KB - 触屏滑动体验跟原生 app 一致 - 维护成本 0(CSS 一直 work) 不适合: - 复杂 carousel(autoplay / parallax / cube transition) - 多嵌套 swiper ## 踩过的坑 1. **scroll-snap-type 在父,align 在子**:写反了不 work。 2. **flex item 没设 `flex: 0 0 ...`** → 自动收缩 → 看起来都对齐 但宽度不对。强制 `flex-shrink: 0`。 3. **mobile 触屏 snap 不灵敏**:低端 Android 偶尔 snap 慢。 设 `scroll-snap-stop: always` 让必停每个。 4. **snap 让锚点跳乱**:含 `#anchor` URL 时 snap 偶尔覆盖 scroll-to-anchor。 配 `scroll-padding-top` 给固定 header 留位置。 5. **iOS Safari momentum 衰减**:iOS 上 momentum 后才 snap,感觉 slightly delayed。这是 OS 行为,无解。

让 Debian / Ubuntu 自动打安全补丁(unattended-upgrades 实战配置)

服务器最常见的安全事故不是 0day,而是已经有补丁但没装。 `unattended-upgrades` 让 Debian 家族的服务器自动应用安全更新, 代价只是十分钟的配置。 适用:单机 / 小集群。集群规模上去后用 Ansible / Salt 统一管。 ## 安装 ```bash sudo apt update sudo apt install -y unattended-upgrades apt-listchanges sudo dpkg-reconfigure --priority=low unattended-upgrades # 这一步会写一个最小的 /etc/apt/apt.conf.d/20auto-upgrades ``` ## 配置——只装安全更新 `/etc/apt/apt.conf.d/50unattended-upgrades` 是核心配置。 最小工作版: ``` Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}-security"; "${distro_id}ESMApps:${distro_codename}-apps-security"; "${distro_id}ESM:${distro_codename}-infra-security"; }; Unattended-Upgrade::Package-Blacklist { "linux-image-*"; // 自己挑时间重启更稳 "linux-headers-*"; }; Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; // 如果允许自动重启,把上面改为 true,再设: // Unattended-Upgrade::Automatic-Reboot-Time "04:00"; Unattended-Upgrade::Mail "[email protected]"; Unattended-Upgrade::MailReport "on-change"; ``` 只放 `*-security` 源,不会把 `-updates`(功能更新)也拉进来, 风险最小。 ## 调度 `/etc/apt/apt.conf.d/20auto-upgrades`: ``` APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Download-Upgradeable-Packages "1"; APT::Periodic::AutocleanInterval "7"; APT::Periodic::Unattended-Upgrade "1"; ``` 四个数字单位是 "天"。上面这套是:每天 update + 下载 + 跑 unattended, 每周 autoclean 一次缓存。 实际触发由 systemd timer 接管: ```bash systemctl list-timers apt-daily.timer apt-daily-upgrade.timer ``` ## 演练 + 校验 ```bash # 不实际安装,只显示会做什么 sudo unattended-upgrade --dry-run --debug | tail -30 # 看上次实际运行的日志 sudo tail -50 /var/log/unattended-upgrades/unattended-upgrades.log # 看是否需要重启(更新内核 / glibc / 某些库后会显示) ls /var/run/reboot-required 2>/dev/null && \ cat /var/run/reboot-required.pkgs ``` ## 邮件通知(可选) 要收 `MailReport` 邮件,本机需要能寄信。最轻量是 msmtp + cron-aliases: ```bash sudo apt install -y msmtp msmtp-mta bsd-mailx ``` ``` # /etc/msmtprc account default host smtp.example.com port 587 auth on user notifier password ... from [email protected] tls on tls_starttls on ``` `sudo chmod 600 /etc/msmtprc`,然后 `echo test | mail -s 'hi' [email protected]` 能收到就 OK。 ## 踩过的坑 - `Automatic-Reboot "true"` 在跑数据库或长任务的机器上是灾难。 默认关掉,自己写个监控脚本看到 `/var/run/reboot-required` 就告警。 - 某些第三方 PPA 的源 origin 字符串不是 `Ubuntu:jammy-security`,需要 把它显式加进 `Allowed-Origins`,否则永远装不上更新。可以用 `apt-cache policy <package>` 看具体的 origin。 - 关闭 SSH 期间不要触发自动重启 —— 可能升级了 openssh-server 后 sshd 没起来。 我们的做法是把 `Automatic-Reboot-Time` 设在白天有人值班的时段。

现代 CLI 替代品:bat / eza / sd / dust / procs / duf

## 起因 `cat` / `ls` / `sed` / `du` / `ps` / `df` 用了几十年。够用,但: - 默认输出朴素,看不出结构 - 性能没跟上多核 CPU - 选项 / 标志难记 Rust / Go 写的现代替代品,**默认就好用**: | 老 | 新 | 干啥 | |---|---|---| | cat | bat | 查看文件 + 高亮 | | ls | eza (前 exa) | 列目录 + 图标 + git status | | sed | sd | 简单替换 | | du | dust | 磁盘占用 visualization | | df | duf | 磁盘空间分区 | | ps | procs | 进程列表 | | find | fd | 文件搜索 | | grep | rg | 文本搜索 | | top | btop | 系统监控 | | tldr | tealdeer | man 替代(例子) | 下面挨个看。 ## bat (cat 替代) ```bash brew install bat ``` ```bash $ bat README.md ``` - 语法高亮(300+ 语言) - 显示行号 - 长文件自动 pager(按 q 退) - git diff 标记(左侧 +/-) ```bash bat -p file.json # 不要 pager / 行号 bat -A file.txt # 显示空白字符(debug 缩进) bat --diff # diff 高亮 ``` alias: ```bash alias cat='bat -p' # 当 cat 用 alias less='bat' # 当 pager 用 ``` vim 类似 less 但更好。 ## eza (ls 替代) ```bash brew install eza ``` ```bash $ eza -l --icons --git .rw-r--r-- u 1.2K Mar 14 10:23 README.md .rw-r--r-- u 234 Mar 14 10:25 package.json M drwxr-xr-x u - Mar 14 10:25 src -- 1 ``` - 颜色按文件类型 - NerdFont 图标 - git status 集成 - tree 模式:`eza --tree` 我的 alias: ```bash alias ls='eza --group-directories-first' alias ll='eza -l --icons --git' alias lt='eza --tree -L 2' alias la='eza -la --icons' ``` ## sd (sed 替代) ```bash brew install sd cargo install sd ``` 简单替换,语法直觉: ```bash # sed sed -i 's/foo/bar/g' file.txt # sd sd 'foo' 'bar' file.txt ``` - 默认 PCRE regex(不像 sed POSIX) - 不用 `\(\)`,用 `()` - 不用 escape `/` - 默认 in-place ```bash sd 'class (\w+)' 'struct $1' src/*.rs # 替换 + capture group sd -p 'foo' 'bar' file.txt # preview 不写 ``` sed 仍胜的场景:复杂 stream editor(s/// + d + p 命令组合)。 90% 简单替换 → sd。 ## dust (du 替代) ```bash brew install dust ``` ```bash $ dust 9.0G ┌── node_modules 14.0G ┌─┴ project_a 5.0G ┌─┴ projects 24.0G ┴ /home ``` ASCII 树 + 大小条 + 自动按大小排序。比 `du -sh * | sort -h` 直接。 ```bash dust -d 3 # 深度 3 dust -n 30 # 显前 30 大 dust -X .git # 排除 ``` ## duf (df 替代) ```bash brew install duf ``` ``` ╭────────────────────────────────────────────────────────────────╮ │ 3 local devices │ ├──────────────┬──────────┬────────┬─────────┬───────┬───────────┤ │ MOUNTED ON │ SIZE │ USED │ AVAIL │ USE% │ FILESYS │ ├──────────────┼──────────┼────────┼─────────┼───────┼───────────┤ │ / │ 466.0 GB │ 200 GB │ 246 GB │ [████░░░░░░] 45% │ │ /boot/efi │ 100.0 MB │ 30 MB │ 70 MB │ [████░░░░░░] 31% │ ╰──────────────┴──────────┴────────┴─────────┴───────┴───────────╯ ``` 彩色 bar + 按 mount point 分组。`df` 的丑表格再见。 ## procs (ps 替代) ```bash brew install procs ``` ```bash $ procs node PID: 1234 USER: alice CPU: 12.3% MEM: 234MB START: 10:23 STATE: Sleeping CMD: node server.js ``` 彩色 + 友好单位 + tree mode: ```bash procs --tree procs --sortd cpu # 按 CPU 降序 ``` ## btop (top / htop 替代) ```bash brew install btop ``` GUI-like TUI 监控,键盘鼠标都能用。比 htop 更现代。 ``` ╔═CPU═══════════════════════╗ ╔═MEM═════════════╗ ║ 1 ███████████░░ 56% ║ ║ Total 16.0 GB ║ ║ 2 ████░░░░░░░░░ 22% ║ ║ Free 3.2 GB ║ ║ 3 ███░░░░░░░░░░ 15% ║ ║ Cached 4.5 GB ║ ║ 4 ██░░░░░░░░░░░ 10% ║ ╚═════════════════╝ ╚═══════════════════════════╝ ``` ## tldr (man 替代,例子) ```bash brew install tealdeer # Rust 实现,比 nodejs 版快 ``` ```bash $ tldr tar tar - Archiving utility. - Create an archive from files: tar -cf path/to/target.tar path/to/file1 path/to/file2 - Extract: tar -xf path/to/source.tar - List contents: tar -tvf path/to/source.tar ``` 5 个常用例子,不像 man 一屏密密麻麻看不进去。 ## 我的 .zshrc 完整 alias 块 ```bash # Modern CLI replacements # cat → bat command -v bat &>/dev/null && alias cat='bat -p' # ls → eza if command -v eza &>/dev/null; then alias ls='eza --group-directories-first' alias ll='eza -l --icons --git' alias la='eza -la --icons --git' alias lt='eza --tree -L 2' fi # find → fd command -v fd &>/dev/null && alias find='echo "use fd";' # grep → rg # (don't alias, keep grep for scripts) # du → dust command -v dust &>/dev/null && alias du='dust' # df → duf command -v duf &>/dev/null && alias df='duf' # ps → procs (for human use, scripts keep ps) # don't alias # top → btop command -v btop &>/dev/null && alias top='btop' ``` ## 不替代 / 谨慎替代 - **grep**:很多 script 用 `grep -E` 之类,alias rg 会破。手动用 `rg`。 - **find**:很多 script 用 `find -exec`,alias fd 会破。 - **ps**:进程间通信 / script 解析常用 ps 的 well-known column 输出。 不要 alias。 - **sed**:stream pipeline 中 sed 仍最广。 **人交互用新 / script 用老**。alias 加 `if [[ -t 1 ]]` 检查 tty, 避免 pipeline 触发。 ## 一次性试 不想 alias 永久 → 装了直接调名字: ```bash $ bat file.md $ eza -l $ dust ``` 朴素需求老命令、想要好看 / 信息多用新工具。 ## 性能 / 资源 这些 Rust / Go 工具普遍比传统 C 实现快 1.5-5x。 启动开销小(无 JVM 之类)。 单 binary 没 dep,下个 portable 文件就跑。 ## 踩过的坑 1. **NerdFont 字符方块**:eza --icons 显示乱码 → 终端字体没装 NerdFont。 2. **CI 没装替代品**:本地 alias `find=fd` 习惯后写 script `find` → CI 报错。 alias 只在 interactive shell 加。 3. **eza 旧版叫 exa**:教程到处写 exa,已 abandoned 改 eza。fork 后 保持兼容。 4. **bat 没装 themes**:默认 monokai。喜欢 dracula / nord 等 → `bat --list-themes` + `bat --theme=Dracula file`。 5. **远程 server 没装**:ssh 上去 alias 不在。要么 dotfiles 同步 + ssh 自动装。

DVC 给数据集做版本控制:数 GB 文件不进 git 也能 reproduce

## 起因 ML 项目的代码可以 git 管,但数据集(几百 MB / 几 GB / 几十 GB)不能进 git。 结果是"我两个月前那个 SOTA 实验用的是哪份数据?" 完全说不清。 git 只能 commit 一个 `data.csv` 指针 / README 描述,没法保证可复现。 `DVC` 把这个问题解了:在 git 里只 commit 一个"数据元信息文件"(指针 + hash),实际数据存对象存储 / S3 / SSH 服务器。`dvc pull` 拉对应 hash 的 数据,整套实验完全可复现。 ## 解决方案 ### 装 ```bash uv add 'dvc[s3]' # 后端用 S3;其它有 [gs] [azure] [ssh] [gdrive] # 或 brew install dvc dvc version ``` ### 初始化 在已有 git 仓库里: ```bash dvc init git commit -m 'init dvc' ``` DVC 在 `.dvc/` 建配置目录。 ### 配远程存储 ```bash dvc remote add -d myremote s3://my-bucket/dvc-storage dvc remote modify myremote endpointurl https://s3.example.com # 兼容 minio # AWS S3 凭据走 ~/.aws/credentials 或环境变量 git commit .dvc/config -m 'add s3 remote' ``` ### 添加数据 ```bash dvc add data/train.parquet # 输出:data/train.parquet.dvc + .gitignore 更新 git add data/train.parquet.dvc data/.gitignore git commit -m 'add train dataset v1' # 推送数据到 S3 dvc push ``` `.dvc/cache/` 是本地缓存。`data/train.parquet.dvc` 文件长这样: ```yaml outs: - md5: 6c7a3b8d9e2f4a1c5b8e7d6f3a2c4b5e size: 234567890 path: train.parquet ``` 只 28 行 YAML 进 git,原始几百 MB 数据进 S3。 ### 别人 / 别机器拉 ```bash git clone my-project cd my-project dvc pull # 自动拉所有 .dvc 文件对应的数据 ``` 切某个 git commit → `dvc pull` 自动拉那个版本的数据。 ### Pipeline(DAG) DVC 的核心还在于 pipeline 定义 + 自动 cache。`dvc.yaml`: ```yaml stages: prep: cmd: python src/prep.py deps: - src/prep.py - data/raw.parquet outs: - data/clean.parquet train: cmd: python src/train.py --data data/clean.parquet --out models/model.pt deps: - src/train.py - data/clean.parquet params: - lr - epochs outs: - models/model.pt metrics: - metrics.json eval: cmd: python src/eval.py deps: - models/model.pt - data/test.parquet metrics: - eval.json ``` 跑: ```bash dvc repro # 跑全 pipeline,按依赖关系 + 自动跳过未变 stage dvc repro train # 只跑 train + 下游 dvc dag # 看 DAG ``` `params.yaml`: ```yaml lr: 0.001 epochs: 10 ``` 改 `params.yaml` 里某个值 → `dvc repro` 只重跑受影响 stage。 没改的 stage 命中 cache 秒级返回。 ### 实验对比 ```bash dvc exp run -S lr=0.005 -S epochs=20 dvc exp run -S lr=0.001 -S epochs=30 dvc exp show # 表格对比所有实验的 params + metrics ``` `-S` 临时改参数。每次 exp 产生独立分支,不污染 main。 ## 效果 - 数据从 git 中消失(仓库从 5 GB 降到 12 MB) - 切换数据版本 = git checkout + dvc pull,分钟级 - 多人同时改不同 stage 不冲突(每人本地 cache 各自命中) - "上次 SOTA 用的是哪份数据" 永远答得清 - CI 里 `dvc pull` + `dvc repro` 复现实验 ## 与 git-lfs / DataLad 对比 | | git-lfs | DVC | DataLad | |---|---|---|---| | 数据大文件 | ✅ | ✅ | ✅ | | Pipeline | ❌ | ✅ | ❌ | | 实验跟踪 | ❌ | ✅ | ❌ | | 多 backend | 仅 GitHub LFS | S3 / GCS / SSH / 多 | 多 | | 学习曲线 | 低 | 中 | 高 | 只存数据用 git-lfs;要做 ML pipeline 用 DVC。 ## 踩过的坑 1. **第一次 `dvc add` 大文件慢**:要算 md5 + 复制到 .dvc/cache/。 `dvc config cache.type symlink` 用软链不复制,省时省空间但 `.dvc/cache/` 不能跨文件系统。 2. **没 push 就 commit + push git**:别人 `git pull` + `dvc pull` 拉不到 数据。养成 `dvc push` 在 `git push` 前的习惯。 3. **.dvc/cache 占满磁盘**:本地保留所有版本 + 多分支切换累积。 `dvc gc --workspace` 清掉工作区当前 commit 用不到的; `dvc gc --all-commits` 极致清。 4. **multiple users 写同一 stage**:`dvc lock` 防止并发 repro 撞车。 或者 stage outputs 必须确定,random_seed 要固定。 5. **大数据集多人 train**:每人都 `dvc pull` 几十 GB 浪费带宽。可以 配 dvc 远程在共享 NFS 上,所有人挂载到本地 `.dvc/cache/` 共享, 配 cache.type=symlink。