OpenMAIC 本地化部署记录

OpenMAIC 本地化部署记录

__

环境:OrangePi 5 Plus(RK3588,ARM64)/ fnOS(飞牛OS)/ 1Panel + Docker 动机:THU-MAIC 官方托管模式(Access Code)每天限 10 次生成,切到本地部署摆脱额度限制 结果:部署成功,Web 端可正常访问,头像/模型图标正常显示;后续跑起来之后日志里又陆续冒出几个权限/网络/API 层面的问题,一并记录在下面


部署方式

  1. 本地电脑 clone 仓库后,打包上传到 fnOS(因网络原因,未在服务器上直接 git clone

  2. 在 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 面板超时叠加。

修复

  1. Dockerfile 里给 base 阶段加一行换源(deps/runner 阶段继承自 base,不用重复加):

    FROM node:22-alpine AS base
    RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
    
  2. 更重要的一条经验:不要指望 1Panel 面板里的「创建编排」按钮跑完整个 build。改为 SSH 到服务器,在仓库目录下手动执行:

    sudo docker compose build --no-cache
    sudo docker compose up -d
    

    CLI 构建没有面板那层超时限制,构建成功、镜像落地之后,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 组,需要 sudousermod -aG docker <user>

  • 容器名字猜错:实际是 compose 生成的 openmaic-openmaic-1,不是简单的 openmaic

  • 容器处于 crash-loop 时 完全没法 docker exec 进去,必须先让它能跑起来(改 Dockerfile 重建),再进去做后续验证,无法在崩溃状态下"先進去改权限再重启"这条路走通。

  • 第一次改完 --chown 后在 1Panel 里点「重建」不生效——1Panel 的"重建"很多情况下只是 up -d --force-recreate不等于重新 build 镜像,Dockerfile 的改动根本没进新镜像。改用 CLI docker 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 误标记为可执行文件,是这类"目录+静态文件混合"场景的标准写法。


问题四:运行时日志里 EACCES ... mkdir '/app/data/usage'(WARN,被忽略)

现象

[WARN] [UsageStorage] Failed to record usage (ignored): Error: EACCES: permission denied, mkdir '/app/data/usage'

根因

仓库自带的 docker-compose.yml 用的是具名卷(named volume)挂载用量统计数据:

volumes:
  - openmaic-data:/app/data

具名卷第一次被使用时,Docker 会把镜像里 /app/data 这个路径当时的内容/权限复制进卷里初始化;但 Dockerfile 里从来没创建过 /app/data,所以这个目录是 Docker 运行时临时自动建出来的,属主是 root,容器又是以 nextjs(uid 1001)身份跑,写不进去。跟问题二、三是同一类"属主/权限没配对"的问题,只是这次是数据卷维度的。好在这条只是 WARN,被忽略了,不影响主功能,只是用量统计记不下来。

修复

卷已经初始化过一次了,得直接改卷里已有的内容(立即生效,不用重建):

sudo docker exec -u root -it openmaic-openmaic-1 sh -c "chown -R nextjs:nodejs /app/data"

Dockerfile 里补一行,防止以后卷被重新初始化(比如换机、删卷重建)时再犯:

RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data

问题五:google:gemini-3.5-flash 模型校验 Connect Timeout —— 网络问题,不是配置问题

现象

[ERROR] [Verify Model] Model verification failed [model="google:gemini-3.5-flash"]: AI_RetryError: ...
Connect Timeout Error (attempted addresses: 172.217.114.4:443 ...)

根因

报错里那些 IP 都是 Google 的,Connect Timeout(不是 401/403 鉴权错误)说明服务器网络层面根本连不上 Google——典型的国内直连被墙场景,跟 Dockerfile、权限、代码都无关。

修复

仓库自带的 lib/server/proxy-fetch.ts 本身支持走代理,.env.example 里也留了对应配置项:

# HTTP_PROXY=
# HTTPS_PROXY=
  • 如果有可用代理:取消注释填上代理地址即可。

  • 如果没有代理:干脆不配 GOOGLE_API_KEY,改用国内可直连的 provider(SiliconFlow / DeepSeek / 智谱 GLM / 豆包 / Kimi 等)作为 DEFAULT_MODEL,省得每次启动都跳一遍这个必定失败的校验。


问题六:siliconflow:deepseek-ai/DeepSeek-V3.2 校验返回 Bad Request

现象

[ERROR] [Verify Model] Model verification failed [model="siliconflow:deepseek-ai/DeepSeek-V3.2"]: AI_APICallError: Bad Request

排查

先确认模型名本身没写错——deepseek-ai/DeepSeek-V3.2 确实是 SiliconFlow 目前在架的真实模型 ID,问题不在这。容器日志里的 AI_APICallError: Bad Request 把 HTTP 响应体吞掉了,看不到具体原因,大概率是下面几种之一:

  • SiliconFlow 账户余额不足,或者这个模型需要额外开通/达到一定消费等级才能调用

  • .env.localSILICONFLOW_API_KEY 前后多了空格或引号,导致请求头拼出来是坏的

  • 手动填了 SILICONFLOW_BASE_URL 但拼错了(没有特殊需求默认留空,走官方 https://api.siliconflow.com/v1 即可)

排查方法

绕开 OpenMAIC,直接用 curl 单独测这个 key/模型,看真实返回的错误 JSON(比容器日志里被吞掉细节的 "Bad Request" 有用得多):

curl https://api.siliconflow.com/v1/chat/completions \
  -H "Authorization: Bearer $SILICONFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"deepseek-ai/DeepSeek-V3.2","messages":[{"role":"user","content":"你好"}]}'

返回内容通常会明确写是余额、权限还是参数问题。


问题七:lib/prompts/templates/.../system.md EACCES —— 和问题三同一个根因,只是漏了一个目录

现象

[ERROR] [PromptLoader] Failed to load prompt interactive-outlines: Error: EACCES: permission denied, open '/app/lib/prompts/templates/interactive-outlines/system.md'

根因

跟问题三(/logos/doubao.svg 500)根因完全一致:这些 .md 提示词模板文件是 Next.js standalone 构建时从源码目录(也就是本地上传、权限位已经损坏的那份仓库)原样带出来的,COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 这行只改了属主,没改权限位,这些文件同样可能是全零权限(----------)。

之前的修复 chmod -R a+rX ./public 只覆盖了 public 目录,没管 .next/standalone 带出来的 lib/prompts 这些文件,所以漏网了。

修复:把权限修正范围从 public 扩大到整个 /app

一次性堵住这整类问题(包括问题四的 /app/data),不用每冒出一个新目录就单独打补丁:

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 /app && \
    chmod -R a+rX /app && \
    mkdir -p /app/data && \
    chmod -R u+rwX /app/data

USER nextjs

最终生效的 Dockerfile 改动(相对官方原版的 diff)

# ---- Stage 1: Base ----
FROM node:22-alpine AS base
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/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 /app && \                                     # 新增:范围从 public 扩大到整个 /app
    chmod -R a+rX /app && \                                              # 修正权限模式,同时覆盖 .next/standalone 里的 lib/prompts
    mkdir -p /app/data && \                                              # 新增:预建具名卷挂载点,避免运行时 root 自动创建
    chmod -R u+rwX /app/data
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

这一版是踩过问题二/三/四/七之后的最终合并版:chown+chmod 的范围从最初只管 public,扩大到整个 /app(顺带盖住了 .next/standalone 里的 lib/prompts 模板文件),并且预先建好了 /app/data 给具名卷挂载点用。


经验总结 / 下次可以直接跳过的坑

  1. 1Panel「创建编排/重建」≠ 保证重新 build 镜像,Dockerfile 改了之后想确认生效,老老实实 SSH 上去 docker compose build --no-cache,不要依赖面板按钮。

  2. 国内构建 Alpine 系镜像,Dockerfile 里第一步就先换源mirrors.ustc.edu.cn / mirrors.aliyun.com / mirrors.tuna.tsinghua.edu.cn),能省掉一大类"莫名其妙卡住/超时"的排查时间。

  3. chownchmod 是两回事:多阶段构建里 COPY --chown 只保证属主对,不保证权限位对。如果源文件权限本身就有问题(比如从 Windows/macOS 打包上传过来的),属主再对也读不了,两者要一起显式处理。

  4. 容器处于 crash-loop 时无法 docker exec 进去,排查权限类问题得先让容器至少能跑起来(哪怕跑一个 docker run --rm -it --entrypoint sh <image> 一次性容器)才能看到实际现场。

  5. 从本地电脑打包上传项目到 Linux 服务器,是这次两个权限坑的共同源头。以后这类仓库优先直接在 fnOS 上 git clone,避免打包/上传过程中丢权限位这一整类问题;如果已经上传了,记得给整个项目目录跑一遍 chown -R + chmod -R a+rX(或至少对 public/ 这种会被静态直出的目录做一遍)再进 Docker 构建。

  6. 诊断 500 vs 404 是关键分水岭:静态资源 404 → 路径/COPY 范围问题;500 → 服务端读取文件时出错,第一反应查权限(属主 + 权限位)。

  7. 权限修正不要只盯着 public.next/standalone 里凡是从源码目录 COPY . . 带出来的非 JS 资源(比如 lib/prompts/templates/*.md 这种运行时用 fs.readFileSync 读的文件),都可能继承同样的权限损坏问题。与其一个个目录单独打补丁,不如直接把 chown+chmod 的范围定在整个 /app

  8. 具名卷(named volume)的挂载点权限,得在 Dockerfile 里预先建好:如果镜像里那个路径本来就不存在,Docker 会在卷初始化时以 root 身份自动建出来,容器内非 root 用户照样会写不进去。提前 mkdir -p + chown 就行。

  9. 报错不能一概而论都当权限问题排查:同一份日志里,EACCES 类是权限;Connect Timeout 多半是网络可达性(国内访问境外 API 常见);Bad Request 是对方 API 返回的业务错误,得去查账户余额/权限/请求参数,不是本地环境的锅——先看错误类型再决定往哪个方向查,别一上来就无脑 chmod。


完整部署步骤(供下次/换机复用)

0. 前置条件(可选)

  • Docker + Docker Compose(1Panel 自带即可)

  • 当前用户在 docker 组里,避免每条命令都要 sudo

    sudo usermod -aG docker $USER
    # 退出重新登录 SSH 生效
    

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 /app && \
    chmod -R a+rX /app && \
    mkdir -p /app/data && \
    chmod -R u+rwX /app/data
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

权限修正范围是整个 /app(不只是 public),并预建了 /app/data 给用量统计的具名卷挂载点用——这是踩过问题二/三/四/七之后的最终版本,一步到位,不用后面再补丁。

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_*),不配也能跑基础的课堂生成。

国内网络注意GOOGLE_API_KEY 这类境外 provider 如果没配代理,启动时的模型校验会直接 Connect Timeout 失败(见问题五)。要么在 .env.local 里配好 HTTPS_PROXY/HTTP_PROXY,要么干脆别填 Google 的 key,只用 SiliconFlow/DeepSeek/GLM/豆包/Kimi 这类国内可直连的 provider。配置好 Key 之后,建议先用 curl 单独测一下 provider 的 API(不经过 OpenMAIC),确认 key 有效、账户有余额,再启动容器,能避免把"账户/网络问题"误判成"部署问题"(见问题六)。

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,目录权限 755drwxr-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 鉴权)

  • 排查清楚 SiliconFlow deepseek-ai/DeepSeek-V3.2 的 Bad Request 具体原因(余额/权限/参数,见问题六),确认好用的 provider + 模型组合再写死进 Hermes skill 的默认配置里

  • 决定 Google 系模型是配代理用,还是干脆不用,避免每次启动日志里都刷一遍 Connect Timeout(见问题五)

  • 视情况通过 NPM(Nginx Proxy Manager)把本地 OpenMAIC 反代出去,方便非本机访问

fnOS 1.2.0xx 升级踩坑记录 2026-07-02

评论区