知识广场

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

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

Dagster vs Airflow vs Prefect:现代 ETL pipeline 选哪个

## 起因 要搭一个数据 pipeline:每天凌晨 1 点从 S3 拉新数据 → 清洗 → 入 ClickHouse → 跑特征工程 → 训 ML model → push 到 staging。 失败要重试 + 告警 + 部分重跑。 三个主流编排工具: - **Airflow**:老牌,2014 出自 Airbnb,业界事实标准 - **Prefect**:Airflow 工程师出走重新设计,更轻量 - **Dagster**:从数据资产角度重新思考 pipeline,2024 风头最劲 试了一周下来记录。 ## Airflow 定义 DAG: ```python # airflow_dag.py from datetime import datetime, timedelta from airflow import DAG from airflow.operators.python import PythonOperator from airflow.operators.bash import BashOperator default_args = { 'owner': 'data-team', 'retries': 2, 'retry_delay': timedelta(minutes=5), } with DAG( 'daily_etl', default_args=default_args, schedule='0 1 * * *', start_date=datetime(2024, 1, 1), catchup=False, max_active_runs=1, ) as dag: def fetch_data(**ctx): ds = ctx['ds'] # '2024-05-24' download_from_s3(f's3://bucket/raw/{ds}/') def clean_data(**ctx): ds = ctx['ds'] clean(input_dir, output_dir) fetch = PythonOperator(task_id='fetch', python_callable=fetch_data) clean = PythonOperator(task_id='clean', python_callable=clean_data) load = PythonOperator(task_id='load', python_callable=load_to_ch) train = PythonOperator(task_id='train', python_callable=train_model) fetch >> clean >> load >> train ``` 跑: ```bash airflow webserver airflow scheduler # 浏览器 http://localhost:8080 看 DAG ``` **优点**: - 业界最广,会的人多 - operator 库巨大(Bash / Spark / EMR / Snowflake / Postgres / Slack / ...) - 成熟的 SLA / lineage / retry 机制 - K8s 部署成熟(KubernetesExecutor / KubernetesPodOperator) **缺点**: - DAG = 全局 Python module,导入慢(scheduler 反复 import 几千 DAG) - 任务间传数据麻烦(XCom 限大小;走 S3 / DB 才行) - 本地开发体验差(webserver + scheduler + executor 三件套) - DAG 语法重,3 个 task 也 50 行 ## Prefect ```python # prefect_flow.py from prefect import flow, task from datetime import datetime, timedelta @task(retries=2, retry_delay_seconds=300) def fetch_data(date: str) -> str: path = f's3://bucket/raw/{date}/' download_from_s3(path) return path @task def clean_data(raw_path: str) -> str: out = clean(raw_path) return out @task def load_to_ch(clean_path: str): load(clean_path) @task def train_model(): train() @flow(name='daily_etl', log_prints=True) def daily_etl(date: str = None): date = date or datetime.utcnow().date().isoformat() raw = fetch_data(date) clean = clean_data(raw) load_to_ch(clean) train_model() if __name__ == '__main__': daily_etl() ``` 跑: ```bash # 本地直接跑 python prefect_flow.py # 部署到 Prefect Cloud / 自托管 server prefect deploy prefect_flow.py:daily_etl --name nightly --cron '0 1 * * *' prefect worker start --pool default ``` **优点**: - 写 flow 像写普通 Python 函数,task 间传值像普通调用 - 本地开发体验好(直接 run flow) - Prefect Cloud 免费层够小团队用 - 动态 task / sub-flow 支持好 **缺点**: - 生态不如 Airflow(operator / connector 少) - 2.0 vs 3.0 几次大改 breaking - 自托管 server 还需要装 Postgres + Redis 等 ## Dagster ```python # dagster_pipeline.py from dagster import asset, AssetExecutionContext, Definitions, ScheduleDefinition, define_asset_job @asset def raw_data(context: AssetExecutionContext) -> str: ds = context.partition_key # '2024-05-24' return download_from_s3(f's3://bucket/raw/{ds}/') @asset(deps=[raw_data]) def cleaned_data(context, raw_data: str) -> str: return clean(raw_data) @asset(deps=[cleaned_data]) def clickhouse_table(cleaned_data: str): load_to_ch(cleaned_data) @asset(deps=[clickhouse_table]) def ml_model() -> str: return train() daily_job = define_asset_job('daily', selection='*') daily_schedule = ScheduleDefinition(job=daily_job, cron_schedule='0 1 * * *') defs = Definitions( assets=[raw_data, cleaned_data, clickhouse_table, ml_model], schedules=[daily_schedule], ) ``` 跑: ```bash dagster dev # http://localhost:3000 看 asset 图 ``` **优点**: - "asset-based" 思维:每个 task 产出一个数据资产 (table / file / model),blueprint = asset 图,自然 documentation - IDE 友好:type hint + 静态分析整套 pipeline - backfill / partition / lineage 一等公民 - 内置数据测试 / asset checks - 本地开发 = `dagster dev` 一条命令 **缺点**: - 最新(2019),生态比 Airflow 小 - 学习曲线略陡(asset / op / job / sensor 几个概念) - 团队上手时间长 ## 选型对比 | 场景 | 推荐 | |---|---| | 已经在用 Airflow + 团队熟 | 继续 Airflow | | 新项目 + 现代 Python + IDE 重度 | **Dagster** | | 极简 + 个人项目 + Cloud SaaS | Prefect | | 万千 DAG + 跨团队 + 老系统对接 | Airflow(生态最广) | | ML pipeline + 强 data lineage | Dagster | 我个人新项目首选 Dagster:asset 抽象比 task 更贴合"数据流"思维。 ## 共同 best practice 不管选哪个: ### 1. 任务幂等 ```python @task def load_to_ch(path): # 用 INSERT OR REPLACE / DELETE then INSERT 让重跑不重复 db.execute(f"DELETE FROM table WHERE date = '{date}'") db.execute(f"INSERT INTO table SELECT * FROM read_parquet('{path}')") ``` partition / date 当业务 key,重跑刷掉这个 partition 再插。 ### 2. 数据 contract 定义"这个 task 输出什么 schema / row count / 范围": ```python @asset def cleaned_data(...) -> Output: df = ... return Output( value=df, metadata={ 'rows': len(df), 'columns': list(df.columns), 'null_rate': df.isna().mean().to_dict(), } ) ``` 下游任务依赖 contract 而非 implicit 假设。下次改变 schema 显式报错。 ### 3. 分层 按 dbt 风格: ``` sources/ # 原始数据 staging/ # 轻清洗 / 类型转换 intermediate/ # 业务逻辑组合 marts/ # 业务可用的 final table ``` 每层独立可测试 + 故障 isolate。 ### 4. 告警 + 监控 - task 失败 → Slack / 邮件 - task 跑太久 → SLA miss 告警 - 数据指标异常(行数突变) → 检查告警 - Prometheus 暴露 task duration / success rate ### 5. backfill 历史日期重跑: ```bash # Airflow airflow dags backfill -s 2024-01-01 -e 2024-05-01 daily_etl # Dagster dagster job backfill --job daily --partitions '2024-01-01..2024-05-01' # Prefect prefect deployment run 'daily_etl/nightly' --param date=2024-01-15 ``` ### 6. data lineage / catalog 知道"这个表是哪些 task 生成的,哪些下游用了它"。 Dagster 内置;Airflow 接 OpenLineage / Marquez;Prefect 类似。 ## 整套部署(举例 Dagster + K8s) ``` - dagster-webserver (UI) - dagster-daemon (scheduler + sensor) - code locations (你的 pipeline Python) - PostgreSQL (metadata) - S3 / GCS (intermediate storage) - K8s(task 在 pod 跑) ``` helm chart 一键: ```bash helm install dagster dagster/dagster --set ... ``` ## 效果 我们的项目用 Dagster: - 100+ assets 自动算依赖图 - 单次 backfill 半年数据:UI 选时间范围 + run,asset 自动并行 - 数据 schema 改动 → asset check fail → CI 拦下 → 改下游 → 再合 - 新成员看 asset 图 30 秒理解整套 pipeline ## 踩过的坑 ### Airflow 类 1. **DAG 太多 scheduler 慢**:每 30s 扫所有 DAG 文件 import。 把 DAG 分文件 + 用 dynamic DAG generation 减少 scheduler IO。 2. **XCom 大数据**:传几 MB 会爆 backend DB。task 间传 path(S3 key) 而非数据本身。 ### Prefect 类 3. **2.x → 3.x breaking**:API 大改。生产 pin 大版本 + 升级提前测。 ### Dagster 类 4. **asset partition 配错**:同 asset 一个 daily 一个 hourly partition 会冲突。统一一 asset 一种 partition scheme。 5. **monorepo 多 code location**:每个 location 独立 Python 进程, 依赖隔离好但启动慢。开发期合并;生产分。

Storybook 8:组件开发 / 文档 / 视觉回归一站搞定

Storybook 是组件开发的 IDE:在隔离环境里写组件 + 各种状态展示 + 自动文档 + 视觉回归测试。Storybook 8 升级了 Vite-first / 性能大涨。 ## 安装 ```bash npx storybook@latest init # 自动检测项目类型(React/Vue/Svelte 等),生成 .storybook/ 配置 ``` `package.json` 加 script: ```json { "scripts": { "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" } } ``` ## 第一个 story ```tsx // src/components/Button.tsx export function Button({ children, variant = 'primary', onClick }) { return <button className={`btn btn-${variant}`} onClick={onClick}>{children}</button> } // src/components/Button.stories.tsx import type { Meta, StoryObj } from '@storybook/react' import { Button } from './Button' const meta: Meta<typeof Button> = { title: 'Components/Button', component: Button, tags: ['autodocs'], // 自动生成文档页 argTypes: { variant: { control: 'select', options: ['primary', 'secondary', 'danger'], }, onClick: { action: 'clicked' }, }, } export default meta type Story = StoryObj<typeof Button> export const Primary: Story = { args: { children: 'Click me', variant: 'primary' }, } export const Secondary: Story = { args: { children: 'Secondary', variant: 'secondary' }, } export const Danger: Story = { args: { children: 'Delete', variant: 'danger' }, } ``` `npm run storybook` → 浏览器看到 Button 的 3 个变体 + interactive controls 可以实时改 args。 ## 2. Controls ```tsx argTypes: { size: { control: { type: 'range', min: 12, max: 32, step: 2 }, }, bg: { control: 'color' }, date: { control: 'date' }, } ``` Storybook UI 自动渲染滑块 / 颜色选择器 / 日期选择器。 ## 3. 自动文档 `tags: ['autodocs']` 让 Storybook 自动生成 Docs 页: - 组件描述(取自 JSDoc / TypeScript types) - Props 表格(取自 TypeScript types) - 所有 story 实例展示 ## 4. MDX 写富文档 ```mdx {/* src/components/Button.mdx */} import { Canvas, Story } from '@storybook/blocks' import * as ButtonStories from './Button.stories' # Button 按钮组件。 ## 何时使用 - 提交表单 - 触发关键操作 ## 示例 <Canvas of={ButtonStories.Primary} /> 注意:danger 按钮配合二次确认 modal 使用。 <Canvas of={ButtonStories.Danger} /> ``` MDX 让你混 Markdown + 实际可交互的 story。 ## 5. play 函数:交互测试 ```tsx import { userEvent, within, expect } from '@storybook/test' export const ClickHandling: Story = { args: { children: 'Click me' }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement) const btn = canvas.getByRole('button') await userEvent.click(btn) expect(args.onClick).toHaveBeenCalled() }, } ``` 打开这个 story Storybook 自动执行 play 函数 → 模拟点击 → 校验。 相当于把单元测试 + 视觉展示合一。 可以直接 `npm run test-storybook` 在 CI 里跑所有 play 函数。 ## 6. decorators:包一层 context ```tsx const meta: Meta = { decorators: [ (Story) => ( <ThemeProvider theme="light"> <div style={{ padding: 24 }}> <Story /> </div> </ThemeProvider> ), ], } ``` 每个 story 自动套 Provider + 内边距。 ## 7. globalTypes:主题 / 语言切换器 ```ts // .storybook/preview.ts export const globalTypes = { theme: { description: '主题', defaultValue: 'light', toolbar: { title: 'Theme', icon: 'circlehollow', items: ['light', 'dark'], }, }, } export const decorators = [ (Story, ctx) => ( <ThemeProvider theme={ctx.globals.theme}> <Story /> </ThemeProvider> ), ] ``` Storybook 顶部工具栏出现切换按钮,所有 story 同步主题。 ## 8. addon:a11y / viewport / measure ```bash npm i -D @storybook/addon-a11y @storybook/addon-viewport ``` `.storybook/main.ts`: ```ts export default { addons: [ '@storybook/addon-essentials', '@storybook/addon-a11y', // axe-core 自动 a11y 检查 '@storybook/addon-viewport', // 不同设备尺寸 ], } ``` 每个 story 自动跑 a11y audit;右上角设备切换看响应式。 ## 9. 视觉回归测试(Chromatic) ```bash npx chromatic --project-token=xxx ``` Chromatic(Storybook 母公司服务)每次 build 自动截图所有 story, 和上次对比,任何视觉变化提醒你确认。 替代方案:自托管 reg-suit / loki / Percy。 ## 10. 部署 storybook ```bash npm run build-storybook # 输出到 storybook-static/ # 上传任何静态托管(Vercel / Netlify / GitHub Pages / S3) ``` 设计师 / PM / 客户能直接看组件演示。 ## 11. 与 design token 把 Figma design tokens 导出 JSON → 写 Tailwind / Styled-Components 主题, 在 Storybook 里切换主题对比效果。Tokens Studio + Figma + Storybook 是 设计系统的成熟工作流。 ## 12. 何时不用 Storybook - 小项目(< 10 个组件):维护 stories 文件成本 > 收益 - 业务页面(不是可复用组件):直接在 app 里开发更快 - 团队不愿意写 stories:勉强引入只会废弃 适合:组件库开发 / 设计系统 / 跨团队共享组件场景。 ## 踩过的坑 - Story 文件忘了 default export meta:Storybook 不识别。 - 全局样式没在 preview.ts 里 import:story 看到的样式和 app 不一致。 `.storybook/preview.ts` 里 `import '../src/index.css'`。 - Storybook 8 + Vite 5 升级有 breaking change:`framework: '@storybook/react-vite'` 必须明确写。 - Chromatic 视觉测试对小动画 / 字体渲染差异敏感,可能 false positive。 配 `delay: 200` 等动画结束再截图。

headscale:自托管 tailscale control plane(mesh VPN 不依赖第三方)

## 起因 Tailscale 是 WireGuard-based 的 mesh VPN,体验绝佳:所有设备通过 控制平面"发现"互通,零端口转发。但: - 控制平面是 Tailscale 公司的(数据不出公司是大忌行业) - 免费层 100 设备限制 - 隐私敏感场景不想依赖商业服务 `headscale` 是开源的 Tailscale control plane 实现,自己跑一台 机器就能成为"自己的 Tailscale 公司"。客户端继续用 Tailscale 官方 app(功能完整)+ 连你的 headscale server。 ## 架构 ``` ┌──────────────┐ │ headscale │ ← 自己跑,public 可达 │ (control) │ └──┬──────┬────┘ │ │ ┌───────┘ └────────┐ │ │ [laptop] ←── mesh ──→ [server] │ WireGuard │ └─── 互联 ──────────────┘ ``` 控制平面只协调 peer discovery + key exchange,**不路由数据流量**。 节点间 P2P 直连(NAT 穿透)或者 P2P 失败时 fallback 到 DERP relay。 ## 装 headscale 需要一台公网可达的服务器(VPS)+ HTTPS(必须)。 ```bash # 二进制安装 HEADSCALE_VERSION=0.23.0 curl -fsSL https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_amd64.deb \ -o headscale.deb sudo dpkg -i headscale.deb ``` 或 Docker: ```yaml # docker-compose.yml services: headscale: image: headscale/headscale:0.23.0 command: serve restart: unless-stopped ports: ["8080:8080", "9090:9090"] volumes: - ./config:/etc/headscale - ./data:/var/lib/headscale ``` ## 配置 `/etc/headscale/config.yaml`(关键字段): ```yaml server_url: https://hs.example.com listen_addr: 0.0.0.0:8080 metrics_listen_addr: 0.0.0.0:9090 ip_prefixes: - 100.64.0.0/10 # tailscale 默认 CGNAT 段 - fd7a:115c:a1e0::/48 derp: server: enabled: false # 自己跑 DERP 可选;先用官方 DERP urls: - https://controlplane.tailscale.com/derpmap/default database: type: sqlite sqlite: path: /var/lib/headscale/db.sqlite log: level: info oidc: # 可选:用 GitHub / Google / Authelia 等 SSO 登录 # issuer: https://auth.example.com # client_id: ... ``` ## 用 systemd ```bash sudo systemctl enable --now headscale sudo systemctl status headscale ``` 需要 HTTPS 反代(nginx / Caddy)。`hs.example.com` 指向这台机器 + TLS 证书。 ## 创建 user + 设备 enroll ```bash # 创建 user (一个 user 一组设备) sudo headscale users create alice # 客户端那边(macOS / Linux / iOS / Android / Windows)装 Tailscale, # 但通过 --login-server 指向你的 headscale sudo tailscale up --login-server https://hs.example.com # 输出一个授权 URL,但 headscale 不能 auto-auth,要手动 register key: # 看到 URL: # To authenticate, visit: https://hs.example.com/register/abc123... # 复制 nodekey 到 server 上: sudo headscale nodes register --user alice --key nodekey:abc123... ``` 或者用 pre-auth key(推荐脚本化): ```bash sudo headscale preauthkeys create --user alice --reusable --expiration 24h # 输出:xxx-yyy-zzz # 客户端 sudo tailscale up --login-server https://hs.example.com --auth-key xxx-yyy-zzz # 自动 enroll,无需手动 register ``` 成功后: ```bash sudo headscale nodes list # ID | Name | User | IP | Online # 1 | laptop | alice | 100.64.0.1 | yes # 2 | server | alice | 100.64.0.2 | yes ``` ## 节点间访问 ```bash ssh [email protected] # 通过 mesh VPN 访问 ping 100.64.0.2 ``` 或用 MagicDNS: ```bash ssh user@server # tailnet 内 hostname 自动解析 ``` 要开 MagicDNS: ```yaml # config.yaml dns: magic_dns: true base_domain: tailnet.example.com override_local_dns: true nameservers: global: - 1.1.1.1 ``` 每节点 `tailscale.tailnet.example.com` 自动可解析。 ## ACL(谁能访问谁) ```hujson // /etc/headscale/acl.hujson { "acls": [ // alice 的设备能互访 {"action": "accept", "src": ["alice"], "dst": ["alice:*"]}, // bob 只能访问 server {"action": "accept", "src": ["bob"], "dst": ["alice:server:22"]}, // 默认 deny(不写 accept 的都不通) ], "groups": { "group:admins": ["alice"], }, "tagOwners": { "tag:prod-server": ["group:admins"], }, } ``` ```bash sudo headscale policy set -f /etc/headscale/acl.hujson ``` 类似 K8s NetworkPolicy 但更简单。 ## subnet routes(让 VPN 节点暴露内网) server 节点 advertise 内网: ```bash sudo tailscale up --login-server https://hs.example.com \ --advertise-routes=192.168.1.0/24 ``` headscale 上 approve: ```bash sudo headscale nodes routes list sudo headscale nodes routes enable -r 1 ``` 之后所有 mesh 节点能通过这个 server 访问 192.168.1.x(家庭内网)。 等于"VPN 入口"。 ## exit nodes(用某节点作出口) ```bash # server 节点 advertise 为 exit node sudo tailscale up --advertise-exit-node # headscale approve sudo headscale nodes routes enable -r 1 --all # 客户端用: sudo tailscale up --exit-node=server ``` 客户端流量全部走 server 出公网。"自建 VPN" 替代商业 VPN。 ## 跟 cloud Tailscale 对比 | | Tailscale Cloud | headscale | |---|---|---| | 控制平面 | Tailscale 公司 | 自己 | | 数据流量 | 不经控制平面 | 不经控制平面 | | 客户端 app | 一样 | 一样(开源 tailscaled) | | ACL UI | Web 仪表盘 | CLI / hujson 文件 | | OIDC | 完善 | 基础支持 | | MagicDNS | ✅ | ✅ | | DERP relay | 全球 + 免费 | 用官方 DERP 或自建 | | 价格 | 免费 100 节点 / 团队收费 | 完全免费 | | 适合 | 普通团队 | 大规模 / 隐私敏感 | 非企业 + 100 节点以内 → 用 cloud Tailscale 省心。 > 100 设备 / 隐私敏感 / 跑公司基础设施 → headscale 自托管。 ## 与 WireGuard 直接配相比 裸 WireGuard: - 每加设备改所有节点 conf - 没 NAT 穿透(双 NAT 后设备难互联) - 没 MagicDNS - 维护 100 节点想哭 Tailscale / headscale: - 自动 P2P discovery + NAT 穿透 - 加设备一行命令 - ACL 中央管理 - MagicDNS mesh 规模上去后 headscale > 裸 WireGuard 远不止一个量级。 ## 实际效果 我家庭 + 公司 + VPS 一共 12 个设备: - 手机 / 笔记本 在外能直连家里 NAS(之前要开 OpenVPN 客户端) - 公司 / 家里互通(VPC peering 替代品) - 一台 VPS 当 exit node:手机走 VPN 翻墙 - ACL 让"工作笔记本只能访问 work server" 严格隔离 - headscale 自己跑在一台 $5 / 月 VPS 上,零成本控制平面 完全替代了我之前的 OpenVPN + 维护 WireGuard config。 ## 踩过的坑 1. **server_url 错** → 客户端连不上。必须包含 https:// 和正确域名, 且证书有效。 2. **客户端不接受 `--login-server` 指向 IP**:必须用域名 + 证书。 3. **NAT 双层穿不过** → fallback 到 DERP relay。自建 DERP server 加速。 4. **OIDC 配 SSO 时 callback URL 错**:headscale callback 是 `/oidc/callback`,注册 SSO 时填对。 5. **0.22 → 0.23 数据库迁移**:headscale 大版本升级偶尔需 migrate 命令。release notes 仔细看。

nftables 替代 iptables 写防火墙(统一 IPv4/IPv6 + 现代语法)

iptables 用了 20 年但有几个老问题:IPv4 / IPv6 / arptables / ebtables 四套不同命令、规则插入慢、语法古老。nftables 是它的现代继任者: - 单一 `nft` 命令统一所有协议 - 表达式 + 集合(set / map)让规则更短 - 增量加规则不重建整张表,性能好 - 类似 iptables 的 chains / rules,迁移有学习成本但不大 ## 装 + 启用 ```bash sudo apt install -y nftables sudo systemctl enable --now nftables ``` Debian 11+ / Ubuntu 22.04+ 默认就有。 ## 一个完整的服务器防火墙 `/etc/nftables.conf`: ``` #!/usr/sbin/nft -f flush ruleset table inet filter { # 允许 SSH 的源(管理 IP 段) set admin_ips { type ipv4_addr elements = { 192.0.2.10, 198.51.100.0/24 } } chain input { type filter hook input priority filter; policy drop; # 1. 已建立连接 / 相关连接放行 ct state established,related accept ct state invalid drop # 2. 本地 loopback iif lo accept # 3. ICMP / ICMPv6(ping、路径 MTU 探测等) ip protocol icmp accept meta nfproto ipv6 icmpv6 accept # 4. SSH 仅允许 admin_ips tcp dport 22 ip saddr @admin_ips accept # 5. 公开服务 tcp dport { 80, 443 } accept # 6. WireGuard udp dport 51820 accept # 7. 日志后丢弃(限速避免刷屏) limit rate 5/minute log prefix "nft drop input: " counter drop } chain forward { type filter hook forward priority filter; policy drop; } chain output { type filter hook output priority filter; policy accept; } } ``` 启用: ```bash sudo nft -c -f /etc/nftables.conf # 语法检查 sudo systemctl restart nftables sudo nft list ruleset ``` ## 关键语法点 - `table inet ...` 中 `inet` 表示同时处理 IPv4 + IPv6(这是 nftables 最大优势) - `policy drop` = 默认拒绝(白名单模式) - `ct state established,related accept` = 连接跟踪放行 - `set admin_ips` = 命名集合,规则里 `@admin_ips` 引用, 改 IP 不动规则 - `dport { 80, 443 }` = 内联集合 - `limit rate 5/minute log ...` = 限速日志 ## 命令行操作(运行时) ```bash # 看现有规则 sudo nft list ruleset sudo nft list table inet filter # 给集合加 IP(不需要重写整套规则) sudo nft add element inet filter admin_ips '{ 203.0.113.5 }' sudo nft delete element inet filter admin_ips '{ 198.51.100.0/24 }' # 加一条临时规则 sudo nft add rule inet filter input tcp dport 8080 accept # 删某条规则 sudo nft -a list ruleset # -a 显示 handle 编号 sudo nft delete rule inet filter input handle 12 ``` ## NAT 表(让内网通过本机出公网) ``` table inet nat { chain prerouting { type nat hook prerouting priority dstnat; } chain postrouting { type nat hook postrouting priority srcnat; # 内网 10.0.0.0/24 出口走 eth0 做 masquerade ip saddr 10.0.0.0/24 oifname "eth0" masquerade } } # 端口转发:把 80 转给内网 10.0.0.5 table inet nat { chain prerouting { type nat hook prerouting priority dstnat; iifname "eth0" tcp dport 80 dnat to 10.0.0.5:80 } } ``` ## 限速 / 防 DDoS ``` chain input { type filter hook input priority filter; policy drop; # 新 SSH 连接限速:每分钟同 IP 最多 6 次 tcp dport 22 ct state new \ limit rate over 6/minute \ counter drop # SYN flood 防护 tcp flags syn tcp option maxseg size 1-535 drop tcp flags & (syn|rst|ack) == syn \ limit rate 100/second burst 50 packets accept # ...其它规则 } ``` ## 集合 + map 高级用法 ``` # 不同源 IP → 不同处理 map verdict_map { type ipv4_addr : verdict elements = { 192.0.2.10 : accept, 198.51.100.20 : drop, } } chain input { ip saddr vmap @verdict_map } ``` ## 持久化 ```bash # 当前规则保存到 /etc/nftables.conf sudo nft list ruleset > /etc/nftables.conf # 或者用 systemd sudo systemctl restart nftables # 会读 /etc/nftables.conf ``` 服务重启 / 机器重启后 `/etc/nftables.conf` 被加载。 ## 从 iptables 迁移 ```bash # 看现有 iptables 规则 sudo iptables-save > /tmp/v4.rules sudo ip6tables-save > /tmp/v6.rules # 转 nftables sudo iptables-restore-translate -f /tmp/v4.rules > /tmp/v4.nft sudo ip6tables-restore-translate -f /tmp/v6.rules > /tmp/v6.nft # 看生成的,决定要不要手动整理(自动转出来语法生硬) ``` 实际生产建议手写 nftables,不直接转。iptables 规则积累的"历史包袱" 不该带进来。 ## ufw / firewalld 怎么办 它们底层会用 iptables 或 nftables(取决于 distro 版本)。 不要 ufw / firewalld + 手写 nftables 混用 —— 不同工具会互相覆盖。 选一个: - 简单 / 不需要进阶规则:ufw(前面那篇) - 需要 NAT / 限速 / map / 大规模规则:直接 nftables ## 调试 ```bash # 实时看哪条规则被命中(packet/byte count) sudo nft list ruleset | grep counter # 看 drop 日志 sudo journalctl -k -f | grep 'nft drop' # tcpdump 抓没通过的包 sudo tcpdump -i eth0 -nn 'host 1.2.3.4' ``` ## 踩过的坑 - 把自己 ban 了:从 console / out-of-band 进,`nft flush ruleset` 清空, 重新写规则。提前准备一个 5 分钟自动恢复脚本: ```bash (sleep 300 && sudo nft flush ruleset) & ``` - iptables-nft(在 RHEL 8+ / Debian 11+)让 iptables 命令实际写 nftables。这是过渡兼容;新写规则直接用 nft。 - `inet` 表的规则对 IPv4 和 IPv6 都生效;如果你 IPv6 没用,规则也会 消耗 conntrack。可以分别建 `ip filter` + `ip6 filter` 而不是 `inet`。 - container(Docker / K8s)默认绕过 nftables。Docker 会自己写 iptables / nftables 规则,与你的规则可能冲突。生产容器集群单独 规划网络策略(Calico / Cilium)。

GraphQL vs REST:取舍 + 各自的最小项目骨架

REST 仍是默认选择,但有些场景 GraphQL 更合适。下面讲两者的取舍 + 最小骨架。 ## 哲学差异 - **REST**:服务端定义资源 + 端点,前端按需调用多个端点 - **GraphQL**:服务端定义 schema + resolver,前端按需 query 任意字段 ## 谁该选 GraphQL - 移动端 / 弱网:一次请求拿所有数据,比串多个 REST 快 - 复杂前端(仪表盘 / 列表 + 详情):避免 over-fetch / under-fetch - 多客户端(web / iOS / android)字段需求差异大 - 公共 API(GitHub / Shopify):用户按需 query ## 谁该选 REST - 简单 CRUD - 强缓存需求(HTTP cache 直接生效) - 流式 / 文件上传下载(REST 更自然) - 团队熟悉度 ## 1. REST 最小骨架 (FastAPI) ```python from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class User(BaseModel): id: int name: str email: str @app.get('/users/{uid}', response_model=User) def get_user(uid: int): ... @app.get('/users/{uid}/posts') def list_user_posts(uid: int): ... @app.post('/users', response_model=User, status_code=201) def create_user(payload: CreateUser): ... ``` 每个资源一组端点:GET / POST / PATCH / DELETE。 返回固定字段集合。 前端要拿"用户 + 他的 5 篇最新帖子": ```js // REST: 2 个请求 (worst case 串行) const user = await fetch('/users/42').then(r => r.json()) const posts = await fetch('/users/42/posts?limit=5').then(r => r.json()) ``` ## 2. GraphQL 最小骨架 (Strawberry, Python) ```bash uv add 'strawberry-graphql[fastapi]' ``` ```python import strawberry from typing import List @strawberry.type class Post: id: int title: str body: str @strawberry.type class User: id: int name: str email: str @strawberry.field def posts(self, limit: int = 5) -> List[Post]: return db.get_user_posts(self.id, limit) @strawberry.type class Query: @strawberry.field def user(self, id: int) -> User: return db.get_user(id) schema = strawberry.Schema(query=Query) from strawberry.fastapi import GraphQLRouter from fastapi import FastAPI app = FastAPI() app.include_router(GraphQLRouter(schema), prefix='/graphql') ``` 启动后浏览器打开 `http://localhost:8000/graphql`,自带 Playground IDE。 前端 query: ```graphql query { user(id: 42) { id name posts(limit: 5) { id title } } } ``` 一个请求拿全部,不传 email / body 等不需要的字段。 ## 3. 避免 N+1 query:DataLoader GraphQL 最大坑是 resolver 一对多时 N+1: ```python @strawberry.type class Post: author_id: int @strawberry.field def author(self) -> User: return db.get_user(self.author_id) # 每篇帖子都查一次 ``` 20 篇帖子 → 1 (list posts) + 20 (各自 author) = 21 次 DB 查询。 DataLoader 模式 batch + cache: ```python from strawberry.dataloader import DataLoader async def load_users(keys: List[int]) -> List[User]: rows = db.get_users_in(keys) by_id = {u.id: u for u in rows} return [by_id[k] for k in keys] user_loader = DataLoader(load_fn=load_users) @strawberry.type class Post: author_id: int @strawberry.field async def author(self) -> User: return await user_loader.load(self.author_id) ``` 20 篇帖子的 author 字段被合并成一次 `SELECT WHERE id IN (...)`。 所有现代 GraphQL 库都自带或推荐 DataLoader。 ## 4. mutation ```python @strawberry.input class CreatePostInput: title: str body: str @strawberry.type class Mutation: @strawberry.mutation def create_post(self, input: CreatePostInput) -> Post: return db.create_post(input) schema = strawberry.Schema(query=Query, mutation=Mutation) ``` 前端: ```graphql mutation { createPost(input: { title: "Hi", body: "..." }) { id title } } ``` ## 5. subscription(GraphQL over WebSocket) ```python @strawberry.type class Subscription: @strawberry.subscription async def comment_added(self, post_id: int) -> AsyncGenerator[Comment, None]: async for c in db.watch_comments(post_id): yield c schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription) ``` 前端用 WebSocket: ```graphql subscription { commentAdded(postId: 1) { id body } } ``` ## 6. 缓存 REST:HTTP cache + ETag + max-age 直接生效,CDN 友好。 GraphQL:所有 query 都 POST 到 `/graphql`,HTTP cache 不起作用。 要客户端层缓存(Apollo Client / urql / Relay)。 ## 7. 鉴权 / 授权 REST:每个 endpoint 装饰器 `@require_login` 简单。 GraphQL:每个 resolver 自己检查 ctx。或者用 directive: ```graphql type Query { adminStats: Stats @auth(role: "admin") } ``` 实现时拦截 schema 执行。 ## 8. error handling REST:HTTP status code (4xx/5xx)。 GraphQL:固定 200,error 在响应 `errors` 数组: ```json { "data": { "user": null }, "errors": [{ "message": "User not found", "extensions": { "code": "NOT_FOUND" } }] } ``` 部分字段失败仍返回其它字段的结果(partial success)—— GraphQL 的特点。 ## 9. 性能 - REST 缓存友好;GraphQL 缓存差 - GraphQL N+1 不注意会严重慢;DataLoader 必备 - REST endpoint 容易优化(针对单接口加索引);GraphQL 查询任意组合, 查询计划难预测 ## 10. 实际生产经验 - 内部团队 API:REST 够用 + 简单 - 公共开放 API:GraphQL 客户体验好但运维难 - 移动 app:GraphQL 节省流量 - B2B + 强一致:REST + OpenAPI 文档生成 client SDK 最稳 可以混搭:核心业务 REST,前端聚合层 GraphQL(BFF 模式)。 ## 11. 替代:tRPC 如果前后端都是 TypeScript,tRPC 让你完全跳过 schema: ```ts // 后端 export const appRouter = router({ user: router({ get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }) => db.users.findUnique({ where: { id: input.id } })), }), }) // 前端(类型完全自动推导) const user = await trpc.user.get.query({ id: '1' }) ``` 不是 REST / GraphQL 任一阵营,但端到端类型安全极其爽。 ## 踩过的坑 - GraphQL schema 暴露太多内部字段 → 给客户端"复杂查询"的能力变成 DOS 武器。query depth limit + complexity limit 是必须的。 - 把 GraphQL 用成"REST + JSON in URL":每个 query 只取一个对象一层字段, 完全没用 GraphQL 的优势。这种场景就用 REST。 - DataLoader 跨 request 共享:缓存了别的 request 的数据 → 数据错乱。 每个 request 新建 loader 实例。 - GraphQL 客户端缓存(Apollo)配错 normalize key → mutation 后 UI 没刷新。 仔细读 normalize 文档。

nginx limit_req + limit_conn:按 IP / API key 限速

## 起因 API 上线后两天,账单显示某个客户调用量是其它人的 50 倍。 查 log:他用 curl 写了个无 sleep 的循环,1000 QPS 持续 12 小时。 正常使用 < 1 QPS。 应用层加限流来不及,先在 nginx 层挡住才是第一道闸。 ## 解决方案 ### 1. 按 IP 限速:limit_req ```nginx # /etc/nginx/conf.d/limits.conf # 共享内存区 zone:用 10MB 存 IP -> 计数器 limit_req_zone $binary_remote_addr zone=ip_lim:10m rate=10r/s; server { listen 443 ssl http2; server_name api.example.com; location /v1/ { # burst=20: 允许短期突发 20 个请求超出 10r/s # nodelay: 突发请求立刻处理(不排队延迟) limit_req zone=ip_lim burst=20 nodelay; # 超出限制返回 429(默认 503,改 429 更标准) limit_req_status 429; proxy_pass http://app; } } ``` `$binary_remote_addr` 是把 IP 压缩成 4 字节(IPv4)/ 16 字节(IPv6), 比 `$remote_addr` 字符串省内存。10MB 能存约 16 万个 IP。 `rate=10r/s` 等价 `1r per 100ms`。"突发 20 + 持续 10r/s"是常见配置。 ### 2. 按 API key 限速:用 map + limit_req_zone ```nginx # 把 Authorization header / query param 抽出 key map $http_authorization $api_key { ~^Bearer\s+(?<token>.+) $token; default ''; } # 优先用 API key 限流,没 key 才用 IP map $api_key $rate_key { '' $binary_remote_addr; default $api_key; } limit_req_zone $rate_key zone=api_lim:20m rate=20r/s; ``` 不同 key 独立计数,恶意 key 不影响别人。 ### 3. 按用户等级限流(基于 key 的差异化) ```nginx # /etc/nginx/conf.d/api_tiers.conf # 假设你有个文件 /etc/nginx/api_keys.conf 维护 key → tier 映射 # 用 njs 或外部 lookup(auth_request)拿 tier 信息 # 用 split_clients 或 geo / map 决定走哪个 zone map $api_key $rate_zone { "free-key-abc" "free"; "free-key-def" "free"; "pro-key-xyz" "pro"; "ent-key-mno" "ent"; default "free"; } limit_req_zone $api_key zone=free:10m rate=1r/s; limit_req_zone $api_key zone=pro:10m rate=10r/s; limit_req_zone $api_key zone=ent:10m rate=100r/s; server { location /v1/ { # 按 tier 选 zone(nginx 不能动态选 zone,用 if + return) # 实际生产用 auth_request + 后端决定 ... } } ``` 复杂 tier 路由通常上 nginx + Lua(OpenResty)或 Kong / Tyk API gateway。 ### 4. 按连接数限:limit_conn 防"单 IP 大量并发长连接占资源"(如 download 慢慢传): ```nginx limit_conn_zone $binary_remote_addr zone=conn_lim:10m; server { location /downloads/ { limit_conn conn_lim 5; # 同 IP 最多 5 个并发连接 limit_rate 1m; # 每连接限速 1 MB/s } } ``` `limit_rate` 控制响应字节速率;适合大文件下载 / 视频流。 ### 5. 看效果 / 监控 ```bash # 看被 429 拒绝的请求 sudo tail -f /var/log/nginx/access.log | awk '$9 == 429' # 统计 sudo awk '$9 == 429 {print $1}' /var/log/nginx/access.log \ | sort | uniq -c | sort -rn | head ``` 把 nginx access log status 暴露给 Prometheus: ```nginx # 用 nginx-prometheus-exporter 抓 stub_status location /metrics { allow 127.0.0.1; deny all; stub_status; } ``` PromQL: ``` rate(nginx_429_total[5m]) ``` ### 6. 给客户友好的 429 响应 ```nginx limit_req_status 429; # 自定义 429 页面 error_page 429 /errors/429.json; location = /errors/429.json { internal; add_header Content-Type 'application/json' always; return 429 '{"error": "rate_limit_exceeded", "retry_after": 60}'; } # 加 Retry-After header location /v1/ { limit_req zone=ip_lim burst=20 nodelay; add_header Retry-After 60 always; add_header X-RateLimit-Limit 10 always; add_header X-RateLimit-Remaining 0 always; proxy_pass http://app; } ``` ## 效果 - 滥用 client 在 nginx 层立刻被 429 挡住,application 不再被打扰 - 账单回归正常水平 - 正常用户感知 0(10 r/s 远超 UI 触发的频率) - 监控 dashboard 上有 429 趋势曲线,能看到攻击 / 滥用模式 ## limit_req 几个 trap **burst vs nodelay**: - `burst=20`(无 nodelay):允许 20 个排队,按 10r/s 速度处理 → 第 20 个 请求要等 2 秒才被响应(用户感受很差) - `burst=20 nodelay`:20 个突发请求立刻处理,之后超限的直接 429 → 用户感受好,但服务端瞬时压力大 - `burst=20 delay=5`:前 5 个突发请求 nodelay,第 6-20 个排队,超过 20 拒绝 API 服务建议 `burst+nodelay`;下载服务建议 `burst` 排队(保护后端)。 **`$binary_remote_addr` 在 CDN 后**:所有请求源 IP 都是 CDN,限流没意义。 用 `$http_x_forwarded_for` 或 `$http_cf_connecting_ip`: ```nginx limit_req_zone $http_cf_connecting_ip zone=ip_lim:10m rate=10r/s; ``` ## 与 application 层限流配合 nginx 层适合"按 IP / key 限速防滥用"。 应用层适合"按业务逻辑限流"(如"每用户每天 100 个 AI 生成请求")。 两者互补:nginx 挡明显异常,应用 enforce 业务规则。 ## 踩过的坑 1. **zone 写错放在 location 里**:`limit_req_zone` 必须在 `http {}` 段 定义;`limit_req` 才在 `server` / `location` 里用。 2. **rate=10r/m 不是 10r/min**:nginx 写法是 `10r/m` 表示 10 requests per minute;`10r/s` 是每秒。别混。 3. **测试时永远 429**:自己开发用浏览器刷页面 + 1r/s 限流自然超。 测试环境配宽点(如 100r/s)或者 dev 走不同 server block。 4. **`X-Forwarded-For` 被伪造**:如果你直接 trust `X-Forwarded-For[0]`, 攻击者可以伪造一个 IP 在 header 里,绕过限流。 `real_ip_header` + `set_real_ip_from` 限定可信代理范围才安全。 5. **zone 太小**:10MB zone 满了 → 老条目被新条目挤出 → 老 IP 一段时间 没访问 + 现在又来访问会被当作"新连接"立刻消耗 burst。生产至少 50MB per zone。

HTMX:用 HTML 属性写动态交互(替代 1/3 SPA 场景)

## 起因 很多内部工具 / dashboard 不需要 React 那种"完整 SPA"——只是几个 "按钮 click → 加载片段 → 局部更新"。这些用 React + 后端 API 实施 是杀鸡用牛刀: - 后端要做 JSON API 而不是直接 render HTML - 前端要 setup React + router + 状态管理 - 部署两个东西 - 一改 backend schema 前后端都要同步 `HTMX` 是 14 KB 的 JS 库,让 HTML 元素**通过属性发 AJAX 请求 + 局部替换 DOM**。 后端继续 render HTML,前端就是"加几个 hx-* 属性"。 ## 最简单的例子 ```html <script src="https://unpkg.com/[email protected]"></script> <button hx-get="/api/hello" hx-target="#result"> 点我加载 </button> <div id="result"></div> ``` 后端(Django / Flask / Rails / 任意): ```python @app.get('/api/hello') def hello(): return HttpResponse('<p>Hello, World!</p>') ``` 按钮 click → 发 GET /api/hello → 服务端返 HTML 片段 → 插入 #result。 零 JS 业务代码。 ## hx 属性速查 ```html <!-- 哪种请求 --> <a hx-get="/path">...</a> <button hx-post="/path">...</button> <button hx-put="/path">...</button> <button hx-delete="/path">...</button> <!-- 替换什么 --> <button hx-get="/x" hx-target="#dest">...</button> <button hx-get="/x" hx-target="closest .card">...</button> <!-- 怎么替换 --> hx-swap="innerHTML" <!-- 默认:替换 target 的 innerHTML --> hx-swap="outerHTML" <!-- 替换 target 整个元素 --> hx-swap="beforebegin" <!-- 插 target 之前 --> hx-swap="afterend" <!-- 插 target 之后 --> hx-swap="delete" <!-- 删 target --> hx-swap="none" <!-- 不动 DOM(仅触发 side effect) --> <!-- 何时触发 --> hx-trigger="click" <!-- 默认 --> hx-trigger="keyup changed delay:500ms" <!-- input 改变 + 500ms --> hx-trigger="every 5s" <!-- 每 5 秒 poll --> hx-trigger="revealed" <!-- 进入视口(infinite scroll)--> hx-trigger="load" <!-- 元素 mount 后立刻 --> <!-- 传额外数据 --> <button hx-post="/like" hx-vals='{"post_id": 42}'> Like </button> <!-- form 自动收集 --> <form hx-post="/save"> <input name="title"> <button>save</button> </form> <!-- 自动把表单字段作 body --> <!-- loading indicator --> <button hx-get="/slow" hx-indicator="#spinner"> Load </button> <span id="spinner" class="htmx-indicator">⏳</span> <!-- 请求期间 .htmx-indicator 自动显示(CSS 控制) --> ``` ## 实战:To-do 列表(含增删改) ```html <ul id="todos"> <li>买菜 <button hx-delete="/todos/1" hx-target="closest li" hx-swap="delete">×</button></li> <li>遛狗 <button hx-delete="/todos/2" hx-target="closest li" hx-swap="delete">×</button></li> </ul> <form hx-post="/todos" hx-target="#todos" hx-swap="beforeend"> <input name="text" required> <button>add</button> </form> ``` 后端: ```python @app.post('/todos') def create(): text = request.form['text'] todo = Todo.objects.create(text=text) return HttpResponse(f'<li>{todo.text} <button hx-delete="/todos/{todo.id}" hx-target="closest li" hx-swap="delete">×</button></li>') @app.delete('/todos/<int:id>') def delete(id): Todo.objects.filter(pk=id).delete() return HttpResponse('', status=200) ``` 完整 CRUD < 30 行 HTML + 后端 model。 零 JavaScript 业务代码。 ## SPA-like:boost 让普通链接 / 表单变 AJAX ```html <body hx-boost="true"> <a href="/about">About</a> <!-- 自动变 hx-get="/about" + 替换 body --> <form action="/login" method="post"> <!-- 自动 hx-post --> ... </form> </body> ``` 整站 SPA 体验,无需为每个链接写 hx 属性。 浏览器 back / forward 自动 work(pushState)。 ## Infinite scroll ```html <div hx-get="/posts?page=2" hx-trigger="revealed" hx-swap="afterend"> Loading more... </div> ``` 最后那个 div 进入视口 → 自动加载下一页 + 插到 afterend。 后端返回的 HTML 末尾再放一个同样的 trigger,无限链。 ## Server-sent events / WebSocket ```html <div hx-ext="sse" sse-connect="/events" sse-swap="message"> 等通知... </div> ``` 服务端 push 时自动更新 div 内容。 ## Active search ```html <input type="search" hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results"> <div id="results"></div> ``` 300ms debounce 后发请求;服务端返回结果 HTML 列表 → 替换 #results。 **"Google instant search" 体验,零 JS**。 ## 编辑表单:click → 变 input → save 后变回 ```html <div id="email-display"> {{ user.email }} <button hx-get="/users/me/edit-email" hx-target="#email-display" hx-swap="outerHTML"> 编辑 </button> </div> ``` 后端 GET /users/me/edit-email 返回: ```html <form hx-post="/users/me/email" hx-target="this" hx-swap="outerHTML"> <input name="email" value="{{ user.email }}"> <button>save</button> <button type="button" hx-get="/users/me/email-display" hx-target="closest form" hx-swap="outerHTML">取消</button> </form> ``` POST /users/me/email 处理后返回更新版 `#email-display`。 inline edit 模式,无 React 也优雅。 ## 复杂前端逻辑(少量 JS) HTMX 不替代所有 JS。需要复杂前端状态时配 Alpine.js / hyperscript: ```html <button x-data="{ count: 0 }" @click="count++"> Count: <span x-text="count"></span> </button> ``` Alpine.js 是 14 KB 的"轻量 Vue",跟 HTMX 同生态。 HTMX 管"跟服务器交互";Alpine 管"客户端局部状态"。 ## 与 SPA / React 对比 | | HTMX + 服务端 render | React SPA | |---|---|---| | bundle | 14 KB | 100+ KB | | 后端 | 返回 HTML | 返回 JSON API | | 前端代码量 | 极少 | 大 | | SEO | 自然好(HTML) | 要 SSR 才好 | | 适合 | 内部工具 / CMS / blog / 简单 CRUD | 复杂 SPA / 离线 / 极致交互 | | 团队 | 全栈 | 前后分离 | HTMX 适合: - 内部 dashboard / 后台 - Django / Rails / Phoenix 等 server-rendered framework 用户 - 不想维护两套 (frontend + backend API) 代码 - 中等复杂度 web app 不适合: - 极复杂客户端状态(编辑器 / IDE / 实时白板) - 离线优先 PWA - 需要超丝滑 transition / 动画 ## 实战 case 我们一个公司内部 admin tool 用 HTMX 重写: - 之前:React + REST API + 后端 + 部署两套 → 4 周 - 现在:Django + HTMX → 5 天 - 维护 1 套代码 - bundle 从 500 KB → 30 KB(Django 资源 + HTMX) - 内部用户没感觉差异(其实更快) 但绝不会用 HTMX 重写"复杂 SaaS dashboard"——那个还是 React。 工具论适配场景。 ## SSR framework with HTMX - **Django** + `django-htmx`:内置 helpers - **Flask** + Jinja2:原生适配 - **Rails 7** + Hotwire / Turbo(不是 HTMX 但理念类似) - **Phoenix** + LiveView(更强大但 Elixir 专属) - **Laravel** + Livewire(PHP 版 LiveView) 服务端渲染 + 轻量 JS 增强 是 2024 重新流行的方向。 ## 完整 demo: 文章 like 按钮 ```html <!-- post.html --> <article> <h1>{{ post.title }}</h1> <p>{{ post.body }}</p> <div id="like-section-{{ post.id }}"> {% include 'like_section.html' %} </div> </article> <!-- like_section.html --> <button hx-post="/posts/{{ post.id }}/like" hx-target="#like-section-{{ post.id }}" hx-swap="innerHTML" {% if user_liked %}disabled{% endif %}> {% if user_liked %}❤️ Liked{% else %}🤍 Like{% endif %} </button> <span>{{ post.likes_count }} likes</span> ``` ```python @app.post('/posts/<int:id>/like') @login_required def like(id): post = Post.objects.get(pk=id) Like.objects.get_or_create(user=request.user, post=post) return render(request, 'like_section.html', { 'post': post, 'user_liked': True, }) ``` 20 行代码完成"点赞 + 实时更新计数"。 React 版本至少 100 行 + 后端 API + 状态管理。 ## 踩过的坑 1. **hx-target="closest .row"** 找不到 → ".row" 必须是元素的祖先。 `find / next` 等其它选择器更明确。 2. **服务端忘记返 HTML fragment 而是返整个 page**:page 被插到 target 里 → 嵌套 html / body 一片乱。返 fragment template。 3. **CSRF token**:hx-post 默认不带 cookie / CSRF token。django-htmx 等 framework helper 自动加;纯 HTMX 配 `hx-headers='{"X-CSRFToken": "..."}'`。 4. **back button 不工作**:hx-boost 自动 pushState;自己写 hx-get 默认 不更 URL。要 history 工作加 `hx-push-url="true"`。 5. **debug 难**:DOM swap 后浏览器 inspector 不显示原 HTML。 开 HTMX debug:`htmx.logAll()` 看所有 request / response。

Linux Network Namespace:在一台机器上模拟多个网络环境

Network namespace(netns)是 Linux 内核的网络隔离机制。每个 netns 有独立的网卡、路由表、防火墙规则、socket 列表。 container(Docker / podman / K8s pod)的网络隔离底层就是这个。 理解 netns 后调试容器网络就直观了;还能直接用 netns 跑测试 / 模拟复杂拓扑。 ## 1. 创建 netns ```bash sudo ip netns add red sudo ip netns add blue ip netns list # blue # red ``` 每个 netns 默认只有一个 loopback(甚至没启): ```bash sudo ip netns exec red ip link # 1: lo: <LOOPBACK> mtu 65536 ... state DOWN # 启 lo sudo ip netns exec red ip link set lo up sudo ip netns exec red ip addr show ``` ## 2. 在 netns 里执行命令 ```bash sudo ip netns exec red bash # 进了一个新 shell,网络环境完全隔离 ip addr # 只有 lo ip route # 空路由表 curl google.com # 当然不通 exit ``` ## 3. 用 veth pair 把两个 netns 连起来 veth 是"虚拟以太网网卡对"——两端一对,一端发送的包另一端立刻收到, 像一根虚拟网线: ```bash # 创建 veth pair sudo ip link add veth-red type veth peer name veth-red-host sudo ip link add veth-blue type veth peer name veth-blue-host # 把一端塞进 netns sudo ip link set veth-red netns red sudo ip link set veth-blue netns blue # host 端启接口 sudo ip link set veth-red-host up sudo ip link set veth-blue-host up # netns 内端启接口 + 配 IP sudo ip netns exec red ip link set veth-red up sudo ip netns exec red ip addr add 10.10.0.2/24 dev veth-red sudo ip netns exec red ip route add default via 10.10.0.1 sudo ip netns exec blue ip link set veth-blue up sudo ip netns exec blue ip addr add 10.10.1.2/24 dev veth-blue sudo ip netns exec blue ip route add default via 10.10.1.1 ``` ## 4. 用 bridge 让多个 netns 共网段 ```bash # host 上建网桥 sudo ip link add br0 type bridge sudo ip link set br0 up sudo ip addr add 10.10.0.1/24 dev br0 # veth host 端挂到 bridge sudo ip link set veth-red-host master br0 sudo ip link set veth-blue-host master br0 ``` bridge 像一个虚拟交换机:red / blue netns 互通。 Docker 默认网络模型就是这套:所有容器 veth 一端在容器 netns,另一端 挂在 `docker0` bridge。 ## 5. 让 netns 访问外网 ```bash # host 上开 IP forwarding sudo sysctl -w net.ipv4.ip_forward=1 # 配 NAT:netns 流量从 eth0 出去时做 SNAT sudo iptables -t nat -A POSTROUTING \ -s 10.10.0.0/16 -o eth0 -j MASQUERADE # 测试 sudo ip netns exec red curl ifconfig.me # 公网 IP(你的 host 的) ``` ## 6. 给 netns 分配独立 DNS netns 内的 `/etc/resolv.conf` 默认还是 host 的。改: ```bash sudo mkdir -p /etc/netns/red echo 'nameserver 1.1.1.1' | sudo tee /etc/netns/red/resolv.conf ``` `ip netns exec red ...` 会自动 bind-mount `/etc/netns/red/resolv.conf` 到 `/etc/resolv.conf`。 ## 7. 实战用例:用 VPN 跑某个进程,不影响系统 ```bash sudo ip netns add vpn # WireGuard 接口 wg0 起好之后丢进 netns sudo ip link set wg0 netns vpn sudo ip netns exec vpn ip addr add 10.0.0.2/24 dev wg0 sudo ip netns exec vpn ip link set wg0 up sudo ip netns exec vpn ip route add default via 10.0.0.1 # 跑 firefox(流量走 VPN) sudo ip netns exec vpn sudo -u $USER firefox ``` 系统的其它进程走正常网络,firefox 走 VPN,零干扰。 ## 8. 删 netns ```bash sudo ip netns del red # veth 一端跟着 netns 消失,host 端 veth-red-host 自动被删 ``` ## 9. 跨重启持久化 iproute2 不持久化 netns。要持久化用 systemd-networkd 或 NetworkManager 配 dispatcher script。容器项目(Docker、systemd-nspawn)自动管 netns 生命周期。 ## 10. 调试技巧 ```bash # 看 netns 内的 socket sudo ip netns exec red ss -tan # 看 netns 内的 conntrack sudo ip netns exec red conntrack -L # 在 netns 内抓包 sudo ip netns exec red tcpdump -i veth-red -nn # 从 host 看进程在哪个 netns sudo lsns -t net # NS TYPE NPROCS PID USER COMMAND # 4026531992 net 100 1 root /sbin/init # 4026532145 net 1 1234 root /usr/bin/firefox ``` ## 11. 容器视角 Docker / podman / K8s 创建容器时: 1. `ip netns add <container-id>` 2. 创建 veth pair,一端塞进 netns 3. 另一端挂到 `docker0` bridge / CNI 网桥 4. 给容器 netns 内的 veth 配 IP / 默认路由 5. `nsenter --net=...` 在容器 netns 内启进程 `docker exec` 等价于 `nsenter --target $PID --net --pid --mount`。 理解了 netns,调试 "我的容器没网络" / "K8s pod 间不通" 等问题 就有了底层视角。 ## 踩过的坑 - 用 `ip netns exec X command` 但 `command` 是 bash 的 builtin(如 `cd`)—— 不是命令。改成 `bash -c "..."` 包一下。 - veth 的 MTU 默认 1500,套 VPN 后 MTU 减小,netns 内 MTU 不调整会 fragment 频繁。`ip link set veth-xxx mtu 1420`。 - conntrack:每个 netns 有独立 conntrack 表。host 防火墙规则不自动 影响 netns。 - 用 sudo 进 netns 后丢失了原 user 环境变量:`ip netns exec X sudo -u $USER bash` 能切回普通用户但要小心 PATH / DISPLAY 等。

CSS 原生嵌套 + :has() + :where():2024 后不再需要 Sass 的几个功能

## 起因 写 CSS 多年装 Sass / Less / postcss-nesting 主要为了嵌套 + 父选择器 + mixin。但 2023 后浏览器原生支持: - **CSS Nesting**:原生嵌套,与 Sass 兼容语法 - **`:has()`**:父选择器(基于子元素状态匹配父元素) - **`:where()` / `:is()`**:选择器组合 + 控制优先级 - **CSS Layers (`@layer`)**:明确分层避免优先级地狱 新项目大多可以不引入 Sass。 ## 1. CSS Nesting ```css /* 旧:扁平 */ .card { padding: 16px; } .card .title { font-size: 18px; font-weight: bold; } .card .title:hover { color: blue; } .card.active { border: 2px solid blue; } .card.active .title { color: blue; } /* 新:嵌套(原生) */ .card { padding: 16px; & .title { /* & 显式指代父选择器,规范要求 */ font-size: 18px; font-weight: bold; &:hover { color: blue; } } &.active { border: 2px solid blue; & .title { color: blue; } } @media (max-width: 600px) { padding: 8px; } } ``` 要点: - `&` 是必须的(不像 Sass 可省) - 嵌套深度不要超过 3 层(可读性 / 编译 size) - @media / @supports 也能嵌套 浏览器支持:Chrome 112+ / Firefox 117+ / Safari 16.5+。 2024 中现代浏览器全支持。 ## 2. `:has()` —— 父选择器 CSS 历史上一直没法"父元素根据子元素状态变样式"。 `:has()` 终于解决: ```css /* 含图片的卡片样式不同 */ .card:has(img) { padding: 0; background: black; } /* 表单包含 invalid input 时整组红边框 */ form:has(input:invalid) { border: 1px solid red; } /* 卡片包含 .featured 时整张卡黄边 */ .card:has(.featured) { border: 2px solid gold; } /* "下个兄弟是 h2" 的段落加边距 */ p:has(+ h2) { margin-bottom: 24px; } /* 复杂:当 ul 包含 li.active 时 */ nav ul:has(li.active) { background: var(--accent-bg); } /* 没有图片的 article */ article:not(:has(img)) { text-align: center; } ``` 之前要靠 JS 加 class 解决的,现在纯 CSS。 **实际例子:自适应高亮表单** ```html <form> <label> Email <input type="email" required> </label> <label> Password <input type="password" required minlength="8"> </label> </form> ``` ```css label:has(input:invalid:not(:placeholder-shown)) { color: red; } label:has(input:valid) { color: green; } ``` 用户输入到无效状态时 label 立刻变红,不需要任何 JS。 ## 3. `:where()` —— 零优先级组合 `:where()` 把多个选择器组合,但**优先级为 0**: ```css /* :is() 优先级 = 最高的子选择器 (这里 #foo = 100) */ :is(.a, #foo) p { color: red; } /* :where() 优先级 = 0 */ :where(.a, #foo) p { color: red; } ``` 实战:写"基础重置"时希望可以被业务样式轻易覆盖: ```css /* 用 :where() 把所有标题清零,业务样式不需要 !important 就能改 */ :where(h1, h2, h3, h4, h5, h6) { margin: 0; font-weight: 400; } /* 业务样式:普通选择器优先级 1 > :where() 的 0 */ .title { font-weight: bold; } /* 生效 */ ``` 不再需要 `!important` 大战。 `:is()` 是同样组合但保留优先级,适合短化代码: ```css /* 旧 */ header h1, footer h1, main h1 { ... } /* 新 */ :is(header, footer, main) h1 { ... } ``` ## 4. `@layer` —— 显式优先级层 ```css /* base 最低 */ @layer base, components, utilities; @layer base { h1 { font-size: 2rem; } a { color: blue; } } @layer components { .btn { padding: 8px 16px; } .card { padding: 16px; } } @layer utilities { .text-center { text-align: center; } .hidden { display: none; } } ``` 后声明的 layer 总是覆盖前面的,**无视选择器优先级**。 意思: - `@layer base { h1 { ... } }` 的 h1(优先级 1) - 被 `@layer utilities { h1 { ... } }` 的同 h1(也是优先级 1)覆盖 ——因为 utilities 层在后 最强:第三方 CSS 引入时套个低优先级 layer: ```css @layer reset, vendor, base, components; @import url('https://cdn.example.com/some-lib.css') layer(vendor); /* 我的 base 总能覆盖 vendor 的所有 selector,无论它写了多复杂的 .x.y.z 选择器 */ ``` 终结"如何覆盖 Bootstrap 的样式"类问题。 ## 5. 用 PostCSS 转旧浏览器 旧浏览器(IE 11、老 Safari)支持差。生产建议加 PostCSS 转译: ```bash npm i -D postcss postcss-nesting postcss-preset-env ``` ```js // postcss.config.js export default { plugins: [ require('postcss-preset-env')({ stage: 2, features: { 'nesting-rules': true, 'has-pseudo-class': true, }, }), ], } ``` PostCSS 把 nesting / `:has()` 等转成等价旧 CSS,老浏览器也能用。 ## 6. 真实重构例子 之前用 Sass 写的组件: ```scss // Card.scss .card { padding: 16px; border-radius: 8px; background: white; &__title { font-size: 18px; font-weight: bold; } &__body { margin-top: 12px; } &--featured { border: 2px solid gold; .card__title { color: gold; } } } ``` 迁移到原生 CSS: ```css .card { padding: 16px; border-radius: 8px; background: white; & > .title { font-size: 18px; font-weight: bold; } & > .body { margin-top: 12px; } &.featured { border: 2px solid gold; & > .title { color: gold; } } } ``` 文件几乎一样,少装一个 Sass + sass-loader 依赖。 ## 效果 新项目: - 不再装 Sass / Less,CSS pipeline 简化 - `:has()` 替代了 5-6 处 JS 操控 class 的逻辑 - `@layer` 让多团队 CSS 协作不再撞车 - DevTools 调试 CSS(不再是编译后的"扁平"代码) ## 仍要用 Sass 的场景 CSS 原生还没有的: - **mixin** / `@function`:能写函数复用 - **运算 / 颜色函数**:CSS `color-mix()` 部分替代 - **partial / @use**:CSS `@import` 弱 如果只用嵌套 + 父选择器 → 原生 CSS 够;要重度逻辑还是 Sass / Stylus。 ## 踩过的坑 1. **`&` 不能省**:写 `.card { .title {} }` 在原生 CSS 里不工作 (但 Sass 工作)。必须 `& .title`。 2. **嵌套里的 type 选择器**: ```css .card { /* 旧 Sass 可以写 a {} 直接 */ a { color: blue; } /* CSS 也支持,但语义是 ".card a",跟 Sass 一致 */ } ``` 实际无差。 3. **`:has()` 性能**:浏览器要"反向"匹配,复杂 selector 可能慢。 单页面大量 `:has()` 时观察 performance。 4. **`@layer` 顺序很重要**:第一行 `@layer a, b, c` 定义顺序, 后续 `@layer a` 即使写在最后也属于第一层(低优先级)。 5. **PostCSS 转译错 `:has()`**:现有 polyfill 不完美(DOM 监听变化 性能差)。如果一定要老浏览器兼容,避免在频繁变化的元素上用。

NATS JetStream:1MB 二进制的"Kafka 平替"

## 起因 要做服务间异步消息:常用选择 Kafka / RabbitMQ / Redis Streams。 Kafka 要装 ZooKeeper 或 KRaft,集群配置复杂;RabbitMQ 单机够但 "事件流"语义弱;Redis Streams 简单但不算 first-class。 NATS 是 CNCF 项目,单二进制、< 20MB 内存、跨平台。 JetStream 是 NATS 内置的持久化层,把 NATS pub/sub 升级到 Kafka-like 能力:persistent stream / consumer / replay / 多副本。 ## 5 分钟起服务 ### 1. 装 ```bash # 二进制下载 curl -fsSL https://github.com/nats-io/nats-server/releases/latest/download/nats-server-v2.10.20-linux-amd64.tar.gz \ | tar xz sudo install nats-server-v2.10.20-linux-amd64/nats-server /usr/local/bin/ # 或 Docker docker run -p 4222:4222 -p 8222:8222 \ nats:latest -js -m 8222 # -js 开 JetStream;-m 开监控 endpoint ``` CLI 工具: ```bash go install github.com/nats-io/natscli/nats@latest nats account info ``` ### 2. 创建 stream ```bash nats stream add ORDERS \ --subjects "orders.>" \ --storage file \ --retention limits \ --max-msgs 1000000 \ --max-age 30d \ --discard old \ --replicas 1 ``` "`orders.>`" 通配符:所有以 orders. 开头的 subject 都进这个 stream (orders.created / orders.shipped / orders.cancelled)。 ### 3. 发消息 ```bash nats pub orders.created '{"id": 1, "amount": 99.5}' nats pub orders.shipped '{"id": 1, "carrier": "fedex"}' ``` ### 4. 创建 consumer + 消费 ```bash nats consumer add ORDERS analytics \ --filter "orders.>" \ --ack explicit \ --deliver all \ --replay instant # Pull-based consumer nats consumer next ORDERS analytics --count 10 # 拿 10 条 ``` ## Python 客户端 ```bash uv add nats-py ``` ```python import asyncio import nats from nats.js.api import StreamConfig, ConsumerConfig, RetentionPolicy async def main(): nc = await nats.connect('nats://localhost:4222') js = nc.jetstream() # 确保 stream 存在(幂等) await js.add_stream(name='ORDERS', subjects=['orders.>'], retention=RetentionPolicy.LIMITS, max_age=30 * 24 * 60 * 60) # 发 await js.publish('orders.created', b'{"id": 1, "amount": 99.5}') # 收(pull-based subscriber) psub = await js.pull_subscribe('orders.>', 'analytics', stream='ORDERS') while True: msgs = await psub.fetch(10, timeout=5) for m in msgs: print(m.subject, m.data) await m.ack() asyncio.run(main()) ``` ## 与 Kafka / RabbitMQ 对比 | | NATS JetStream | Kafka | RabbitMQ | |---|---|---|---| | 二进制大小 | 20MB | 100MB+ | 50MB+ | | 内存 | < 50MB | 几百 MB | 几百 MB | | 集群安装 | 一行命令 | 复杂 | 中等 | | 消息 retention | ✅ | ✅ | 弱 | | Stream 模式 | ✅ | ✅ | 弱 | | Queue 模式 | ✅ | 需要外部 | ✅ | | 性能 | 100k+ msg/s | 1M+ msg/s | 几十 k | | 跨地域副本 | 容易(NATS leaf) | 复杂(MirrorMaker) | 中 | | 生态成熟度 | 中(快增) | 极成熟 | 极成熟 | **适合 NATS**:小到中规模、要求轻量、跨地域、自托管简单的场景。 **Kafka 仍然适合**:百万级吞吐 / 已有 Kafka 生态 / 大数据 pipeline。 ## 几个 NATS 杀手 feature ### 1. Request-Reply (RPC) ```python # server async def handler(msg): await msg.respond(b'pong') await nc.subscribe('rpc.ping', cb=handler) # client response = await nc.request('rpc.ping', b'', timeout=1) print(response.data) # b'pong' ``` 不需要 HTTP framework,直接通过 NATS 做 RPC。 比 gRPC 简单:无需 .proto,subject 即 route。 ### 2. Key-Value Store ```python kv = await js.create_key_value(bucket='configs') await kv.put('app.version', b'1.2.3') entry = await kv.get('app.version') print(entry.value) # b'1.2.3' # Watch changes async for entry in kv.watch_all(): print('changed:', entry.key, entry.value) ``` JetStream 上的小 K/V,replicated + watch 通知。 替代 etcd 的简单场景。 ### 3. Object Store ```python obs = await js.create_object_store(bucket='photos') await obs.put('logo.png', b'<png bytes>') data = await obs.get('logo.png') ``` S3 替代品(小规模),数据在 stream 里。 ### 4. Leaf node(跨地域 / 边缘) 总部跑 main NATS server,分公司跑 leaf node 连过来: ``` # leaf node config leafnodes { remotes = [ { url: "nats://hq.example.com:7422" } ] } ``` leaf 上发的消息自动同步到总部。流量本地优先,节省跨地域带宽。 ## 实战:订单事件总线 ``` service: order-api ↘ service: payment-svc ↘ publish orders.* ↘ service: shipment-svc ↗ ↘ JetStream ↗ ↙ service: analytics (subscribe orders.> as 'analytics') service: email-svc (subscribe orders.created as 'email-notifier') service: webhook-svc (subscribe orders.> as 'webhook-publisher') ``` 每个消费方独立 consumer,独立维护 offset。 order-api 一次 publish,5 个下游各自异步处理。 加新下游不影响现有: ```python # 新加 'audit-log' consumer await js.add_consumer(stream='ORDERS', config=ConsumerConfig( durable_name='audit-log', filter_subject='orders.>', deliver_policy=DeliverPolicy.ALL, # 从最早开始 )) ``` `deliver_policy=ALL` 让新 consumer 从 stream 最早消息开始处理, 回放历史(Kafka 一样能力)。 ## 监控 NATS 自带 `:8222/varz` /`:8222/jsz` 等 endpoint。Prometheus exporter: ```bash docker run -p 7777:7777 \ natsio/prometheus-nats-exporter:latest \ -varz -connz -channelz -subz http://nats:8222 ``` Grafana 用现成 dashboard ID 2279 / 2280。 ## 高可用:cluster + replicas 3 node cluster: ```bash nats-server -p 4222 -n n1 -cluster_listen 0.0.0.0:6222 -routes nats://n2:6222,nats://n3:6222 -js -sd /data/n1 nats-server -p 4223 -n n2 -cluster_listen 0.0.0.0:6222 -routes nats://n1:6222,nats://n3:6222 -js -sd /data/n2 nats-server -p 4224 -n n3 -cluster_listen 0.0.0.0:6222 -routes nats://n1:6222,nats://n2:6222 -js -sd /data/n3 ``` Stream `--replicas 3` → 3 副本(Raft 共识)。一台挂仍可用。 ## 效果 我们一个中型 SaaS 把消息中间件从 Redis Streams 换 NATS JetStream: - 容器内存从 250MB(Redis 加事件订阅逻辑)→ 60MB(NATS 单进程) - 跨地域分公司间消息同步 native 支持(之前要自己写桥) - delivery semantics 更清楚(at-least-once 默认,exactly-once 配置开) - consumer 重连 / replay 等行为类 Kafka,无需自己处理 ## 踩过的坑 1. **`-js` 没加忘了启 JetStream**:以为 stream 创建失败, `failed to find storage directory`。第一次启动一定加 `-js`。 2. **`--max-msgs` / `--max-bytes` 一定要设**:默认 unlimited, 磁盘吃满。 3. **consumer ack 超时配置**:处理慢的 consumer 默认 30s ack 不及 消息被 redelivered。`--ack-wait` 调大。 4. **多语言客户端兼容性**:Go / Python / Node / Java / Rust / C# 都有 官方 client;version 一致性要注意(client 落后 server 可能少 feature)。 5. **TLS / auth**:开放公网必须配 `--tls` + `--auth` + 用户密码或 nkey。默认 anonymous + plain 安全为零。

推荐系统第一步:用 implicit 库做协同过滤(不用任何深度模型)

## 起因 老板说"加个'你可能也喜欢' 推荐"。我们的数据: - 用户 5 万 - 商品 1 万 - 用户-商品 浏览 / 购买记录 100 万条 不是 YouTube 级,深度学习 / DSSM / DeepFM 过于豪华。 传统协同过滤(implicit feedback ALS)几行代码就有 baseline,效果 比"按热度排序" 提升 30-50% CTR。下面是真实跑通流程。 ## 解决方案 ### 装 ```bash uv add implicit scipy pandas ``` `implicit` 库是 Ben Frederickson 写的 C++ ALS 实现,比 surprise / spotlight 快 100x。 ### 数据格式:sparse user-item matrix ```python import pandas as pd from scipy.sparse import csr_matrix events = pd.read_csv('user_events.csv') # user_id,item_id,event_type # 1234,567,view # 1234,567,view # 1234,789,purchase # 隐式反馈权重:购买 > 加购 > 浏览 weights = {'view': 1, 'cart': 3, 'purchase': 10} events['w'] = events['event_type'].map(weights) # 聚合 agg = events.groupby(['user_id', 'item_id'])['w'].sum().reset_index() # user_id,item_id,w # 1234,567,2 # 1234,789,10 # 编码成连续整数索引 user_ids = agg['user_id'].unique() item_ids = agg['item_id'].unique() user_idx = {u: i for i, u in enumerate(user_ids)} item_idx = {it: i for i, it in enumerate(item_ids)} agg['ui'] = agg['user_id'].map(user_idx) agg['ii'] = agg['item_id'].map(item_idx) # sparse matrix matrix = csr_matrix((agg['w'].values, (agg['ui'].values, agg['ii'].values)), shape=(len(user_ids), len(item_ids))) print(matrix.shape, matrix.nnz) # (50000, 10000) 1000000 ``` ### ALS 训练 ```python from implicit.als import AlternatingLeastSquares model = AlternatingLeastSquares( factors=64, # embedding 维度 regularization=0.01, iterations=20, alpha=40, # implicit feedback 信心权重 use_gpu=False, # GPU 可选 ) model.fit(matrix) # 几秒钟完成 ``` `alpha` 是 implicit ALS 的关键超参: - 表示"我们对'用户买了'比'用户没买' 的信心差多少 - 论文建议 alpha=40 是好起点 - 调参可以 GridSearch on 验证集 ### 给一个用户推荐 top-K ```python def recommend(user_id, k=10): if user_id not in user_idx: return [] # 冷启动 uid = user_idx[user_id] item_ids_arr, scores = model.recommend(uid, matrix[uid], N=k) return [(item_ids[i], float(s)) for i, s in zip(item_ids_arr, scores)] print(recommend(user_id=1234, k=5)) # [(item_id, score), ...] ``` `model.recommend()` 内部: 1. 拿用户 embedding (64 维) 2. 跟所有 item embedding 算 dot product 3. 排除已交互的 item 4. 返回 top-K 毫秒级返回。 ### 相似商品推荐("你看了这个,也可能看那个") ```python def similar_items(item_id, k=10): if item_id not in item_idx: return [] iid = item_idx[item_id] item_ids_arr, scores = model.similar_items(iid, N=k+1) # 第一个是它自己,跳过 return [(item_ids[i], float(s)) for i, s in zip(item_ids_arr[1:], scores[1:])] ``` 商品详情页用:"看了这个的人还看了..."。 ### 评估:split + 看 hit@K / MAP@K ```python import numpy as np def hit_at_k(model, train_matrix, test_dict, k=10): """test_dict: {user_idx: set(item_idx)} 是 holdout 的真 interaction""" hits = 0 total = 0 for uid, true_items in test_dict.items(): if uid >= train_matrix.shape[0]: continue recommended, _ = model.recommend(uid, train_matrix[uid], N=k) if any(it in true_items for it in recommended): hits += 1 total += 1 return hits / total ``` hit@10 = 0.20 意味着 20% 的用户 top-10 推荐里有真实喜欢的。 随机推荐 hit@10 ≈ k / num_items ≈ 0.001。 200x 提升 = 信号强 = 模型 work。 ### 冷启动用户 新用户没历史 → 用户 embedding 不存在 → ALS 推不了。 fallback: ```python def recommend_with_fallback(user_id, k=10): if user_id in user_idx: return recommend(user_id, k) # 冷启动:推热门 return popular_items_in_user_segment(user_id, k) ``` 或者用 sign-up 时收集的偏好 / 人口学信息做内容-based 起步。 ### 冷启动商品 新上架商品没 interaction → ALS 给不出 embedding。 解决: - 用商品 metadata(类别 / 标签 / 描述)映射到现有 embedding 空间 - "two-tower" 模型:让 item tower 用 metadata 做 embedding 简单做法:新商品借用同类商品的 embedding 均值。 ## 几个进阶 model ### BM25 weighting ```python from implicit.nearest_neighbours import bm25_weight weighted = bm25_weight(matrix, K1=100, B=0.8) model = AlternatingLeastSquares(factors=64) model.fit(weighted) ``` BM25 给罕见物品更高 weight,对长尾推荐更好。 ### Item-Item CF (用户少 + 商品多时) ```python from implicit.nearest_neighbours import CosineRecommender model = CosineRecommender(K=10) model.fit(matrix) ``` 直接算 item-item 相似度,无 embedding 训练。 适合 100k+ items + 用户少场景。 ### BPR (Bayesian Personalized Ranking) ```python from implicit.bpr import BayesianPersonalizedRanking model = BayesianPersonalizedRanking(factors=64, learning_rate=0.05) model.fit(matrix) ``` learn-to-rank 风格,对"排序质量" 优化(不是 reconstruction)。 小数据集上经常更好。 ## 部署到生产 ```python # 训练完保存 import pickle with open('als_model.pkl', 'wb') as f: pickle.dump({ 'model': model, 'user_idx': user_idx, 'item_idx': item_idx, 'item_ids': item_ids, }, f) # inference 服务 class RecAPI: def __init__(self, path): with open(path, 'rb') as f: d = pickle.load(f) self.model = d['model'] self.user_idx = d['user_idx'] self.item_ids = d['item_ids'] # ... def recommend(self, user_id, k=10): uid = self.user_idx.get(user_id) if uid is None: return [] item_arr, _ = self.model.recommend(uid, ...) return [self.item_ids[i] for i in item_arr] ``` FastAPI 包一下,端口暴露给业务。 ### 每日重训 cron / Airflow: ```python @task def retrain_als(): events = load_recent_events(days=90) matrix = build_matrix(events) model = AlternatingLeastSquares(factors=64) model.fit(matrix) save_model(model, ...) # 通知 inference 服务 reload requests.post('http://rec-api/reload') ``` 每天凌晨 1 点训。新 interaction 进入 embedding。 ## A/B test 验证 把 50% 用户走"推荐"接口,50% 走"按热度排"。 观察: - CTR(点击率) - conversion rate(转化率) - 用户 session 时长 通常协同过滤 baseline 比"按热度" CTR 提升 30-100%。 ## 与深度模型对比 | | implicit ALS | LightFM | DeepFM | YouTube DNN | |---|---|---|---|---| | 数据量需求 | 中(万级用户) | 中 | 大 | 极大 | | 训练时间 | 秒-分钟 | 分钟 | 小时 | 天 | | 冷启动支持 | 弱 | 中(hybrid) | 中 | 中 | | 实施成本 | 极低 | 低 | 高 | 极高 | | 效果上限 | 中等 | 中-高 | 高 | 极高 | 500 万以上交互 + 想榨干 5-10% CTR → 上深度模型。 否则 ALS 就够。 ## 效果 我们小型电商上线 implicit ALS: - 数据:5w 用户 + 1w SKU + 100w events - 训练:30 秒(CPU) - 推理:< 5ms / user - A/B test:CTR +47%,conversion +18% - vs 之前的"按销量排序"基线,**显著**改善 后续上 LightFM hybrid(加 metadata)再 +12%。 深度学习准备阶段,但 ALS 已经 cover 大头 ROI。 ## 踩过的坑 1. **`matrix[uid]` 而不是 `matrix.getrow(uid)`**:implicit 0.7 后 API 改了, `recommend(user, user_items)` 要传整行 sparse vector。 2. **数据严重 popularity bias**:只看热门,ALS embedding 也偏热门。 BM25 weight 缓解。 3. **训练 / 推理使用不同 user_idx 映射** → 错位。永远 pickle 整套 {model, user_idx, item_idx, item_ids}。 4. **新用户进来推不了**:冷启动 fallback 一定要有,不然推荐 API 返空。 5. **A/B test 早期看 metric 没差异**:用户没注意新推荐位 / 流量太小 = 0 statistical power。至少 1 周 + 万级用户。

systemd socket activation:让服务按需启动(替代 inetd)

## 起因 我有一台小 VPS 跑十几个偶尔被用的服务(小工具 API、自建图床、内部 Wiki 之类)。每个 24x7 常驻进程都吃 50-200 MB 内存。 "按需启动 + 闲置自动停"能省一大半内存。 `xinetd` 是老古董做法;systemd 的 socket activation 是现代等价物 + 完全集成系统单元 + 更灵活。 ## 工作原理 1. systemd 启动时只创建 listening socket(端口),不启服务进程 2. 第一个请求到达端口时,systemd 启动服务进程并把 socket fd 传给它 3. 服务正常工作,处理后续请求 4. 闲置一段时间后服务可以 exit,systemd 重新只监听端口 5. 下次请求重复 1-4 ## 解决方案 ### 例子:按需启动一个 Python 小服务 `my-tool.service`: ```ini # /etc/systemd/system/my-tool.service [Unit] Description=My Tool API After=network.target my-tool.socket Requires=my-tool.socket [Service] Type=notify # 配合 sd_notify,启动好通知 systemd User=trio Group=trio ExecStart=/srv/my-tool/.venv/bin/python /srv/my-tool/server.py StandardInput=socket # systemd 把 socket fd 传给进程 Restart=on-failure # 闲置 5 分钟自动退出 RuntimeMaxSec=infinity [Install] WantedBy=multi-user.target ``` `my-tool.socket`: ```ini # /etc/systemd/system/my-tool.socket [Unit] Description=My Tool socket [Socket] ListenStream=8088 Accept=no # systemd 不 accept,把 listening socket 传给服务 # 闲置多久关: NoDelay=true [Install] WantedBy=sockets.target ``` ```bash sudo systemctl daemon-reload # 只 enable + start socket,不启 service sudo systemctl enable --now my-tool.socket sudo systemctl status my-tool.socket # active (listening) sudo systemctl status my-tool.service # inactive (dead) ss -tlnp | grep 8088 # 看到是 systemd 在监听 # 第一次访问 curl http://localhost:8088/ # systemd 启动 my-tool.service,处理请求 sudo systemctl status my-tool.service # active (running) ``` ### 服务端代码(Python)拿 socket fd ```python # /srv/my-tool/server.py import os, socket from wsgiref.simple_server import make_server def app(environ, start_response): start_response('200 OK', [('Content-Type', 'text/plain')]) return [b'hello from on-demand service'] # 检测是否被 systemd 启动 + socket activated LISTEN_FDS_START = 3 if os.environ.get('LISTEN_FDS') == '1': fd = LISTEN_FDS_START sock = socket.socket(fileno=fd) # 用现成的 listening socket 而非新建 sock.listen(128) # 配合 wsgiref: from wsgiref.simple_server import WSGIServer httpd = WSGIServer(('', 0), None, bind_and_activate=False) httpd.socket = sock httpd.set_app(app) print(f'serving on socket fd {fd}', flush=True) # 通知 systemd 启动完成(Type=notify 要求) notify_socket = os.environ.get('NOTIFY_SOCKET') if notify_socket: ns = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) ns.sendto(b'READY=1', notify_socket) httpd.serve_forever() else: httpd = make_server('', 8088, app) httpd.serve_forever() ``` 很多框架原生支持 socket activation: - **systemd-python** 库的 `daemon.listen_fds()` 简化拿 fd - **Gunicorn** 支持 `--bind fd://3`(systemd LISTEN_FDS 模式) - **Caddy / nginx** 都能 socket activate ### 让服务闲置后自动退出 服务端代码:accept 后开 N 秒计时,无请求就 sys.exit(0)。 或者用 `RuntimeMaxSec=10min` 强制最多跑 10 分钟(systemd 会 SIGTERM)。 ### accept-per-connection 模式(替代 inetd) 如果服务每个连接超轻量(如 TCP echo),可以 `Accept=yes`: ```ini # my-tool.socket [Socket] ListenStream=8088 Accept=yes ``` ```ini # [email protected] (注意 @) [Service] ExecStart=/srv/my-tool/handle.sh StandardInput=socket StandardOutput=socket ``` 每个连接 systemd accept 后 fork 一个 service 实例处理。 适合纯短任务,不适合 HTTP(每请求新进程开销大)。 ### 监控 ```bash # 看 socket 等待状态 sudo systemctl list-units --type=socket # 看 service 历史 sudo systemctl list-timers journalctl -u my-tool.service --since '1 day ago' ``` ## 效果 我的 VPS 上: | | 常驻 | socket activation | |---|---|---| | 12 个小服务总内存 | 1.6 GB | ~200 MB(只有正在用的几个) | | 冷启动延迟 | 0 | 100-500ms(首次请求) | | 闲置时 CPU | < 1% | 0% | 代价:用户每天第一次访问慢半秒。绝大多数内部工具能接受。 ## 与替代方案对比 | | systemd socket | inetd / xinetd | nginx fastcgi | k8s scale-to-zero | |---|---|---|---|---| | 复杂度 | 低 | 中 | 高 | 高 | | 现代 | ✅ | ❌(淘汰) | ✅ | ✅ | | 每连接 fork | 可选 | 是 | 否 | 否 | | 内存节省 | 大 | 大 | 中 | 大 | | 适合场景 | 偶用小服务 | 纯短任务 | Web 服务 | 微服务集群 | ## 踩过的坑 1. **`Type=notify` 但代码不发 READY**:systemd 默认 90 秒后认为启动 失败,把服务 kill 掉。改 `Type=simple` 或确认代码发 sd_notify。 2. **第一次冷启动慢得离谱**:venv 加载 / 模型加载 / DB 连接初始化 都在第一次请求时发生。考虑 `WatchdogSec=` + 预热请求。 3. **socket 端口冲突**:先确保对应端口没被别的进程占用。 `Type=simple` 的进程自己 bind 会冲突,必须用 socket activation 提供 的 fd。 4. **service 退出后端口短暂关闭**:闲置退出 → systemd 检测到 → 重新 开 socket。这之间几十 ms 可能拒绝连接。`KeepAlive=true` + 服务退出 前发 stop 信号给 systemd 让它先准备好。 5. **不适合高并发**:socket activation 设计给"偶尔用"。如果服务持续 有流量,常驻反而更高效(避免冷启动)。

bpftrace 一行 eBPF 脚本排查"看不见"的内核 / 用户态问题

eBPF 允许在内核里跑沙箱化的小程序,无侵入地观察系统行为。 bpftrace 是 awk 风格的高级语言,让你不用 C / libbcc 也能写 eBPF 脚本。一行就能解决很多用 strace / perf 都麻烦的问题。 ## 装 ```bash sudo apt install -y bpftrace bpftrace --version # 需要 >= 0.16,老版本功能少很多 # 内核 5.5+ / Debian 11+ / Ubuntu 20.04+ ``` ## 1. 一行解决的常见问题 ### 谁在打开 /etc/passwd ```bash sudo bpftrace -e ' tracepoint:syscalls:sys_enter_openat /str(args->filename) == "/etc/passwd"/ { printf("%s (%d) opened /etc/passwd\n", comm, pid); } ' ``` 任何进程 open /etc/passwd 时立即打印进程名 + PID。 ### 哪个进程在创建 socket ```bash sudo bpftrace -e ' tracepoint:syscalls:sys_enter_socket { printf("%s (%d) opened socket family=%d type=%d\n", comm, pid, args->family, args->type); } ' ``` ### 哪个 exec 系列 syscall 在被频繁调用 ```bash sudo bpftrace -e ' tracepoint:syscalls:sys_enter_execve { printf("%s -> %s\n", comm, str(args->filename)); } ' ``` 实时显示所有 exec —— 找出 shell 死循环 / 短生命周期进程喷。 ### 系统调用直方图 ```bash sudo bpftrace -e ' tracepoint:syscalls:sys_enter_read { @[comm] = count(); } interval:s:5 { print(@); clear(@); exit(); } ' # 5 秒内每个进程的 read 次数 ``` ### 哪个进程在 page fault ```bash sudo bpftrace -e ' software:major-faults:1 { @[comm] = count(); } ' # Ctrl-C 后看汇总 ``` major-fault = 必须从磁盘加载 page,对应 swap / mmap miss 等慢操作。 ## 2. 用户态 dynamic instrumentation (uprobe) 观察 nginx 调用某函数: ```bash # 列 nginx 二进制的可探测函数 sudo bpftrace -l 'uprobe:/usr/sbin/nginx:*' | head # 探测某个函数 sudo bpftrace -e ' uprobe:/usr/sbin/nginx:ngx_http_process_request { @[comm] = count(); } ' ``` 不需要重启 nginx、不需要改代码,实时统计某函数被调多少次。 ## 3. 时延直方图 ```bash sudo bpftrace -e ' tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; } tracepoint:syscalls:sys_exit_read /@start[tid]/ { @us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); } interval:s:5 { print(@us); clear(@us); } ' ``` 输出每 5 秒一次 read syscall 的时延直方图(微秒): ``` @us: [0] 8 |@ | [1] 256 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ | [2, 4) 384 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ | [4, 8) 128 |@@@@@@@@@@@@@@@@ | [8, 16) 16 |@@ | [16, 32) 4 | | [1K, 2K) 1 | | ``` 立刻看出 read 大多 1-4 µs,偶尔有 1-2 ms 的慢——可能是磁盘 / 网络 IO 慢。 ## 4. 内置变量速查 - `pid` / `tid`:进程 / 线程 ID - `comm`:进程名(前 16 字符) - `nsecs`:当前时间戳(纳秒) - `cpu`:当前 CPU - `args`:tracepoint 参数(结构体) - `kstack` / `ustack`:内核 / 用户调用栈 ## 5. 输出 + 聚合 ``` @hist_name = hist(value) # 2 的幂直方图 @lhist_name = lhist(value, 0, 100, 10) # 线性直方图 @count[key] = count() # 计数 @sum[key] = sum(value) # 求和 @avg[key] = avg(value) # 平均 @stats[key] = stats(value) # min/max/avg/count/sum @top10 = bottom(10, count()) # Top-N ``` Map 自动按 key 分组。 ## 6. 实战:找出"哪个文件被读最多" ```bash sudo bpftrace -e ' tracepoint:syscalls:sys_enter_openat { @opens[str(args->filename)] = count(); } interval:s:30 { print(@opens, 10); clear(@opens); } ' # 每 30 秒输出 Top 10 被 open 的文件 ``` ## 7. 现成脚本库:bpftrace tools ```bash git clone https://github.com/iovisor/bpftrace ls bpftrace/tools # biolatency.bt biosnoop.bt execsnoop.bt funcslower.bt # opensnoop.bt runqlat.bt tcpaccept.bt tcpconnect.bt # ... ``` 例:磁盘 IO 时延直方图 ```bash sudo bpftrace bpftrace/tools/biolatency.bt ``` `execsnoop.bt` 实时显示新建进程,`tcpconnect.bt` 显示所有新建 TCP 连接, 都是诊断生产问题的利器。 ## 8. 跟 bcc 比 bcc 是用 C 写 eBPF + Python 包装,更强大但学习成本高。 bpftrace 是高级语言,限制更多但写起来快 10x。生产经验: - 简单监控 / 调试一次性脚本:bpftrace - 持续运行的复杂 agent / 工具:bcc ## 9. 性能影响 bpftrace 比 strace / perf 通常开销小 5-10x。但还是有开销: - tracepoint:几乎零(编译时插入的探测点) - kprobe / uprobe:每次触发都中断到 BPF 解释器,有一点开销 - 高频事件(每秒百万级)+ map 写:可能加 5-15% CPU 生产环境 attach 前先估算事件频率,从低开销 tracepoint 开始。 ## 10. CO-RE / libbpf-tools 最新的 BPF 工具用 CO-RE(Compile Once, Run Everywhere): ```bash sudo apt install -y libbpf-tools sudo execsnoop-bpfcc # bcc 版(需要 kernel-devel) sudo execsnoop # libbpf-tools 版(任意内核 5.4+) ``` CO-RE 工具不需要内核头文件,便携性最好。生产首选 libbpf-tools。 ## 踩过的坑 - 老内核(< 5.0)功能阉割严重;如果没法升内核,回退到 strace / perf。 - BTF(BPF Type Format)没启用的内核:很多 bpftrace 脚本直接报错。 `ls /sys/kernel/btf/vmlinux` 看是否有,没有就只能用 kprobe 而非 fentry。 - 一个 bpftrace 脚本里写 5+ 个 probe + 大 map → BPF verifier 拒绝 ("too many instructions" / "stack overflow")。拆成多个脚本。 - 容器内跑 bpftrace 没权限:需要 `--privileged` + `--cap-add SYS_ADMIN`, 或者直接在 host 上跑。

LVM 上扩 / 缩 ext4 分区(不停机)

LVM 比直接分区灵活得多:磁盘空间可以在线扩、跨多块物理盘组卷、做快照。 日常运维最常用的就是"分区满了,加块盘扩进去"。下面按真实流程走一遍。 前置:分区是 `ext4` on `LVM`,要扩或缩的是某个 LV。XFS 类似但不能缩。 ## 现场摸底 ```bash df -h /data # Filesystem Size Used Avail Use% Mounted on # /dev/mapper/vg0-data 100G 95G 5G 95% /data sudo lvs # LV VG Attr LSize # data vg0 -wi-ao 100.00g # root vg0 -wi-ao 50.00g sudo vgs # VG #PV #LV #SN Attr VSize VFree # vg0 1 2 0 wz--n- 200.00g 50.00g sudo pvs # PV VG Fmt Attr PSize PFree # /dev/sda2 vg0 lvm2 a-- 200.00g 50.00g ``` ## 场景 A:VG 有剩余空间,扩 LV + ext4 最简单情况。直接扩: ```bash # 加 30G 到 data LV,顺手扩 ext4 sudo lvextend -L +30G -r /dev/vg0/data # -r 等价于扩完后自动跑 resize2fs(ext4)或 xfs_growfs ``` 不带 `-r` 时分两步: ```bash sudo lvextend -L +30G /dev/vg0/data sudo resize2fs /dev/vg0/data ``` ext4 / XFS 都支持在线扩容,不需要 umount,业务无感知。 校验: ```bash df -h /data # /dev/mapper/vg0-data 130G 95G 31G 76% /data ``` ## 场景 B:VG 空间不够,先加物理盘 ```bash # 新磁盘 /dev/sdb 装上,整盘做 PV(生产建议先 fdisk 建分区给它) sudo pvcreate /dev/sdb sudo vgextend vg0 /dev/sdb sudo vgs # VG #PV #LV #SN Attr VSize VFree # vg0 2 2 0 wz--n- 700.00g 550.00g # 然后回到场景 A 扩 LV sudo lvextend -L +100G -r /dev/vg0/data ``` ## 场景 C:缩 LV(危险,要停机 + 备份) ext4 缩容必须先 umount,且 **没有 100% 安全的在线缩容方案**。 执行前 ===先做备份===。 ```bash # 1. umount sudo umount /data # 2. fsck(强制检查) sudo e2fsck -fy /dev/vg0/data # 3. 文件系统先缩到目标大小 sudo resize2fs /dev/vg0/data 60G # 4. LV 缩到同样或稍大的尺寸(建议 +1G 余量) sudo lvreduce -L 61G /dev/vg0/data # 5. 文件系统再扩满 LV sudo resize2fs /dev/vg0/data # 6. 重新 mount sudo mount /data ``` 步骤 3 < 步骤 4 的顺序不能颠倒!文件系统比块设备大会立即损坏。 ## 场景 D:从一块物理盘迁数据到另一块(替换坏盘) ```bash # 新盘加入 VG sudo pvcreate /dev/sdc sudo vgextend vg0 /dev/sdc # 把 sdb 上的所有 LV 迁到其它 PV(pvmove 是在线的) sudo pvmove /dev/sdb # 漫长过程,可以加 -b 后台跑,sudo pvmove -b /dev/sdb # 完成后从 VG 移除 sudo vgreduce vg0 /dev/sdb sudo pvremove /dev/sdb # 现在 sdb 可以物理移除 ``` ## 快照(紧急回滚) ```bash # 给 data 拍个快照,预留 10G 写时变化 sudo lvcreate -L 10G -s -n data-snap /dev/vg0/data # 操作系统看到的是冻结时刻的 data,挂载它能恢复文件 sudo mkdir /mnt/snap sudo mount /dev/vg0/data-snap /mnt/snap # 没问题就删掉快照(不删会占空间且影响写性能) sudo umount /mnt/snap sudo lvremove /dev/vg0/data-snap ``` 升级 / 大改动前给 root LV 拍快照,是 LVM 系统的"undo"能力。 ## 踩过的坑 - `lvextend -L 200G` 是 **设置** 总大小到 200G,`-L +100G` 是 **追加** 100G。 搞反了能把分区缩没掉。 - ext4 在 64-bit 模式(新版本默认)才支持超 16T;老分区扩到 16T+ 会 fail。 `tune2fs -O 64bit /dev/...` 升级,操作需要 fsck 时间。 - pvmove 时如果系统 reboot,会卡在半迁状态。重新启动后用 `pvmove --abort` 或继续 `pvmove`。 - XFS 不能缩 —— 想缩只能 dump + mkfs + restore。所以新分区如果有可能缩, 用 ext4。

用 LUKS 给整盘加密 + 启动时密码解锁(Debian / Ubuntu)

笔记本 / 移动机器一旦丢失,硬盘上的数据如果没加密,攻击者拆下盘 插到另一台机器就能读。LUKS(Linux Unified Key Setup)是 Linux 标准全盘加密方案。 下面分两个场景: - A. 新装系统时加密 - B. 给已有数据盘加密 ## A. 新装系统时加密(推荐) Debian / Ubuntu / Fedora 安装器都有"加密整盘"选项,勾上即可: - 设置一个开机解锁密码(passphrase) - 安装器自动建 LUKS 容器 + LVM 上挂分区 完成后启动会先停在密码输入界面,输对了才进 grub / 启动内核。 校验: ```bash lsblk # NAME TYPE MOUNTPOINT # nvme0n1 # ├─nvme0n1p1 part /boot/efi # ├─nvme0n1p2 part /boot # └─nvme0n1p3 part # └─nvme0n1p3_crypt crypt # ├─ubuntu--vg-root lvm / # └─ubuntu--vg-swap lvm [SWAP] sudo cryptsetup status nvme0n1p3_crypt # /dev/mapper/nvme0n1p3_crypt is active and is in use. # type: LUKS2 # cipher: aes-xts-plain64 # keysize: 512 bits ``` ## B. 给已有数据盘 / 第二块盘加密 ```bash # 准备空盘 /dev/sdb(数据会清!) sudo cryptsetup luksFormat /dev/sdb # WARNING! Will overwrite data on /dev/sdb irrevocably. # Are you sure? (Type 'yes' in capital letters): YES # Enter passphrase: ... # Verify passphrase: ... sudo cryptsetup open /dev/sdb data # 设备出现在 /dev/mapper/data # 在加密容器内建文件系统 sudo mkfs.ext4 /dev/mapper/data # 挂载 sudo mkdir /mnt/data sudo mount /dev/mapper/data /mnt/data ``` ## 启动时自动打开(/etc/crypttab) `/etc/crypttab`: ``` data UUID=<luks-uuid> none luks,discard ``` 获取 UUID:`sudo blkid /dev/sdb` 取 `UUID="..."`。 `/etc/fstab`: ``` /dev/mapper/data /mnt/data ext4 defaults,nofail 0 2 ``` `none` 是 keyfile 字段。如果填路径,开机用那个文件解锁(无需输密码)。 `luks,discard` 选项: - `luks`:指定为 LUKS 格式 - `discard`:允许 TRIM 给底层 SSD(提性能 + 寿命;但泄露空间使用模式) 重启验证。 ## 加多个密码 / 撤销密码 LUKS 支持最多 8 个 keyslot: ```bash # 加新密码 sudo cryptsetup luksAddKey /dev/sdb # 输旧密码 + 新密码 # 删某个 keyslot(0 是第一个) sudo cryptsetup luksKillSlot /dev/sdb 0 # 看 keyslot 状态 sudo cryptsetup luksDump /dev/sdb ``` 主密码忘记了用备用密码登录,进去 KillSlot 删主密码再 AddKey 新的。 ## 用 keyfile 而不是密码 ```bash # 生成 4K 随机 keyfile sudo dd if=/dev/urandom of=/root/luks.key bs=1024 count=4 sudo chmod 400 /root/luks.key # 加到 LUKS sudo cryptsetup luksAddKey /dev/sdb /root/luks.key # crypttab 改用 keyfile # data UUID=... /root/luks.key luks ``` 适用:服务器机器没人输密码;keyfile 存 USB / TPM。 ## YubiKey / FIDO2 解锁 ```bash sudo apt install -y libpam-u2f systemd-cryptsetup # 注册 YubiKey 到 LUKS sudo systemd-cryptenroll --fido2-device=auto /dev/sdb # /etc/crypttab 加上 # data UUID=... none fido2-device=auto,luks ``` 启动时插 YubiKey 触摸金属片就解锁。比记复杂密码方便。 ## TPM 解锁(笔记本无人值守) ```bash sudo systemd-cryptenroll --tpm2-device=auto /dev/sdb # crypttab 加 tpm2-device=auto ``` TPM 解锁的安全性: - ✅ 抗"拔盘到另一机器"攻击:TPM 绑定主板 - ❌ 抗"原机被偷":开机就自动解锁,攻击者通电就能用 要双保险加 PIN: ```bash sudo systemd-cryptenroll --tpm2-device=auto --tpm2-with-pin=yes /dev/sdb ``` ## 备份 header(关键!) LUKS header 在分区开头几 MB,如果损坏(mkfs 误操作 / 磁盘坏块), 整个加密容器无法解开(数据丢失)。备份: ```bash sudo cryptsetup luksHeaderBackup /dev/sdb --header-backup-file /secure/sdb-luks-header.bin ``` 把这个 bin 存到安全的地方(U 盘 / 加密邮箱)。还原: ```bash sudo cryptsetup luksHeaderRestore /dev/sdb --header-backup-file /secure/sdb-luks-header.bin ``` ## 加密性能 LUKS2 + AES-XTS 是当前默认。现代 CPU(带 AES-NI)几乎无性能损失: ```bash cryptsetup benchmark # aes-xts 256b 3000 MiB/s 3000 MiB/s # ... ``` 3 GB/s 远超 SSD 顺序读写速度,加密不会成瓶颈。 ## 隐藏 / SED 进阶: - **SED**(Self-Encrypting Drive):硬件加密,性能更好,但要信任厂商 实现(多家被发现有后门) - **Plausible deniability**(VeraCrypt 风格的隐藏卷):LUKS 不原生支持 通常 LUKS 已经够。 ## 踩过的坑 - 装系统时设置弱密码(如 "1234"):暴力破解几秒搞定。LUKS 用 argon2id 让暴力很慢,但密码本身弱无济于事。**最少 16 位混合字符**或用 diceware。 - 没备份 LUKS header → 一次 mkfs 失手数据全没。养成 luksFormat 后立刻 备份 header 的习惯。 - crypttab 写错让启动卡在解锁界面无限循环。从 LiveUSB 进入修复。 - 加密 swap:如果用 hibernation / suspend-to-disk,需要 swap 加密, 否则 RAM 数据写到 swap 时明文。`/etc/crypttab` 配 swap 用随机 key 每次开机重新生成(不能 hibernate)或用固定 key(可以 hibernate)。