环境:OrangePi 5 Plus(RK3588,ARM64)/ fnOS(飞牛OS)/ 1Panel + Docker 动机:THU-MAIC 官方托管模式(Access Code)每天限 10 次生成,切到本地部署摆脱额度限制 结果:部署成功,Web 端可正常访问,头像/模型图标正常显示
部署方式
本地电脑 clone 仓库后,打包上传到 fnOS(因服务器上访问github超时,所以未在服务器上直接
git clone)在 1Panel 中新建编排,指向仓库文件夹,走仓库自带的
Dockerfile+docker-compose.yml构建
后面证明,"本地上传"这一步是本次两个坑里其中一个坑的根源。
问题一:1Panel 创建编排时构建超时
现象
#9 [runner 3/7] RUN apk add --no-cache libc6-compat cairo pango jpeg giflib librsvg
#9 1156.0 (33/50) Installing glib (2.88.1-r1)
创建编排 失败 命令超时
前面 pnpm build(Next.js 编译 + TypeScript 检查)已经正常跑完,卡在 runner 阶段装 cairo/pango/jpeg/giflib/librsvg 这几个图形库依赖上——19 分钟才装了 50 个包里的 33 个。
根因
Alpine 默认源 dl-cdn.alpinelinux.org 在本地网络访问慢/丢包,apk add 被拖到十几分钟,而 1Panel「创建编排」这个动作本身有执行超时,构建还没跑完就被杀掉了。跟 Dockerfile 写法、架构(ARM64)都没关系,纯粹是国内访问 Alpine 官方源的网络问题 + 1Panel 面板超时叠加。
修复
Dockerfile 里给
base阶段加一行换源(deps/runner 阶段继承自 base,不用重复加):FROM node:22-alpine AS base RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories更重要的一条经验:不要指望 1Panel 面板里的「创建编排」按钮跑完整个 build。改为 SSH 到服务器,在仓库目录下手动执行:
sudo docker compose build --no-cache sudo docker compose up -dCLI 构建没有面板那层超时限制,构建成功、镜像落地之后,1Panel 面板再去管理这个已存在的编排(启动/停止/重建)就不会再触发这个问题。
问题二:容器崩溃重启循环(EACCES scandir)
现象
✓ Starting...
Error: EACCES: permission denied, scandir '/app/public/vendor/maic-importer/adapter'
▲ Next.js 16.1.2
✓ Starting...
Error: EACCES: permission denied, scandir '/app/public/vendor/maic-importer/adapter'
...(循环往复)
容器不断重启,docker exec 一度报 Container ... is restarting, wait until the container is running,进都进不去。
根因
Dockerfile runner 阶段的三行 COPY:
COPY --from=builder /app/public ./public # 漏了 --chown
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
public 这一行唯独没加 --chown=nextjs:nodejs。builder 阶段全程以 root 身份跑,public/vendor/maic-importer/ 下这些文件复制过来后属主还是 root,而容器最后 USER nextjs 切到 uid 1001 运行,对 root 拥有的目录 scandir 自然被拒绝。
排查中踩的坑
第一次
docker exec -it openmaic ...报permission denied while trying to connect to the Docker daemon socket—— 当前用户不在docker组,需要sudo或usermod -aG docker <user>。容器名字猜错:实际是 compose 生成的
openmaic-openmaic-1,不是简单的openmaic。容器处于 crash-loop 时 完全没法
docker exec进去,必须先让它能跑起来(改 Dockerfile 重建),再进去做后续验证,无法在崩溃状态下"先進去改权限再重启"这条路走通。第一次改完
--chown后在 1Panel 里点「重建」不生效——1Panel 的"重建"很多情况下只是up -d --force-recreate,不等于重新 build 镜像,Dockerfile 的改动根本没进新镜像。改用 CLIdocker compose build --no-cache && docker compose up -d才让改动真正生效。
修复
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
问题三:容器起来了,但网页头像/模型图标 500
现象
容器不再崩溃,Web 端能打开,但页面上老师头像、大模型图标(如 doubao.svg)全部缺失,F12 控制台:
Failed to load resource: the server responded with a status of 500 (Internal Server Error)
:3000/logos/doubao.svg:1
500 而不是 404,说明不是路径错,是服务端读文件时又出错了。
排查
进容器直接看权限:
sudo docker exec -it openmaic-openmaic-1 sh -c "ls -ld /app/public/logos /app/public/avatars; ls -la /app/public/logos | head -5"
结果:
drwxr-xr-x 1 nextjs nodejs 610 Jul 10 02:28 /app/public/logos
---------- 1 nextjs nodejs 1615 Jul 10 02:28 azure.svg
---------- 1 nextjs nodejs 5380 Jul 10 02:28 baidu.png
属主已经是对的(nextjs:nodejs),但文件权限位是 ----------——连属主自己都没有读权限。
根因
仓库是从本地电脑(Windows/macOS)打包上传到 fnOS 的,上传/打包过程中把这些静态资源文件的权限位传丢了,变成了全 0(chmod 000 效果)。前面 Dockerfile 里加的 --chown 只改了属主,没改权限模式——两者是两件独立的事:chown 决定"这文件归谁",chmod 决定"归属者/同组/其他人能不能读写执行"。属主对了但权限位是 0,照样谁都读不了,包括 nextjs 自己。
修复
先在运行中的容器里现场验证(无需重建即可立即生效):
sudo docker exec -u root -it openmaic-openmaic-1 sh -c "chmod -R a+rX /app/public"
刷新网页,头像和模型图标恢复正常,确认就是这个问题。
固化进 Dockerfile,保证下次重建不再复发:
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN chown -R nextjs:nodejs ./public && chmod -R a+rX ./public
chmod a+rX(大写 X)只给目录、以及本来就带执行位的文件加 x,不会把普通的 .svg/.png 误标记为可执行文件,是这类"目录+静态文件混合"场景的标准写法。
最终生效的 Dockerfile 改动(相对官方原版的 diff)
# ---- Stage 1: Base ----
FROM node:22-alpine AS base
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories # 新增:换源,解决 apk 慢/1Panel 构建超时
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
WORKDIR /app
# ...(deps / builder 阶段不变)...
# ---- Stage 4: Runner ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN apk add --no-cache cairo pango jpeg giflib librsvg
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public # 修改:补上 --chown
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN chown -R nextjs:nodejs ./public && chmod -R a+rX ./public # 新增:修正权限模式
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
经验总结 / 下次可以直接跳过的坑
1Panel「创建编排/重建」≠ 保证重新 build 镜像,Dockerfile 改了之后想确认生效,老老实实 SSH 上去
docker compose build --no-cache,不要依赖面板按钮。国内构建 Alpine 系镜像,Dockerfile 里第一步就先换源(
mirrors.ustc.edu.cn/mirrors.aliyun.com/mirrors.tuna.tsinghua.edu.cn),能省掉一大类"莫名其妙卡住/超时"的排查时间。chown和chmod是两回事:多阶段构建里COPY --chown只保证属主对,不保证权限位对。如果源文件权限本身就有问题(比如从 Windows/macOS 打包上传过来的),属主再对也读不了,两者要一起显式处理。容器处于 crash-loop 时无法
docker exec进去,排查权限类问题得先让容器至少能跑起来(哪怕跑一个docker run --rm -it --entrypoint sh <image>一次性容器)才能看到实际现场。从本地电脑打包上传项目到 Linux 服务器,是这次两个权限坑的共同源头。以后这类仓库优先直接在 fnOS 上
git clone,避免打包/上传过程中丢权限位这一整类问题;如果已经上传了,记得给整个项目目录跑一遍chown -R+chmod -R a+rX(或至少对public/这种会被静态直出的目录做一遍)再进 Docker 构建。诊断 500 vs 404 是关键分水岭:静态资源 404 → 路径/COPY 范围问题;500 → 服务端读取文件时出错,第一反应查权限(属主 + 权限位)。
完整部署步骤(供下次/换机复用)
1. 获取仓库
推荐直接在服务器上 git clone,不要在本地电脑下载/打包再上传(本次两个坑之一的权限位丢失问题,根源就是本地上传):
cd /vol1/1000 # 换成你想放的目录
git clone https://github.com/THU-MAIC/OpenMAIC.git
cd OpenMAIC
如果这次是已经从本地上传好的目录,先无脑跑一遍权限归一化,防患于未然:
find . -type d -exec chmod 755 {} + find . -type f -exec chmod 644 {} +
2. 改 Dockerfile(换源 + 权限修正)
对照仓库里的 Dockerfile,确认/补上这几处(本次实际验证有效的版本):
# ---- Stage 1: Base ----
FROM node:22-alpine AS base
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
WORKDIR /app
# ---- Stage 2: Dependencies ----
FROM base AS deps
RUN apk add --no-cache python3 build-base g++ cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/ ./packages/
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile
# ---- Stage 3: Builder ----
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages ./packages
COPY . .
COPY --from=deps /app/public/vendor ./public/vendor
RUN pnpm build
# ---- Stage 4: Runner ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk add --no-cache cairo pango jpeg giflib librsvg
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN chown -R nextjs:nodejs ./public && chmod -R a+rX ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
3. 配置 LLM Provider
cp .env.example .env.local
编辑 .env.local,至少配一个核心 LLM Provider Key,DEFAULT_MODEL 记得带 provider 前缀(否则默认按 OpenAI 解析):
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=anthropic:claude-3-5-haiku-20241022
或者用 Google(速度/成本更均衡):
GOOGLE_API_KEY=...
DEFAULT_MODEL=google:gemini-3-flash-preview
联网搜索/图片/视频/TTS 等都是可选功能,按需追加对应 Key(TAVILY_API_KEY / IMAGE_* / VIDEO_* / TTS_*),不配也能跑基础的课堂生成。
4. 构建镜像(一定走 CLI,不要依赖 1Panel 面板按钮)
sudo docker compose build --no-cache
--no-cache 保证 Dockerfile 改动一定生效,不会被层缓存跳过。这一步在 1Panel 面板里点「创建编排/重建」大概率会因为面板自身的超时被打断,尤其是第一次拉依赖、装 cairo/pango 这些图形库的时候,SSH 上去手动跑不受这个限制。
5. 启动
sudo docker compose up -d
之后就可以回 1Panel 面板里正常管理这个编排(启动/停止/看日志),因为镜像已经构建好了,面板只是拉起已有镜像,不会再触发构建超时。
6. 验证
# 看容器是不是稳定运行,而不是在重启
sudo docker compose ps
# 确认服务端健康
curl -fsS http://localhost:3000/api/health
# 权限问题排查专用:确认 public 目录属主+权限位都正常
sudo docker exec -it <容器名> sh -c "ls -ld /app/public/logos /app/public/avatars /app/public/vendor/maic-importer"
正常应该是属主 nextjs:nodejs,目录权限 755(drwxr-xr-x),文件权限 644(-rw-r--r--)左右,不应该出现 ---------- 这种全零权限。
浏览器打开 http://<fnOS-IP>:3000,确认页面上老师头像、模型图标都能正常显示,再算部署完成。
7.(可选)NPM 反代对外
如果需要局域网外访问,走你已有的 Nginx Proxy Manager 配置一个转发到 <fnOS-IP>:3000 即可,跟你现有 Gitea/Halo 等服务的做法一致。
TODO / 后续
给 Hermes 写调用本地 OpenMAIC 的 skill(对接方式定为直接 HTTP 调用本地
http://<fnOS-IP>:3000/api/...,不需要 Access Code 鉴权)确认本地部署用哪个 LLM Provider 写入
.env.local(DEFAULT_MODEL记得带 provider 前缀,如anthropic:claude-3-5-haiku-20241022)视情况通过 NPM(Nginx Proxy Manager)把本地 OpenMAIC 反代出去,方便非本机访问