Skip to content

1Panel → fnOS 转换:踩坑与经验教训 ​

本文记录 FNOS-001 转换过程中真实踩到并已修复的问题。每条都包含:现象、根因、修法,以及如何避免重犯。

只写实际发生过的,不写设想。发生批次以 T0/T1/T2 标注。


一、工具链层 ​

1.1 fnpack build 打包失败仍返回退出码 0(T0) ​

现象

text
$ fnpack build
Packing failed. Required file manifest is missing
$ echo $?
0

根因:fnpack 不以退出码反映打包结果,且失败时不产出 .fpk。

修法:构建入口改为三重判定——退出码 0 且 .fpk 存在 且 其 mtime 不早于本次构建开始时间(排除上次残留)。见 tooling/fn-os-apps-cli/src/commands/build.ts。

为什么重要:这是静默失败。只看退出码的 CI 会在包完全没产出的情况下报"成功"。整个项目后续所有自动化都依赖这道闸。

如何避免:任何调用 fnpack 的地方都不要裸调,必须走加固后的入口。


1.2 入口 ID 有 32 字符硬上限(T0) ​

现象

text
Packing failed. The entry ID "fn-wechatmarkdowneditor.Application" exceeds the 32-character limit.

根因:fnpack 校验 app/ui/config 的入口键长度。fn- + <app> + .Application 超过 32 字符即拒绝。

实测边界(Lead 独立复现):

入口 ID 长度结果
30 / 31 / 32✅ 通过
33 及以上❌ 拒绝

即 <app> ≤ 17 字符必然通过,≥ 18 字符必然超限,不存在灰区。换 .main 后缀(3+len+5)可放宽到 <app> ≤ 24。

修法:超限时改用 <appname>.main(官方《应用入口》文档示例即用 myapp.main;仓库先例 fn-memos.main、fn-xiaoya-only.main)。

相邻规则:入口键还必须以 manifest.appname 为前缀,否则报 should start with <appname>。

如何避免:先算长度再建目录。已发生的实例:fn-wechatmarkdowneditor(35)、fn-palworld-server-tool(35)、fn-nginx-proxy-manager(34)。

⚠️ 此处失败时 fnpack 仍返回 0,靠 1.1 的产物判定才发现。


1.3 .fpk 字节级不可复现(T0) ​

现象:同一份源码连续两次构建,产物大小与哈希都不同。

根因:fnpack 向包内 manifest 注入一行随构建时刻变化的 checksum,tar 成员 mtime 亦随之变化。

修法与结论:.fpk 的大小与哈希不能作为验收判据,CI 不应做产物哈希断言。要校验就比对解包内容——内层 app.tgz 解开后 diff -r 无输出,顶层 manifest 仅 checksum 一行不同。

衍生影响:FnDepot 源索引的 sha256/size 不能在生成阶段填写(见 3.1)。


1.4 归一化名比对:只归一化一侧等于没归一化(Lead) ​

现象:统计"还有哪些应用未转换"时,把 obsidian_livesync 报为待转换。实际它早已交付(apps/fn-obsidian-livesync/,b539f83 引入)。

根因:仓库存的是连字符目录名(fn-obsidian-livesync),上游清单写的是下划线键(obsidian_livesync)。判断「是否已存在」时只对上游名做了 _→- 归一化,却没有保证 repo 集合本身也归一化——一步之差,下划线名永远匹配不上。

为什么危险:这个 bug 不报错、不告警,只静默给出错误的"待办清单"。若不是要求"创建前再确认一次",就会去重复创建一个已经交付的应用——而禁止项 9 正是为此而写,结果第一个违反它的判据本身就是我写的。

修法与结论:

python
norm = lambda x: x.replace('_', '-').lower()
repo = {norm(d[3:] if d.startswith('fn-') else d) for d in os.listdir('apps')}
pending = [a for a in upstream if norm(a) not in repo]

两侧都必须过同一个 norm,且 repo 侧必须是归一化后的集合(不是原始目录名)。仅归一化查询键是无效的。

如何避免:任何"名单比对"脚本,先对两侧同施一个归一化函数,再比对;并用已知的反例自检(例如断言 obsidian_livesync 与 obsidian-livesync 视为同一个)。


1.5 formFields 在版本级 data.yml(T0) ​

现象:按 appstore/apps/<app>/data.yml 读向导字段,得到"全部应用都无表单"的错误结论。

根因:formFields 只在版本级 appstore/apps/<app>/<version>/data.yml 的 additionalProperties 下;顶层文件只有展示元数据。实测 265 个顶层文件命中数为 0。

修法:读版本级;多版本按并集取值(实测并集/全部/首个三种取法均得 259,无应用跨版本出现有/无差异)。

如何避免:清单的「盘点口径」已写死这一点。


1.6 可变镜像标签:判据连踩三次(T0/T2) ​

这是本项目同一个坑踩了三次的记录,值得完整保留。

次数漏判的标签形态
第 1 次ghcr.io/koodo-reader/koodo-reader:master:master 不是 :latest
第 2 次zabbix/zabbix-java-gateway:alpine-latestlatest 前面是 - 不是 :
第 3 次ghcr.io/umami-software/umami:${PANEL_DB_TYPE}-v2.20.2标签是变量插值

根因:最初的判据是"标签等于 latest/master/…",这是个精确匹配。

修法:改为宽匹配——

text
标签串包含 latest / master / main / dev / edge / stable / nightly 即命中
或标签含 ${ 即命中(变量插值,同样不可复现)

配套要求:把可变标签钉到具体版本时,必须联网确认该 tag 真实存在,不能凭"同一版本线"推断。实例:

text
zabbix/zabbix-java-gateway:alpine-latest  →  钉到 alpine-7.4.5
  (Docker Hub 实测 alpine-7.4.5 存在,与同 compose 的 zabbix-server-mysql:alpine-7.4.5 同版本线)

1.7 图标:非正方形与 ICO(T0/T1) ​

情况处理
非正方形(如 173×178)新增 --allow-non-square:居中补透明边到正方形,不裁剪不拉伸。默认行为仍拒绝,避免静默放宽既有校验
Windows ICO(magic 00000100)jimp 无法解码。已解决(komga):用 ffmpeg 转 PNG 后再走管线。坑:ffmpeg 按扩展名探测格式,直接 ffmpeg -i logo.png 会报 Invalid PNG signature,必须先 cp 成 .ico 再转换。ICO 内最大仅 48×48,产物为 48 放大到 256,管线如实告警、文档写明质量限制
<64px 的 PNG5 个(browserless 48、glance 50、linkding 56、miniflux 48、redpanda-console 48)。策略已定:接受放大,管线仅警告不阻断——源图无更高分辨率可用,改不了就如实告知
JPEG 伪装成 .png7 个,jimp 能正常解码,无需处理
WebP 伪装成 .pngredis-commander。jimp 1.6.1 无 webp 插件。与 ICO 不同的是:不需要改扩展名——WebP 的 magic bytes 能被 ffmpeg 正确识别
JPEG 伪装 + 非正方形叠加neko(ffd8ffe0 + 180×80 宽幅 banner)。先按 PNG 解析会得到荒谬尺寸(65536×4292542596),必须按 magic bytes 判类型。补透明边后内容只占画布中间部分,属长宽比不合适而非分辨率不足,与 komga 是不同性质的问题

判据:永远按 magic bytes 判图片真实格式,不要信扩展名。 本项目已遇到 PNG 实为 JPEG(7 个)、PNG 实为 WebP(1 个)、PNG 实为 ICO(1 个)、PNG 实为 JPEG 且非正方形(1 个)四种情况。



二、形态判定层 ​

2.1 相对挂载正则漏判单引号写法(T0) ​

现象:newsnow、openldap 被错分到 T0(实为 T1)。

根因:判据写成"行首 - 后接 ./",漏掉 - './data:...'。两者的挂载写法带单引号。

修法:改用引号无关的正则 ^\s*-\s*['"]?\./。

衍生:还有一个口径差异要写清——真实 ./ volume 挂载是 222 个应用,而该正则命中 223(多出的 1 个是 zitadel,其 ./data/masterkey.env 出现在 env_file: 段而非 volume)。两个口径跑完整分级结果完全一致,不影响分级。


2.2 "外部数据库"判据过宽(T0) ​

现象:2fauth、ai-gateway、gotify、zentao 被误判为需要外部数据库(T2),实际是容器内自带 SQLite / 镜像内置数据库。

根因:判据是"env 文本里出现 DATABASE"——把 SQLite 与镜像内置数据库也算成了外部依赖。

修法:只有 compose 使用 PANEL_DB_* 或 DBHUB_DB_* 才算外部数据库依赖。

变量含义
PANEL_DB_*需要用户提供外部数据库实例
DBHUB_DB_*同义,同样是外部数据库(dbhub 因此从 T0 移到 T2)
DB_DATABASE=...sqlite / GOTIFY_DATABASE_DIALECT=sqlite3 / MYSQL_INTERNAL=true容器内自带,不算

2.3 "多容器""多挂载"不能靠印象(T2) ​

现象:我在任务描述里写"tomcat 6 个 service、多挂载 2—6 条、:ro 必须保留",app-builder 用 YAML 解析器逐个核实后发现:7 个"多容器"全是单容器、4 个实为单挂载、:ro 出现 0 次。

根因:我按"预期形态"批量推,没做结构化核实。svc 数字来自上游 data.yml 的展示元数据,根本没解析 compose。

修法:核实形态一律用 YAML 解析器,不用 grep。已写入规约与任务模板。

这类错误不会导致构建失败,只会产出"看起来对但配置错"的应用,靠任何后置校验都抓不到。


2.4 多版本应用:容器内目标可能随版本大变(T2) ​

现象:dataease 曾用 3.1.0 的 13 条挂载配 v2.10.27 的镜像,容器内路径全是 /opt/dataease3.0/...,而该镜像里只有 dataease2.0 目录。

上游版本挂载数容器内路径
2.10.277 条/opt/dataease2.0/...
3.1.013 条/opt/dataease3.0/...

后果:13 个挂载点全部指向镜像里不存在的目录,数据不落盘。

关键:"构建通过"与"挂载源存在"这两道校验都抓不到它——前者不解析容器内路径,后者只检查宿主侧源目录。

修法:新增通用校验 断言每个应用的容器内目标与所选用版本的上游逐条一致,并做反向回归(故意配错版本验证校验会报错)。


2.5 type: website 不能作为"有无界面"的判据(T0) ​

现象:上游 data.yml 把 memcached、zabbix-agent 这类明显无 Web 界面的应用也标成 type: website。

正例:fn-memcached 的字段名就是 PANEL_APP_PORT_HTTP(值 11211),但 11211 是 memcached 的 TCP 协议、没有 HTTP 界面。

修法:「无 Web UI 应用」按 是否通过 HTTP 提供用户界面判定,与字段名、有无端口都无关:

情况app/ui/configmanifest
有 HTTP 界面写 .url保留 desktop_*
TCP / 非 HTTP 服务写 {}删掉 desktop_uidir 与 desktop_applaunchname
无端口同上同上

三、语义与落盘层 ​

3.1 data-share:声明了不等于用得上(T1) ​

这是既有应用里最普遍的一个"形同虚设"。

现象:仓库里 10 个应用(fn-halo、fn-moviepilot、fn-reader 等)在 config/resource 声明了 data-share,但:

text
grep TRIM_DATA_SHARE_PATHS apps/   →  0 处命中
/vol4/@appshare/                    →  空

根因:共享目录与私有数据目录是两个不同位置,应用实际全挂在 ${TRIM_PKGVAR}:

text
/var/apps/fn-halo/shares/fn-halo  →  /vol4/@appshare/fn-halo   共享目录(文件管理器可见)
/var/apps/fn-halo/var             →  /vol4/@appdata/fn-halo    私有数据目录

后果:用户看到的共享目录永远是空的,应用也不读它。

修法(D 组规则):

  1. 恰好声明 1 个共享目录(TRIM_DATA_SHARE_PATHS 是 : 分隔多值,不能作单个挂载源)
  2. 不需要 permission——官方文档:「一般情况下不需要写 permission……系统会自动为应用运行用户授予所需的 ACL 访问权限」
  3. 挂载源用 ${TRIM_APPDEST_VOL}/@appshare/fn-<app>/<子目录>
  4. 卷号必须用变量,不能写死 /vol<N>/——<N> 是安装时用户选择的存储卷,写死会在选错卷时失效。仓库先例 apps/fn-xiaoya-only 已在用 ${TRIM_APPDEST_VOL}
  5. 不要用 TRIM_DATA_SHARE_PATHS:多值分隔无法作单个挂载源,且其能否在 compose 中展开尚未验证

另注:实际目录名是 shares(复数),官方文档写作 share,以实际为准。


3.2 权限:PUID=1000 在 fnOS 上必然失败(T1) ​

现象:上游 linuxserver 系镜像硬编码 PUID=1000/PGID=1000。

真机取证:

text
fnOS 包用户 UID 是动态分配的,不是 1000:
  docker-fn-it-tools 928    docker-fn-halo 969    docker-fn-reader 975
uid 1000 = 普通登录用户 tnnevol

数据目录:/vol4/@appdata/fn-halo  owner=docker-fn-halo(969)  mode=770

目录属主是包用户、权限 770(组与其他用户无权),容器以 uid 1000 写入必然失败。

修法:改用 ${TRIM_UID} / ${TRIM_GID。同样适用于 UID/GID 变量写法(synapse、navidrome)。

⚠️ 重要边界:TRIM_UID/TRIM_GID 能否在 compose 中展开未经真机验证。官方文档只列出这两个变量,没有任何 compose 示例使用它们(官方 Docker 案例只有 ${TRIM_APPDEST} 与 ${TRIM_SERVICE_PORT})。若未注入会静默传空值:

text
warning: The "TRIM_UID" variable is not set. Defaulting to a blank string.
PUID: ""
exit=0          ← 不报错

属静默失败,构建与解析环节都拦不住。目前使用者:code-server、freshrss、siyuan、synapse、tinyrdm、komari、node-red、dashy、easyimage2、nextcloud、ntfy、gitea。


3.3 user: root 上游默认值(T1) ​

现象:komari、node-red、tinyrdm 上游以 user: "0:0" 或 user: root 运行。

危害:违反规约禁止项;且 root 写下的文件属主是 root,后续卸载删除数据时包用户可能删不掉。

修法:改为 user: "${TRIM_UID}:${TRIM_GID}"。


3.4 touch 空文件当种子配置会让应用坏掉(T1) ​

现象:单文件挂载若只用 touch 造空文件:

应用空文件的后果
focalboard空串不是合法 JSON → 配置解析失败
obsidian_livesyncrequire_valid_user = true 丢失 → 未认证请求可访问公开端点(安全弱化)
dashy上游自带示例仪表盘 → 面板空白
glance无 pages → 什么都不渲染
phpmyadmin上传限制与隐藏系统库配置失效(功能退化但不报错)

根因:上游 data/ 目录自带真实初始内容,不是空目录。

修法:自写最小可用配置放 app/seed/,由 install_callback 在文件不存在时拷入。

为什么不搬上游的种子文件:禁止项 4 的立法目的是「不搬运上游的表达」(YAML 注释、README 措辞、表单文案)。而上游 glance.yml/conf.yml 里含具体 RSS 源与示例仪表盘——那是示例数据,本就不该搬。自己写最小配置同时满足两边。

校验判据要升级:断言"挂载源存在"不够,还要断言"文件非空且格式合法"。


3.5 Docker 对绑定挂载的目录同样要求源存在(T1) ​

现象:误以为只有单文件挂载需要预创建源,实测目录型挂载源缺失时 Docker 会以 root 建一个属主错误的目录。5 个应用受影响。

修法:install_callback 从 compose 挂载源反推处理,分三类——有种子内容的单文件(拷贝)、无内容的单文件(touch)、目录(mkdir -p)。不再手工维护目录列表(那正是漏项根源)。


3.6 上游数据落盘缺陷必须修正而非照搬(T1) ​

2fauth 实例:

yaml
volumes:
  - ./data:/2fauth
environment:
  - DB_DATABASE="/srv/database/database.sqlite"   # /srv/database 不在 /2fauth 挂载内

数据库落在容器可写层,容器重建即丢——而这正是 T1 要修的问题,照搬等于没做 T1。

其他同类修正:

类型实例
固定弱凭据2fauth 的 APP_KEY=SomeRandomStringOf32CharsExactly(所有安装共享同一加密密钥)
弱默认口令tomcat/onlyoffice/openldap/newsnow/laya-server/archivebox/ai-gateway/octop/pgadmin4/qiandao/tinyrdm/waline
自指但无对应服务cordys-crm 的 jdbc:mysql://${CONTAINER_NAME}(compose 里没有 MySQL 服务,转换后指向自己必然连不上)
宿主绝对路径挂载ai-gateway 的 /etc/1panel、showdoc 的 /showdoc_data、glance 的 /etc/timezone+/etc/localtime
变量插值镜像umami 的 ${PANEL_DB_TYPE}-v2.20.2

3.7 数据库向导字段:合并式与分离式不能混用(T2) ​

上游对数据库连接的两种消费方式:

风格何时用字段
合并上游把整段拼进 URL/DSN(DATABASE_URL、JDBC URL、DSN)DB_TYPE/DB_HOST(含 地址:端口/库名)/DB_USERNAME/DB_PASSWORD
分离上游分别消费 host/port/name(WORDPRESS_DB_HOST、DB_SERVER_PORT)DB_TYPE/DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD

判断依据:看上游 compose 里该变量怎么被用——DB_HOST 后直接跟 /库名 用合并;有独立 DB_PORT 变量用分离。

已发生的错误:一度让合并式同时生成 DB_HOST(含 地址:端口/库名)与独立 DB_NAME,compose 里又写 @${DB_HOST}/${DB_NAME} → 拼成 ...host:port/db/db,连接失败。

对分离型误用合并会把 host:port/db 当成 host,同样连接失败。

统一要求:不预填地址;DB_PASSWORD 用 password 类型;APP_PORT 与 DB_PORT 分开;type: service 子字段删除;helpText 必须给出连接串格式示例并说明连接失败的表现(上游靠平台保证连通性,fnOS 靠用户输入,这是转换后最大的行为差异)。

DB_ROOT_PASSWORD 是规约允许的例外:仅当应用确实需要 root 凭据时保留为独立字段(zabbix-server 初始化建库需要),并写明理由。


3.8 用户可填路径:放开与收紧各有代价(T1) ​

应用上游字段处理代价
llama-cppMODEL_DIR改 MODEL_PATH,initValue 留空路径存在性无法校验(fnOS 向导无此规则类型),填错会启动失败——但失败可见,非静默
trendradarDATA_PATH删掉,固定到 ${TRIM_PKGVAR}/data/output用户失去自定义能力(上游默认就是 ./data,无实际损失)
filebrowserMOUNT_PATH固定到 ${TRIM_PKGVAR}/data/mnt功能被削弱——"浏览你指定的目录"是它的核心卖点。已在文档留「高级配置」出口
jellyfinMEDIA_FOLDER_PATH/CACHE_FOLDER_PATH保留为向导字段与上面相反的取舍:用户自填路径是上游的设计,不削弱功能

判断原则:用户填任意宿主路径 + 无校验 = 数据可能写到意外位置。但若用户自填路径是该应用的核心功能(如 jellyfin 的媒体库),就不该收紧——收紧的前提是"用户没有非填不可的理由"。


四、流程与协作层 ​

4.1 统计口径错一处,整批结论都要重算 ​

formFields 的读取位置(1.4)、"外部数据库"判据(2.2)、可变标签判据(1.5)——每一处都是"数字看起来对但含义错了"。它们都曾导致分级或清单出错,且要靠独立复算才发现。

做法:任何写入清单的数字,都要能用脚本独立复算,并在文档里写明复算口径。

4.2 formFields 口径订正后,259 数字本身成立 ​

订正了读取位置后复算一遍,确认 259 在"版本级 + 容错解析"口径下仍成立——所以只补口径、不改数字。不是每次订正都要改数,先复算再决定。

4.3 提交作者与 GitHub 头像 ​

GitHub 靠提交邮箱是否关联到账号决定是否显示头像。仓库里已有 37 个 deepseek-ai <greecenew@foxmail.com> 的提交,那才是能触发头像的邮箱;用未关联的邮箱提交会显示灰色默认头像、且不进 Contributors。

4.4 机械校验比人眼可靠 ​

多次实证:

  • 「文档提到的每个 data/... 路径都必须是真实挂载源」的断言,抓出 2 处文档路径层级写错
  • 「容器内目标与所选版本上游逐条一致」的断言,抓出 dataease 混版
  • 「种子键必须在该应用 compose 挂载源里」的编译期断言,抓出种子路径少 data/ 前缀

共同点:这类错误都不影响构建通过,只影响运行时正确性,人眼对十几个应用逐一核对必然漏。

做法:把核对规则写成脚本,并做反向回归——故意改错验证校验确实会报错,确认它不是空转。

4.5 fnpack 模板文件缺末尾换行 ​

fnpack create 生成的 cmd/ 下 9 个文件末尾都没有换行符,会触发仓库的 shfmt 规则报 lint error。生成骨架后需先补 \n。补换行属于"修改模板文件内容",不违反禁止项 1(该条禁止的是删除模板文件)——不要用删除文件的方式绕开 lint。


五、尚未解决的边界 ​

项状态
TRIM_UID/TRIM_GID 能否在 compose 中展开待真机验证。若未注入会静默传空值,容器启动失败。影响 12 个应用
TRIM_APPDEST_VOL 在 compose 中的展开仓库先例已在用,风险较低,仍需真机确认。D 组 6 个应用阻塞在此
7+ 个外部数据库应用的连通性全部未真机验证,只做了本地格式校验
cordys-crm 的 JDBC URL 裁定改写镜像对该拼法的接受性未验证
fn-mysql_v8 目录名用下划线 vs 文档用连字符有意保留不改:appname 是已发布应用的升级主键,改名会断掉已装用户的升级路径
既有 10 个应用的 data-share 声明未使用已登记未修,涉及已发布应用,需单独评估
komga 的 ICO 图标(48×48)暂缓,需补源图或换图标方案
8 个可变标签应用阻塞,需先选定固定版本(emlog、neko、dvwa、redis-commander、teable、forgejo、openlist、gsmanager)
<64px 的 5 个上游图标放大失真策略待定

变更记录 ​

日期说明
2026-10-16新增 1.4「归一化名比对:只归一化一侧等于没归一化」——统计待转换应用时,仓库存连字符目录名、上游写下划线键,只归一化查询键而 repo 集合未归一化,导致已交付的 obsidian_livesync 被误报为待转换。该 bug 不报错、只静默给出错误的待办清单,且第一个违反禁止项 9(不得覆盖已有应用)的判据本身就是本规约写的。修法:两侧同过一个 norm 函数,并用已知反例自检。小节编号顺延(原 1.4/1.5/1.6 → 1.5/1.6/1.7)。
2026-10-15本文档建立,汇总 T0—T2 转换中实际发生过的踩坑:工具链层(fnpack 静默失败、入口 ID 上限、产物不可复现、formFields 在版本级、可变标签连踩三次、图标 ICO/非正方形)、形态判定层、语义与落盘层、流程与协作层,并单列「尚未解决的边界」。
2026-10-20图标小节同步落地结论:komga 的 ICO 已用 ffmpeg 解决(含「必须先改扩展名」那个坑)、<64px 策略定为「接受放大 + 管线告警」。新增两类实测:WebP 伪装成 .png(redis-commander,不需要改扩展名,与 ICO 不同)、JPEG 伪装 + 非正方形叠加(neko,先按 PNG 解析会得到荒谬尺寸)。归纳判据:永远按 magic bytes 判格式——本项目已遇四种扩展名与实际格式不符的情况。

相关文档 ​

基于 VitePress 构建