欢迎光临
我们一直在努力

一台 2G 内存的电视盒子,我跑了 7 个容器

/* 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。但有两个坑:

  • 只对之后新建的容器生效,已运行的不会变
  • 1Panel 这类面板创建的容器不继承 daemon.json 的 log-opts,面板会自己生成 HostConfig
  • 所以必须逐个确认:

    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 里。

    两次失败尝试

  • 在容器环境变量里加 PYTHONPATH=/ql/data/scripts —— 无效。青龙执行任务时会自己重写 PYTHONPATH。
  • 往 dep_cache/…/site-packages/ 放一个 sitecustomize.py,内容是 sys.path.append(…) —— 单独 python3 -c "import notify" 成功,任务里照样失败。
  • 真因

    看 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

    机器还在跑,欢迎交流。

    赞(0)
    未经允许不得转载:171主机测评 » 一台 2G 内存的电视盒子,我跑了 7 个容器
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址