首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一台 2G 内存的电视盒子,我跑了 7 个容器

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

原创
作者头像
用户10817318
发布于 2026-10-21 14:18:17
发布于 2026-10-21 14:18:17
530
举报

背景

家里有台吃灰的 CM311-1A,运营商送的那种电视盒子。参数在今天是妥妥的电子垃圾:

项

规格

SoC

Amlogic S905L3A

CPU

4× Cortex-A55

内存

2 GB

存储

16 GB eMMC

网络

家宽

刷了 Armbian 之后,我把它当成一台小型服务器用,现在跑着 7 个容器服务。

这篇文章不复述安装步骤,只讲三个在低配上必然遇到、但搜索很难搜到答案的问题。

整体链路

代码语言:txt
复制
浏览器
  → 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 就没了。

代码语言:bash
复制
docker image prune -f    # 只删悬空镜像,安全

注意不要加 -a。docker system prune -a 会删掉所有没有容器在用的镜像,容器一停就再也起不来。

② 容器日志

Docker 默认 json-file 驱动没有大小上限。在 /etc/docker/daemon.json 里加:

代码语言:json
复制
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "2" }
}

改完 systemctl restart docker。但有两个坑:

  1. 只对之后新建的容器生效,已运行的不会变
  2. 1Panel 这类面板创建的容器不继承 daemon.json 的 log-opts,面板会自己生成 HostConfig

所以必须逐个确认:

代码语言:bash
复制
docker inspect 容器名 --format '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}'

输出 map[] 就是没上限。

已经写大的日志不要 rm——容器还开着句柄,空间不会释放。用截断:

代码语言:bash
复制
truncate -s 0 /var/lib/docker/containers/<ID>/<ID>-json.log

③ 构建缓存

docker system df 里单独一行 Build Cache,经常比镜像本身还大。docker builder prune -f 清掉。

坑二:2G 内存跑 7 个容器,第一版直接被 OOM 打死

现象是最迷惑的一种:

代码语言:bash
复制
$ docker ps
CONTAINER ID   IMAGE          STATUS
a1b2c3d4e5f6   xxx:latest     Restarting (137) 3 seconds ago

容器不是起不来,是起来几秒就被杀。docker inspect 看到 ExitCode: 137,dmesg 里能看到:

代码语言:txt
复制
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 个容器各自独立计算,谁也不让谁。

确认办法:

代码语言:bash
复制
docker exec 容器名 java -XX:+PrintFlagsFinal -version | grep -Ei 'maxheapsize|maxram'

如果 MaxHeapSize 明显大于你设的 limit,就中了。

三条修法,按优先级:

① 显式钉死堆上限(Java 8 必做)

代码语言:yaml
复制
environment:
  - JAVA_OPTS=-Xms128m -Xmx256m -XX:MaxMetaspaceSize=128m

② Java 10+ 用百分比,跟着容器 limit 走

代码语言:yaml
复制
environment:
  - JAVA_OPTS=-XX:MaxRAMPercentage=60 -XX:InitialRAMPercentage=30

③ Node 服务加参数

代码语言:yaml
复制
command: ["node", "--max-old-space-size=256", "app.js"]

然后给容器本身加上限:

代码语言:yaml
复制
services:
  app:
    mem_limit: 384m
    memswap_limit: 384m

memswap_limit 等于 mem_limit 时,容器就没有 swap 可用。低配机器上这点很重要——有 swap 的时候 OOM 不会立刻杀进程,而是让整机开始疯狂换页,SSH 都连不上,比直接杀掉难受得多。

验证要看到真实占用:

代码语言:bash
复制
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}'

7 个容器加起来稳定在 1.4G 左右,留 600M 余量,才算配置到位。

还有一个隐蔽的坑:docker stats 里的 MEM USAGE 只算匿名内存,页缓存不算,所以它比内核里看到的实际占用偏低。真要核对得看 cgroup:

代码语言:bash
复制
# 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'

青龙面板里跑脚本,日志结尾是:

代码语言:txt
复制
⚠️ 青龙通知模块 notify.py 导入失败,已跳过通知:No module named 'notify'

脚本跑完了,只是通知没发出去。notify.py 确实在 /ql/data/scripts/ 下。所以问题只能是那个目录不在 sys.path 里。

两次失败尝试

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

真因

看 otask.sh,关键是这一行(只对 .py / .pyc 任务生效):

代码语言:bash
复制
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. 走官方钩子,把路径追加进去:

代码语言:python
复制
# /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 里那份改名,让两个钩子共存:

代码语言:bash
复制
cd /ql/data/dep_cache/python3/lib/python3.11/site-packages/
mv sitecustomize.py usercustomize.py

site.py 的加载顺序是先 sitecustomize 再 usercustomize,两个名字不同,可以共存。

唯一可信的验证方式

代码语言:bash
复制
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

机器还在跑,欢迎交流。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 背景
  • 整体链路
  • 坑一:16G eMMC 一周就满
  • 坑二:2G 内存跑 7 个容器,第一版直接被 OOM 打死
  • 坑三:青龙脚本报 No module named 'notify'
  • 一些收尾结论
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档