先说个背景,我这个人是典型的“看见长文就烦,但偏偏每天要写大量带图文档”的体质。博客配图、公众号插图、项目README、给老板的周报,全都离不开图片外链。前几年一直用免费图床,要么速度飘忽,要么半夜链接失效,最离谱的一次是某个平台改版直接把我外链全干了,文章里一片裂图图标。后来实在受不了,花了大概一个周末,自己搭了一套极简图床方案,到现在跑了快两年,传了几千张图,几乎没再操过心。
这篇文章就把我的完整实践过程写出来,包括产品选型怎么定、后端存储怎么配、前端传图工具用什么、防盗链怎么加、以及中途踩过的几个印象很深的坑。适合那种不想折腾太复杂、但也不愿意把图片命脉完全交给免费服务的博主、开发者、文档写作者参考。不是什么高深架构,就是一套人人能复制的方案,你按步骤走,两小时左右也能跑起来。
1. 整体设计思路与选型拆解
先说我想要的最终效果:一个能上传图片、能返回外链地址、能长期稳定不失效、最好还有基本访问统计或防盗链能力的“个人图床服务”。这个需求抛出去之后,我第一反应是“直接用对象存储不就行了”,比如各家云厂商的OSS或COS。但真操作起来发现事情没那么简单——对象存储确实能做到稳定,但直接暴露存储桶公网地址,会导致几个问题:一是URL太长太丑,带一长串bucket名和region名;二是访问日志裸奔,什么样的人扫描你的链接都能看到存储桶结构;三是换服务商时,旧的图片链接全部作废。
所以我最终敲定的方案是“两层结构”:上游只负责存,前端专门做一个瘦身接口。上游存的对象存储叫做“存储层”,瘦身接口其实就是一套轻量级上传代理,当你把图片交给代理,它帮你存到对象存储,同时返回一条格式化好的自定义域名链接。以后就算底层换服务商,只要代理逻辑不变,对外链接也不会变。
选型上有几个重要权衡,我实际比对过三套路径:
第一套是“对象存储+默认域名直传”。最省事,但图片链接长得没法看,而且没法做统一鉴权和自定义处理,比如以后想加水印或者压缩,得每次去存储桶后台弄,维护成本高。我只适合前期临时用。
第二套是“自建服务器存本地磁盘”。图片存服务器本地目录,Nginx直接对外提供访问。优点是完全可控、没月租以外的额外费用,缺点是备份麻烦、磁盘空间容易被打满、防盗链得自己写规则。如果只是自己用,流量不大,这套其实也能接受。我后来放弃它主要是考虑以后想接入七牛、又拍这类CDN加速,本地磁盘方案不好做边缘节点缓存。
第三套是最终采用的“对象存储+边缘函数/云函数做上传中转”。也就是图片先传到我部署在云函数上的一个小程序,函数再把它丢给对象存储,并回传外链。这个方法的好处是:第一,函数本身的HTTPS域名能自定义成你自己的子域名,图片外链可以固定成一个好记的格式;第二,你看不到存储桶真实地址,安全面小很多;第三,所有上传动作都得走你自己的鉴权逻辑,别人没法白嫖你的存储空间。
这个“为什么不用默认直传、非要套一层中转”的问题,经常有人问我。我当时的判断依据很简单:图片外链一旦分发出去,就像泼出去的水,是不可能回收的。你在几十篇文章里引用了某条链接,半年后想换存储商,难道把所有文章里的图都重新换一遍?不现实。所以外链稳定性大于一切。中转层相当于一个“链接保险”,存储层随便换,对外纹丝不动。
另外一个常被忽略的点是地域选择。对象存储、云函数都有“区域”概念,创建的时候选哪个节点挺关键。你面对的读者大部分在境内,就选境内节点;读者在海外多,就选海外节点。不要图便宜选个冷门地域,因为上传接口在地域A、浏览图片的CDN在地域B,跨地域请求的耗时和费用都会悄悄走高。我自己吃过大亏:图床源站在一个区域,博客用的CDN加速在另一个区域,后来看账单才发现跨区域回源流量居然比CDN流量还贵。所以地域宁可一开始想清楚,也别后面再迁移。
2. 核心细节与实操要点
说到具体实现,我把整套系统的核心拆成四块:存储桶配置、上传代理函数、域名与HTTPS、防盗链规则。每一块都有不少细节,不注意的话,后面都是泪。
2.1 存储桶配置:权限和命名是第一道关
对象存储创建存储桶的时候,有两个关键点会影响后续所有操作。一个是“访问权限”,另一个是“桶名称”。很多人图省事,直接选“公有读”,觉得反正图片就是要公开访问的。这个做法对了一半:图片本身确实应该“公有读”,但存储桶应该保持“私有读写”,后续通过上传代理函数来转发。为什么?因为如果你把存储桶整个设为公有读,那别人拿到你的存储桶基础URL后,理论上能列出你这个桶里的所有对象,包括你传过的所有图片文件名,这在隐私上是个大问题。
推荐的配置是:桶权限设为“私有读写”,然后单独为CDN或代理域名开一个“只读”授权。这个授权可以是“生成临时凭证”,也可以是“存储桶策略只允许指定Referer访问”,按你自己的情况来。我自己是开了一个“CDN服务授权”,让CDN回源的时候能读到图片,其余外部访问一律拒绝。
桶名称建议取一个全局唯一、但不要包含个人敏感信息的名字。比如“myblog-images-2024”可以,“zhangsan-private-pic”就不太合适,因为图片链接里会显示这个桶名,等于把你的私人标识贴在了所有外链上。还有个细节是开启Object Lock或者生命周期规则:如果图片长期不更新,可以设置自动沉降到低频存储,省钱;如果你需要“图片发出后就不能删”,可以开Object Lock做合规保留,不过普通个人博客没必要,可以不折腾。
2.2 上传代理函数:其实就是一个翻译官
上传函数是整个图床的大脑。它可以部署在云函数上,也可以部署在轻量服务器上,原理都一样:接收上传请求,把参数和文件传给对象存储的写入接口,然后把新地址返回给调用方。
我当时用Node.js写的,代码非常短,核心逻辑就三步:
第一步,校验调用方身份。所有上传请求都要带一个自定义请求头,比如X-Upload-Token,值是我提前生成的一串随机字符串。这个值被存进函数的环境变量里,不写死在代码中。写死代码的坏处是,只要代码仓库泄露,你的图床就变成了公共厕所,谁都能往你桶里传图。
第二步,校验文件类型和大小。文件类型白名单写死jpg、png、gif、webp,超过5MB的直接拒绝。因为个人图床的使用场景是网站配图,不处理原片,没必要允许超大图。文件校验完,用crypto.randomUUID()生成一个不重名的新文件名,保留原扩展名,避免中文名、空格、特殊字符带来的URL转义问题。
第三步,调用对象存储SDK上传文件,并拼接对外访问URL。URL的拼接格式是https://你的自定义域名/文件名,不是存储桶给你的那串原始地址。这一步千万别搞错,否则白折腾半天自定义域名。
还有个容易忽略的点是上传超时设置。云函数一般默认超时是3秒或5秒,但上传一张1MB的图片,从客户端到函数,再从函数到存储桶,整个链路在某些网络环境下可能超过5秒。我当时就被这个问题坑过,传大图(比如4MB的截图)经常超时失败。解决办法是把超时时间调到最大——我用的是30秒,同时在前端上传组件里也做好进度反馈,避免用户(其实就是我自己)以为卡死了。
这里放一个我实际部署的函数核心代码片段,去掉云厂商SDK差异之后,几乎是通用逻辑:
exports.uploadHandler = async (event, context) => { const token = event.headers['x-upload-token'] || ''; if (token !== process.env.UPLOAD_TOKEN) { return { statusCode: 403, body: 'forbidden' }; } const file = event.files[0]; const allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!allowTypes.includes(file.contentType)) { return { statusCode: 400, body: 'bad file type' }; } if (file.size > 5 * 1024 * 1024) { return { statusCode: 400, body: 'file too large' }; } const extMap = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' }; const ext = extMap[file.contentType]; const fileName = `${randomUUID()}.${ext}`; await s3Client.putObject({ Bucket: BUCKET_NAME, Key: fileName, Body: file.content, ContentType: file.contentType }).promise(); return { statusCode: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: `https://img.yourdomain.com/${fileName}` }) }; };这一段代码几乎没有平台绑定,如果你用其他云厂商的云函数,只需要把s3Client换成对应SDK即可。核心思想就是:外部只能和你的函数对话,函数内部才拥有存储桶的读写权限。
2.3 域名与HTTPS:有了它外链才算稳定
链接好不好记、稳不稳定,和域名规划关系很大。我当时做了几个决定:第一,单独准备一个二级域名给图片用,比如img.yourdomain.com,不要和主站混在一起。好处是万一以后图片服务出了什么问题,可以直接通过DNS把流量切换走,不会影响主站。第二,这个域名一定要接入CDN并发给全站域名配置HTTPS证书。CDN不只是为了加速,更重要的是它自带一层缓存和防护,图片请求不会直接打到对象存储源站,减少被刷流量的概率。
CDN配置里有个关键动作——“回源鉴权开关”,有些CDN平台叫“私有Bucket回源”或“回源鉴权”。打开它之后,CDN回源到存储桶时会自动带上一个签名,存储桶只认CDN的请求,其他外部请求全部拒绝。这样既能保证图片正常加载,又能把存储桶“藏”在CDN后面,安全性提升很多。
经验之谈:如果你要用这个方案,一定要把CDN的回源协议设为“HTTPS”,并且让源站也开启HTTPS。不要嫌麻烦,因为如果源站是HTTP,CDN回源时的链路是加密的,但源站到存储桶可能走明文,整个图片加载链路就有被窃听、篡改的风险。搞定了这层,你的图床才算是在安全链路上跑。
2.4 防盗链与访问控制:被白嫖之后我才学的
说句实话,刚开始做图床的时候我完全没想防盗链这回事。直到某一天,我发现博客的图片访问量暴涨了几十倍,点开CDN日志一看,全是某个论坛的帖子在引用我的图片外链。我当时又气又好笑——气的是对方拿我的带宽白嫖,好笑的是我的图居然也有人盗。
防盗链最直接的方案是配置“Referer白名单”。在CDN里设置只允许yourdomain.com和*.yourdomain.com来源的请求访问图片,其他来源一律回403。这个方案简单有效,但有个副作用:有些隐私模式或RSS阅读器发送的Referer是空的,会被误杀。所以我的配置是“允许空Referer + 只允许白名单域名”,既挡住大部分盗链,又不影响正常阅读。
另外一个更硬的招是加URL鉴权。CDN可以生成带有效期的签名URL,链接里附带时间戳和签名,超过指定时间就失效。这个对普通博客不适用,因为图片一旦发出,旧文章里的图片链接过期了,文章就会裂图。我的建议是:普通博主只需要Referer防盗链就足够,签名URL适合那种API图片接口或临时分享场景,不适合长期外链。
3. 实操全过程:从零搭建我的图床
上面聊了选型和细节,这块我把完整搭建过程从零走一遍。虽然我用的某云厂商的具体控制台,但整套流程在几乎所有对象存储+CDN平台上都通用。
3.1 第一步:创建存储桶并设置私有读写
我登录对象存储控制台,点击“创建存储桶”,bucket名称填myblog-private-images,地域选离我目标读者最近的城市(我选的是华东),权限选“私有读写”。注意这里刻意避开了“公有读”选项,为的就是后续强制所有请求走自定义域名和CDN,不让别人看到桶本身。
创建完毕后,我进入“存储桶策略”,新增了一条“Deny所有非CDN来源的访问”策略。具体的策略语法不展开了,每个厂商差不太多,核心意思是:只允许CDN回源IP段访问、只允许我指定的子账号访问,其余请求全部拒绝。这一步有点绕,但是做完整套之后,你会在安全上安心很多。
然后我顺手开启了一个生命周期规则:图片180天无访问自动沉降到低频存储。因为我的博客有些老文章确实很少人看了,图片还放在标准存储上纯属浪费钱。别小看这条规则,我跑了一年多算下来,差不多省了三分之一存储费。
3.2 第二步:部署上传函数
我用的云函数服务,直接通过控制台创建一个新函数,运行环境选Node.js 16。代码就是上面放的那段逻辑,加上了我自己的鉴权token和文件类型校验。函数触发器我选了“API网关触发”,也就是生成一个HTTP地址,上传请求走POST到这个地址即可。这一步要注意:网关的鉴权方式一定要选“无鉴权”或“自定义Header鉴权”,因为我的鉴权逻辑在函数代码里已经写了,不用再套一层网关鉴权。否则等你调试的时候,会多一层“鉴权失败”,排查起来容易怀疑人生。
函数部署完,我用curl做了第一次上传测试。命令大概是这样的:
curl -X POST https://你的网关地址/upload \ -H "X-Upload-Token: 你在环境变量里设置的值" \ -F "file=@/本地路径/测试图片.png"第一次测试返回了{"url":"https://img.yourdomain.com/xxxx.png"},我心里那根弦才算松下来。但紧接着我发现一个问题:我还没配自定义域名,所以返回的域名字段还是函数网关的默认域名。不要慌,这个域名后面换成你自己的图床域名就行。
3.3 第三步:绑定自定义域名并配CDN
在对象存储控制台里找到“自定义域名”设置,填入img.yourdomain.com,然后这会在你的DNS管理后台自动生成一条CNAME记录。我把这条记录解析到CDN分配的CNAME地址。
然后我在CDN控制台添加加速域名:域名填img.yourdomain.com,源站类型选“对象存储”,源站地址直接选我们刚才创建的存储桶。最关键的是打开“回源鉴权”。这个功能的本质是让CDN带着一个动态签名去源站取文件,源站看到这个签名才允许读取。这样即使用户知道了源站地址,也没法绕过CDN直接读图。
证书方面,我在CDN配置里一键申请了免费HTTPS证书,并在源站也开启了HTTPS回源。整个过程大概十分钟,等证书生效后,访问https://img.yourdomain.com/测试.png,能看到图片正常加载,这套图床就算跑通了。
3.4 第四步:接入前端工具,形成上传闭环
图床后端通了,但每次上传都用curl显然不是正常人干的事。我找了一个开源的PicGo客户端作为前端上传入口,自定义图床配置指向我那个函数网关地址,并且把鉴权token填进自定义Header里。PicGo支持自定义上传接口,只要返回JSON格式符合它的约定,就能直接粘贴进博客编辑器。
这个组合让我最舒服的一点是:截图之后可以直接在PicGo里拖拽上传,图片自动命名、自动返回链接,然后我在Typora里直接粘贴Markdown图片地址。整个流程从截图到文章发布不超过十秒。这才是做图床的最终意义——不是折腾基础设施,而是让发文章的时候不再为图片操心。
4. 常见问题与排查实录
任何方案不踩坑是不完整的。我把这一年多来遇到的典型问题排了一下,列成一份速查表,每一个都是我实际撞过墙的。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 上传返回403 | 鉴权Token未带上或写错 | 检查函数环境变量和PicGo自定义Header配置 |
| 图片上传成功后访问404 | 自定义域名和存储桶的CNAME绑定未生效 | 等待DNS解析生效,检查CDN回源地址是否对应正确桶 |
| 图片时而能开时而打不开 | CDN回源超时或源站鉴权配置错误 | 检查CDN回源鉴权开关,确保对象存储“私有读写”且回源授权已配置 |
| 页面显示图片但控制台报Mixed Content | 页面是HTTPS但图片链接是HTTP | 将图床域名强制HTTPS,把图片外链统一改为https:// |
| 上传几MB图片总是超时 | 云函数默认超时时间太短 | 去函数配置中把超时调大,并做前端上传进度提示 |
| 突然某天图片全挂了,但文件还在 | 域名证书过期或存储桶策略误修改 | 检查证书状态、检查存储桶策略里有没有误加拒绝规则 |
这里面最诡异的一个坑,我单独拎出来说一下。“图片全挂了,但文件还在”这种问题,我排查了整整两个小时。控制台里看图片文件还好好的,直接点开源站地址也能访问,但博客里的图片全部403。最后发现是因为某次在CDN控制台调整配置时,顺手改了一下回源地址,结果保存成功但回源鉴权开关被重置成关闭状态。CDN回源时不再携带签名,存储桶的私有策略就会拒绝所有匿名请求。真的是牵一发动全身。所以如果你动了CDN配置,一定要重新确认一下“回源鉴权”是否仍处于开启状态。
另一个常见问题——“上传成功后返回的URL是域名+桶名那种一长串地址”。很多人以为这是正常的,其实是因为你在函数里直接用了SDK返回的对象地址。解决办法是在函数返回前自己拼接https://img.yourdomain.com/前缀,一定不要直接透传SDK结果。我见过不止一个朋友在这个细节上翻车,结果图片链接里带着一层层路径,又长又丑,还暴露了存储桶名称。
最后再分享一个经验:上传函数会偶尔因为网络抖动返回5xx错误,所以我在PicGo的配置里设置了“失败重试”为3次,并且把“保存逻辑”设为“失败不覆盖原图”。原因很现实——我的博客工作流是截图后先上传再写正文,如果图片没传成功但PicGo返回了一个假的成功状态,会导致文章里出现死链。这种问题不是技术难题,但体验影响很大。这个“失败重试、失败不覆盖”的习惯在长期用下来之后,几乎救了我好几次。
我个人在这套方案跑起来之后,最大的感受是:图床这个东西,看似小菜一碟,但真的关系到内容生产的稳定心情。以前用免费图床总有一种“寄人篱下”的感觉,上传之前要祈祷服务商别跑路;现在自建了这套流程之后,所有图片外链都握在自己手里,配置也都固化成文档了,哪怕明年要换服务商,我也只需要改一下函数里的SDK和存储桶地址,对外链接完全不受影响,这种可控感是任何免费服务都给不了的。
如果你也被图片外链失效、速度不稳、被盗链这些问题困扰过,不妨按这个思路自己搭一套。不用追求复杂功能,先保证“上传稳定”“外链固定”“访问可控”这三件事,就已经解决了90%的问题。剩下的水印、压缩、自动重命名,都是顺手加一点点功能的事。