/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
背景
家里有台吃灰的 CM311-1A,运营商送的那种电视盒子。参数在今天是妥妥的电子垃圾:
| SoC | Amlogic S905L3A |
| CPU | 4× Cortex-A55 |
| 内存 | 2 GB |
| 存储 | 16 GB eMMC |
| 网络 | 家宽 |
刷了 Armbian 之后,我把它当成一台小型服务器用,现在跑着 7 个容器服务。
这篇文章不复述安装步骤,只讲三个在低配上必然遇到、但搜索很难搜到答案的问题。
整体链路
浏览器
→ Cloudflare 边缘(SSL: Full)
→ cloudflared(apt 原生部署,非 Docker)
→ OpenResty(host 网络,:80)
→ 站点目录 / 各服务端口
隧道这一层的调参结论很反直觉:默认参数(QUIC + 双栈)是唯一可用的组合。加 –protocol http2 会因为 TCP 7844 不通直接断隧道,加 –edge-ip-version 4 会掐掉唯一可用的 IPv6 通路。
坑一:16G eMMC 一周就满
低配设备上,Docker 有三处会失控,按危害排序:
① 悬空镜像
docker compose pull 拉新版本时,旧镜像不会消失,只是从「有标签」变成 <none>:<none>。来回升级几次,几个 G 就没了。
docker image prune -f # 只删悬空镜像,安全
注意不要加 -a。docker system prune -a 会删掉所有没有容器在用的镜像,容器一停就再也起不来。
② 容器日志
Docker 默认 json-file 驱动没有大小上限。在 /etc/docker/daemon.json 里加:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "2" }
}
改完 systemctl restart docker。但有两个坑:
所以必须逐个确认:
docker inspect 容器名 –format '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}'
输出 map[] 就是没上限。
已经写大的日志不要 rm——容器还开着句柄,空间不会释放。用截断:
truncate -s 0 /var/lib/docker/containers/<ID>/<ID>-json.log
③ 构建缓存
docker system df 里单独一行 Build Cache,经常比镜像本身还大。docker builder prune -f 清掉。
坑二:2G 内存跑 7 个容器,第一版直接被 OOM 打死
现象是最迷惑的一种:
$ docker ps
CONTAINER ID IMAGE STATUS
a1b2c3d4e5f6 xxx:latest Restarting (137) 3 seconds ago
容器不是起不来,是起来几秒就被杀。docker inspect 看到 ExitCode: 137,dmesg 里能看到:
Out of memory: Killed process 12345 (java) total-vm:2891234kB
137 = 128 + 9,就是被 SIGKILL 干掉,最常见的原因就是内核 OOM Killer。
一开始我以为是服务太重,甚至去看了换 4G 内存的板子。后来发现真正的原因跟服务本身无关,是运行时按宿主机内存算默认堆:
| JVM(Java 8) | 物理内存的 1/4 | 完全不看 cgroup,容器里也一样 |
| JVM(Java 10+) | 容器 limit 的 1/4 | 有 UseContainerSupport,能感知 cgroup |
| Node.js | 约 2G(64 位) | 与机器内存无关,写死在 V8 |
问题在于 Java 8 那个版本不感知 cgroup。你在 compose 里写了 mem_limit: 256m,JVM 依然按宿主机的 2G 算出 512M 的堆,一启动就超限被杀。而且 7 个容器各自独立计算,谁也不让谁。
确认办法:
docker exec 容器名 java -XX:+PrintFlagsFinal -version | grep -Ei 'maxheapsize|maxram'
如果 MaxHeapSize 明显大于你设的 limit,就中了。
三条修法,按优先级:
① 显式钉死堆上限(Java 8 必做)
environment:
– JAVA_OPTS=–Xms128m –Xmx256m –XX:MaxMetaspaceSize=128m
② Java 10+ 用百分比,跟着容器 limit 走
environment:
– JAVA_OPTS=–XX:MaxRAMPercentage=60 –XX:InitialRAMPercentage=30
③ Node 服务加参数
command: ["node", "–max-old-space-size=256", "app.js"]
然后给容器本身加上限:
services:
app:
mem_limit: 384m
memswap_limit: 384m
memswap_limit 等于 mem_limit 时,容器就没有 swap 可用。低配机器上这点很重要——有 swap 的时候 OOM 不会立刻杀进程,而是让整机开始疯狂换页,SSH 都连不上,比直接杀掉难受得多。
验证要看到真实占用:
docker stats –no-stream –format 'table {{.Name}}\\t{{.MemUsage}}\\t{{.MemPerc}}'
7 个容器加起来稳定在 1.4G 左右,留 600M 余量,才算配置到位。
还有一个隐蔽的坑:docker stats 里的 MEM USAGE 只算匿名内存,页缓存不算,所以它比内核里看到的实际占用偏低。真要核对得看 cgroup:
# cgroup v1
cat /sys/fs/cgroup/memory/docker/<容器ID>/memory.usage_in_bytes
# cgroup v2(新版 Armbian 默认是这个)
cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.current
两个数字对不上是正常的,别以为统计错了。
坑三:青龙脚本报 No module named ‘notify’
青龙面板里跑脚本,日志结尾是:
⚠️ 青龙通知模块 notify.py 导入失败,已跳过通知:No module named 'notify'
脚本跑完了,只是通知没发出去。notify.py 确实在 /ql/data/scripts/ 下。所以问题只能是那个目录不在 sys.path 里。
两次失败尝试
真因
看 otask.sh,关键是这一行(只对 .py / .pyc 任务生效):
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
dir_preload = /ql/shell/preload。这个目录里有青龙官方的 sitecustomize.py,最后一行是模块级的 import task_before。
于是链条是:otask.sh 把 preload 排最前 → site.py 找到的是官方那份 → Python 模块按名字缓存,同名只导入一个 → 放在 dep_cache 里的那份永远不执行,且不报错。
修法(两条一起做)
A. 走官方钩子,把路径追加进去:
# /ql/data/config/task_before.py
import sys
_scripts_dir = "/ql/data/scripts"
if _scripts_dir not in sys.path:
sys.path.append(_scripts_dir)
一定要 append,不要 insert(0)。仓库里几百个脚本,随便哪个叫 os.py、json.py 的文件都会顶掉标准库。
B. 把 dep_cache 里那份改名,让两个钩子共存:
cd /ql/data/dep_cache/python3/lib/python3.11/site-packages/
mv sitecustomize.py usercustomize.py
site.py 的加载顺序是先 sitecustomize 再 usercustomize,两个名字不同,可以共存。
唯一可信的验证方式
docker exec 容器名 bash -c 'cd /ql/data/scripts && QL_DIR=/ql task 仓库名/目录名/脚本名.py'
这条命令等价于「在面板上点运行」。
不要用 python3 -E 或裸 shell 里 import notify 的结果当结论——那些进程没经过 otask.sh,PYTHONPATH 完全不同。我在这上面浪费了一整轮。
一些收尾结论
- 低配设备上,稳定性靠配置不靠硬件:日志上限、内存上限、重启策略,三样配好,2G 也能长期跑
- 判断要从症状反推层级:137 和 502 是完全不同的问题,别混在一起查
- 「配置值对了」不等于「修好了」:要有能返回 200 的验收命令
- 容器化部署不要用面板自更新:改的是容器内文件,重建即丢,正路是 compose pull && up -d
机器还在跑,欢迎交流。


