一、这一篇讲什么
系列第一篇把选型讲完了:dailyhot-api 主力、RSSHub 补位、redis 中转、Next.js 聚合渲染、SearXNG 做可选的搜索件,五容器跑在阿里云 2C1.8G 单机上。这一篇是搭建与踩坑实录——compose 怎么编排、nginx 反代怎么配、五个组件各自的坑在哪。照旧先亮底牌:这是本站真实在跑的栈,坑都是真踩过的;性能话题全部定性,一个没测过的数字都不给。
这一篇的破题句:“全限额单机”这条约束,会让部署里的每个默认值都变成坑。下面的坑位清单里,一半不是组件的 bug,是组件的默认行为撞上了小内存机器。
二、compose 全栈编排:五个容器一份文件
编排的纪律从第一篇延续:人人有内存限额、出门带重启策略、端口只绑回环口、镜像只 pull 不 build。全文骨架(数值为示意量级,按自己机器压):
# /opt/hotboard/docker-compose.yml —— 密码类统一走 .env,不写进文件进版本库
services:
app:
image: registry.example.com/hotboard-app:latest # CI 构建产物,服务器从不 build(内存红线)
environment:
- HOSTNAME=0.0.0.0 # 坑四:不显式设,standalone 会绑到容器 ID 上,端口映射直接失联
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
- TZ=Asia/Shanghai # 坑五:不设 TZ,容器里的 cron 与日志全是 UTC,排查时间线会错位
ports:
- "127.0.0.1:8080:3000" # 只暴露回环口,公网唯一入口是主站 nginx
mem_limit: 400m
restart: unless-stopped
depends_on:
redis:
condition: service_healthy # app 抢跑而 redis 没就绪时,首启就是白屏
redis:
image: redis
command: >
redis-server --requirepass ${REDIS_PASSWORD}
--maxmemory 150mb --maxmemory-policy allkeys-lru
healthcheck: # 上面 service_healthy 的依据
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "PING"]
interval: 10s
mem_limit: 200m # 大于 maxmemory:给 fork 的写时复制留余量
restart: unless-stopped
dailyhot:
image: imsyy/dailyhot-api
mem_limit: 150m
restart: unless-stopped
rsshub:
image: diygod/rsshub
environment:
- NODE_ENV=production
1. 部分 radix 路由默认不启用,按官方文档配置表逐项补环境变量开关(坑一)
mem_limit: 350m
restart: unless-stopped
searxng:
image: searxng/searxng
volumes:
- ./searxng:/etc/searxng # 挂进来的 settings.yml 负责关 limiter(坑二)
ports:
- "127.0.0.1:8081:8080"
mem_limit: 250m
restart: unless-stopped
限额的总和小于物理内存减去博客主站的占用,这条第一篇讲过;本篇补一句运维侧的配套:限额之外还要有巡检——docker stats --no-stream 每天扫一眼,哪个容器常年顶着限额跑,就说明它的预算定小了或它该被优化了;容器反复重启的排查路径,站内《Docker 重启循环排障》有完整展开。反模式:限额写了就不管。限额不是交完作业的仪式,被限额反复杀死再重启的容器,等于一个自带内存泄漏还每天准点报到的隐患。
三、nginx 反代:一个域名,两条路径
80/443 在主站 nginx 手里,新栈只听回环口。要点是一个子域按路径分流,证书与 DNS 全部复用,零新增成本:
# 主站 nginx 追加的 server 块
server {
listen 80;
server_name hot.example.com; # 独立子域,证书续期复用主站那套
location / {
proxy_pass http://127.0.0.1:8080; # 聚合页(Next.js standalone 映射的回环口)
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s; # 页面有 redis 兜底,不该陪一个卡住的上游等超时
}
location /search/ {
proxy_pass http://127.0.0.1:8081/; # 结尾斜杠:剥掉 /search 前缀再转发给 SearXNG
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
三个配置要点单独点名:proxy_read_timeout 要显式给一个有主见的值,默认 60 秒对”有缓存兜底的页面”太宽容;X-Forwarded-For 必须传,否则坑二里 SearXNG 的 limiter 看到的永远是 127.0.0.1;子路径反代 SearXNG 时,它自己的 settings.yml 里 base_url 要配成同样的子路径,否则页面出来了静态资源 404。反代层面的通用故障(502/504 一类)排查,站内《Nginx 502/504 排障》是专门的展开。
四、踩坑清单:五个坑,每个都是现象到修法
坑一:RSSHub 部分路由 404
现象:同一批路由,本地跑源码正常,进了生产容器就 404 或空结果。定位:不是网络问题,是这部分依赖 radix 路由匹配的接口默认不启用,需要按官方文档的配置表补对应的环境变量开关;另有部分路由要配 cookie/token 才出数据。修法:在 compose 的 rsshub 环境变量区逐项补齐,具体开关名随版本变动,以官方文档为准,不抄二手教程。配套纪律:dailyhot 和 RSSHub 都自带不了缓存兜底,抓取一律走服务端定时任务进 redis——裸连上游,改版和限流任何一个都能把页面打黑。
坑二:SearXNG 的 limiter 与外部访问
现象:反代后搜索前几下正常,接着全部 429 或直接被 ban。定位:SearXNG 默认开 limiter 做机器人防护,反代之后它看到的来源 IP 全是 nginx 所在机器,几个真实用户被折叠成一个 IP,瞬间触发限流。修法二选一:自用场景直接在挂载的 settings.yml 里 server.limiter: false;要保留限流就把真实 IP 传进去(limiter 配置里读 X-Forwarded-For 的 real_ip 设置)。附带一个次生坑:挂载 settings.yml 时路径和权限不对,容器会静默用默认配置起,你以为改了其实没改——改完务必 docker compose restart searxng 后进容器确认文件内容,这一步省掉,后面的排查全是在验证一个没生效的配置。
坑三:redis 的密码与内存策略
三个小坑叠在一起。密码:特殊字符在 compose 与连接 URL 里有两层转义,最省心的做法是密码只用字母数字,或统一走 .env 变量插值(第二节写法);连接串里带密码的格式是 redis://:密码@redis:6379,冒号别丢。内存策略:maxmemory-policy allkeys-lru 只因为这是纯缓存实例,里面只有可重建的数据;哪天想把业务数据也塞进来,先读《Redis 内存打满与驱逐策略》再动手。余量:mem_limit 必须大于 maxmemory,fork 时的写时复制需要这块差值,贴着设等于定时炸弹。
坑四:Next.js 容器内存与 standalone 产物
这条是五个坑里最隐蔽的。Next.js 官方自托管路径是 output: 'standalone' 加多阶段构建,运行镜像只拷 .next/standalone、.next/static 与 public 三样——公开对比数据(WeSeek 的实测文章)显示同一应用的镜像能从 2GB 级降到几百 MB 级,对小机的磁盘与内存双友好。但 standalone 的 server.js 拿环境变量 HOSTNAME 当监听地址,而 Docker 容器里 HOSTNAME 默认是容器 ID——于是服务”起来了”,端口映射却怎么都连不通。修法就是第二节那行 HOSTNAME=0.0.0.0。构建侧的配套红线:绝不在本机 build(构建内存峰值能打穿 1.8G 机器,出事的定位思路见《OOM 与内存泄漏的定位》),CI 出镜像、服务器只 pull。再给 Node 堆上一道 NODE_OPTIONS=--max-old-space-size 的显式上限,让它在限额内自己收着分配,比被 cgroup 硬杀体面。
坑五:定时刷新,cron 还是客户端 SWR
榜单数据的”新鲜”由谁驱动,两个流派对比:
| 方案 | 机制 | 优点 | 缺点 | 本站取用 |
|---|---|---|---|---|
| 服务端定时抓取(cron) | node-cron 或系统 cron 定时打上游、写 redis | 与访客无关;上游压力可控可排班;失败可重试可告警 | 无人访问也在跑;配错时区全部错位 | 主力 |
| 客户端 SWR | 页面 stale-while-revalidate,访客到来触发刷新 | 有人看才回源,零后台 | 高峰时一窝蜂回源;新鲜度依赖前端正确性 | 展示层兜底 |
本站的组合是 cron 保底、SWR 微调。选这个组合还因为它顺手解决了缓存三件套:击穿——访客永远只读 redis,回源只发生在 cron 单点,天然 single-flight,访客再多也不会一起打上游;雪崩——各源 TTL 加随机抖动,避免同一时刻集体过期、下一轮 cron 一起压上游;穿透——上游挂掉时把空结果也写一个短 TTL 的降级标记,否则每个请求都会穿透到已经挂掉的上游。反模式:只上 SWR 不上 cron。看起来省了一个组件,实际是把回源时机交给不可控的访问模式,高峰击穿、夜间数据发霉两头挨打。
五、验收:怎么确认这套栈站住了
| 验收项 | 目标 | 怎么测 |
|---|---|---|
| 全栈起来 | 五容器 running,redis healthy | docker compose ps;curl -I http://127.0.0.1:8080 本机通 |
| 反代路径 | 域名 / 与 /search/ 都通 | curl -I https://hot.example.com/ 与 /search/ 各来一次 |
| 内存有界 | 限额不被常年顶破 | docker stats --no-stream 连续巡检一周;超限重启看 docker inspect 的 OOMKilled 字段 |
| 故障降级 | 单个上游挂只黑一张卡 | docker compose stop dailyhot,页面其余部分照常出、对应卡片标旧数据时间 |
| 榜单新鲜 | 随 cron 节奏更新 | 卡片上的更新时间戳跟着走;redis-cli --scan 抽查键与 TTL |
| 搜索可用 | limiter 不误伤真实使用 | 连续搜二十次不 429;换设备访问正常 |
六、诚实结论与下一篇
这套栈跑稳的三个前提没变:全限额、无本机构建、上游宽容。本文五个坑的共同教训是:小内存机器上,每个组件的默认配置都值得怀疑一遍——默认监听地址、默认时区、默认限流策略、默认无密码,全都是按”一台好机器、一次内网部署”的假设给的。性能与资源占用本篇全部定性,真实运行数据留给系列第三篇复盘,那篇会讲这套栈上线以来的内存曲线形状、哪些限额最终调过、以及”如果要重来一遍会改什么”。
版本核实(撰写时检索):RSSHub(DIYgod/RSSHub)、dailyhot-api(imsyy)、SearXNG 官方镜像均为活跃维护;Next.js 的 standalone 输出与自托管指引出自官方文档,镜像体积对比为公开数据引用(WeSeek 博客,约 2GB 降至几百 MB 量级);SearXNG 的 limiter 与 base_url 配置以官方文档为准。安装命令与镜像 tag 一律不钉版本;本文无本站实测性能数字,内存数值为示意量级。