背景:一台 VPS 上的兰空图床
今年我把博客和日常截图的图片存储收敛到了自建图床 img.v2yy.com,选型是 Lsky Pro(兰空图床),部署链路很标准:
- Vultr VPS 上用 docker 跑
halcyonazure/lsky-pro-docker,容器名lsky-pro,只监听127.0.0.1:8089; - nginx 给
img.v2yy.com做反代,证书用 certbot 签; - 数据库用 SQLite,图库和库文件都在宿主数据卷
/opt/lsky/html里; - 域名整个 zone 挂在 Cloudflare 后面。
搭服务本身十分钟搞定,真正花时间的是后面三件事:接 API、喂图片、让 CDN 生效。每一个坑都不是"报错劝退"型的,而是"报错和根因根本对不上"型的,所以单独记一篇。
选兰空而不是直接往对象存储扔文件,图的是它自带相册、直链规则和后台管理,写博客时 Typora 直接走 PicGo 的 lsky-pro 插件粘贴即传(URL 填 https://img.v2yy.com、token、strategy_id=1 就完事),不用再自己维护一套上传脚本的鉴权和去重。前提是它的 API 能被我驯服——下面第一个坑来得比预想早。
补一句 token 的来龙去脉:Lsky v2.2+ 的 web 端没有 token 管理页,API token 只能走 POST /api/v1/tokens(要 email+password),我嫌把密码发给接口麻烦,最后是在容器里用 tinker 直接签的:
docker exec -it lsky-pro php artisan tinker --execute="echo auth()->guard('web')->user()->createToken('cli')->plainTextToken;"
拿到 token 落盘保存(chmod 600)之后,才正式开始踩坑。
坑一:上传字段名必须单数 file
第一次用 curl 接上传接口,我按"表单可能多文件"的直觉把字段写成了带方括号的复数形式:
TOK=$(cat ~/.hermes/lsky_token.txt)
curl -X POST https://img.v2yy.com/api/v1/upload \
-H "Authorization: Bearer $TOK" -H "Accept: application/json" \
-F "file[]=@/path/img.png" -F "model=public"
返回 500。翻容器里的 Laravel 日志(/var/www/html/storage/logs/laravel-<date>.log),异常是:
Call to a member function getClientOriginalExtension() on array
排查方向一开始全跑偏了:先怀疑 token 权限,又怀疑角色组的单文件 5MB 限制和每分钟限速,甚至想过是不是 SQLite 写冲突——毕竟后台刚建了个新角色组,各项限制都是默认值。但异常本身其实已经把话说完了——代码在一个 array 上调 getClientOriginalExtension(),这是只有文件对象才有的方法。也就是说 -F "file[]=..." 到了 PHP 的 $_FILES 里被解析成了数组结构,Lsky 的上传逻辑拿到的不是 UploadedFile 而是一个 array,炸在了 PHP 层。报错信息里既没有"字段名"三个字,也没有 422 参数校验的样子,纯看异常很难联想到根因。
解法就是把字段名改回单数,一次通过:
curl -X POST https://img.v2yy.com/api/v1/upload \
-H "Authorization: Bearer $TOK" -H "Accept: application/json" \
-F "file=@/path/img.png" -F "model=public"
响应结构是 data[0].links.url,直接就是可以贴进 markdown 的直链。注意上传时 model 参数的语义:model=public 走公开策略,写成 model=private 就进了私有空间,直链要带 token 参数才能访问,博客配图一律显式传 public,别依赖后台"默认权限"——默认值被谁改过是不确定的:
https://img.v2yy.com/i/2026/09/29/<13位hash>.png
顺带把配套的接口也记全了:删除是 DELETE /api/v1/images/<key>,key 就是 URL 末段去掉扩展名;列表是 GET /api/v1/images?page=1,每条都带 width/height,想按横竖比例归档图片可以用它判断。
复盘:这类坑的教训不是"字段名要看文档"这种正确的废话,而是——PHP 生态里表单字段名带 [] 会改变后端收到的数据结构,异常栈却只会展示消费端崩溃的那一行。接任何上传接口,先用 curl 打最小请求,500 时先去看请求本身到达后端变成了什么形状,再怀疑权限和配额。
坑一点五:想按相册归档,发现 API 根本不收 album_id
接口跑通后我想把上传自动化成"按用途进不同目录",翻了 API 才发现路不通:/api/v1/albums 只有 GET 和 DELETE,没有 POST;ImageController::upload 也不接收 album_id 参数,ImageService 只读用户设置里的 default_album。也就是说上传时想把图直接扔进指定相册,API 层做不到。
归目录只剩两条 web 端的路:要么上传前先去设置页把「默认上传相册」下拉切到目标目录,要么传完在图片管理页多选再「移动到相册」。我一开始想在 API 里顺手建相册,撞墙后改用容器内 tinker 跑脚本:
docker cp make_album.php lsky-pro:/tmp/x.php
docker exec lsky-pro php artisan tinker /tmp/x.php
# 跑完双端 rm 临时文件
注意别用 tinker --execute="..." 内联代码——命令要穿过 shell、ssh、docker 多层引号,里面的 $ 变量会被逐层剥掉,必坏。复杂逻辑一律本地写成脚本文件,base64 传过去落盘再执行。我的 5 个归档目录(博客壁纸-桌面、博客壁纸-手机、博客相册-海边、博客文章配图、生活照片)就是这么批量建出来的。横竖比例筛选也有解法:直链本身没有比例概念,但 GET /api/v1/images 每条记录带 width/height,脚本拉列表按宽高判断再移动即可。
复盘:自建服务的"能力边界"要和源码对一遍。文档列了接口不等于覆盖你的场景,Lsky 的相册设计就是 web 优先、API 残缺,与其猜参数,不如直接读 Blade 模板和 Controller 确认。
坑二:手搓的 PNG 会被 Imagick 拒读
有一次我需要程序化生成测试图,不想引任何依赖,就用 struct 直接拼 PNG 的二进制块。拼完在浏览器里打开一切正常,上传到图床却报错:
Unable to read image from path
同一个文件,浏览器渲染毫无问题,本地看图软件也能打开,为什么服务端说读不了?我把文件头几个 chunk 打出来对比,发现问题出在块顺序上:我把 sRGB 块排在了 IHDR 前面。
# 错误示范:sRGB 排在 IHDR 之前 —— 不合规,浏览器宽容,Imagick 不宽容
chunks = [sig, srgb_chunk, ihdr_chunk, idat_chunk, iend_chunk]
PNG 规范明确要求 IHDR 必须是第一个数据块,我的拼装代码违反了这条。浏览器和看图软件对这种畸形文件的容错很高,而 Lsky 服务端走的是 Imagick 做图片校验和缩略图,Imagick 对块顺序是严格校验的,直接拒读。错误信息 "Unable to read image from path" 完全没有指向"文件不合规",只会让人怀疑上传路径、权限或者磁盘。
解法有两条。一是自己拼的话把顺序改对:PNG 文件签名之后,IHDR 必须永远是第一个数据块,sRGB、gAMA 这些辅助块排在 IHDR 之后、IDAT 之前;二是干脆不手搓,用正规库生成:
from PIL import Image
img = Image.new("RGBA", (640, 360), (30, 30, 46, 255))
img.save("test.png")
macOS 上 sips 转一道也能把块顺序规整出来。改完之后同一套上传流程一次通过。后来我把这条写进了生成脚本的前置检查:凡是自己拼的二进制格式,入库前先过一遍服务端同款校验器,能过 Imagick 的图才算有效产出,而不是"肉眼看着对"就算对。
复盘:渲染端通过不等于合规。浏览器是面向用户的容错机器,服务端处理链(这里多了 Imagick 校验)才是真正的守门员。程序化生成任何二进制格式文件,要么用成熟库,要么拿规范核对头部结构,不要靠"打开能看见"来验证。
坑三:源站 no-cache,CF 边缘必须 override_origin
图床挂上 Cloudflare 之后,我默认认为图片直链会被 CDN 缓存,直到某天 curl -sI 看了一眼响应:
cache-control: no-cache, private
cf-cache-status: MISS
源站是 Laravel,Lsky 的响应头就是 Cache-Control: no-cache/private,这合理——管理后台的页面本来就不该缓存。但对图片直链来说,Cloudflare 默认尊重源站头,等于整个 /i/ 路径的图全都穿透回 VPS,CDN 形同虚设。
排查时我先在 CF 控制台把"符合缓存条件"勾选了,结果 cf-cache-status 依然 MISS——因为只放行缓存资格还不够,边缘 TTL 仍然由源站的 no-cache 决定。也就是说源站头不松口,边缘就没有存的东西,必须在规则里显式声明覆盖。最终解法是建一条针对图床路径的 Cache Rule,让边缘强制覆盖源站策略:
- 规则名:
imgbed-long-cache - 表达式:
http.request.uri.path contains "/i/" - 缓存资格:符合缓存条件
- 边缘 TTL:override_origin,1 年
验证方式是拿同一个 URL 连发两次看状态翻转,第一次 MISS 第二次 HIT:
for i in 1 2; do
curl -sI https://img.v2yy.com/i/2026/09/29/xxxx.png | grep -i cf-cache-status
done
# MISS
# HIT
这里还有个小坑要单独点名:验证时不要给 URL 加随机 ?cb= 参数去"防本地缓存"——不同的查询串是不同的缓存 key,加了参数等于每次都打一个新 key,永远验证不出 HIT。
另外这条规则我是在 CF 控制台里用浏览器自动化点的,卡在 TTL 输入框上:那个控件是 downshift 自动补全组件,直接往 DOM 里注入 value 后界面显示填好了,但 React 状态里是空的,保存下来 TTL 没生效。必须模拟真实点击,把下拉里的选项(#downshift-1-item-0)点中,值才会进组件状态。
复盘:给"不该缓存的应用"套 CDN 加速静态资源时,别去动源站的响应头(会误伤管理后台),而是在边缘用 Cache Rule 按路径覆盖。源站 no-cache 不是 CDN 不生效的终点,只是说明你需要显式 override。
备份口径:整包数据卷才是备份
最后一条不算报错型坑,但同样是血泪口径。这套部署里 SQLite 库文件和 uploads 图库都在同一个数据卷 /opt/lsky/html 下,所以备份的正确姿势是整包卷:
docker stop lsky-pro
tar czf lsky-backup-$(date +%F).tar.gz -C /opt/lsky html
docker start lsky-pro
只 dump SQLite、忘传图库,恢复出来就是一片数据库记录齐全、图片全部 404 的坟场;反过来只 rsync 图片不带库,相册和元信息就丢了。单文件 5MB、总容量 500MB 这些角色组限制我后来在后台系统设置里放宽过,调整记录也在卷里,所以"整包即备份"这个口径能无脑执行,不用想"这次改了什么、哪些要带上"。
收尾
三个坑排成一条线看,其实是同一个主题:每一层组件都只报自己那一层的错。PHP 层报 array 没有方法,不说是字段名的锅;Imagick 报读不了路径,不说是块顺序的锅;CF 报 MISS,不说是源站头的锅。自建服务省心的地方在于日志都在自己手里——容器里 docker logs lsky-pro 加 Laravel 日志目录,报错原文一条不落——但前提是愿意把报错原文当作线索而不是结论,逐层往下问一句"这层为什么这么报"。
现在这套图床的日常形态是:Typora 粘贴即传、博客文章配图走 /i/ 直链吃 CF 边缘缓存、视频之类的非图片资产单独走 nginx 自建静态路径,备份就一条 tar 命令。这套流程理顺之后没再出过新乱子。如果这些坑能帮你省掉一个下午,这篇文章就值了。
评论