2418 字
12 分钟
折腾的出发点
在运营基于 Astro / Hugo 等静态框架的独立博客时,图床选型始终是绕不开的话题:
- 公共图床/第三方免费托管:随时面临防盗链、被限流甚至跑路的风险;
- 商业对象存储(如 COS/OSS)+ 国内 CDN:按量计费虽然安全,但一旦遭遇恶意刷刷流量,容易产生高额账单;
- Git 仓库内嵌媒体文件:随时间推移仓库体积快速膨胀,Clone 和 CI/CD 部署构建极度缓慢。
手头有一台闲置的大容量海外 VPS(1.5T 硬盘,Debian 12),既然硬盘空间充裕,将其改造成私有图床源站是极具性价比的选择。然而海外主机直连境内延迟高、且网络波动频繁,要达到商用级别的体验,必须解决三个关键需求:
- 资源极简:尽量避免引入重型面板或全家桶图床程序,内存占用控制在 10MB 以内;
- 边缘加速与回源保护:借助国内边缘加速节点接管大流量,海外源站仅作为物理存储底层;
- 闭环体验:支持前端自动 WebP 压缩、跨设备管理历史图片,并在博客后台(Decap CMS)实现像 Notion 一样截屏按
Ctrl + V即可无感直传。
本文完整记录这一架构的搭建细节、踩坑经验与落地代码。
架构设计概览
整体架构链路非常清晰:
[ 写作端 / Decap CMS ] │ (Ctrl+V 触发前端 Canvas 转码 WebP) ▼[ EdgeOne 边缘加速 (Anycast) ] ├── 动态接口 (/upload, /list, /delete) ──(直接穿透 / 0缓存)──► [ 海外 VPS: Caddy:80 ] └── 静态图片 (*.webp, *.png, ...) ──(强缓存 30 天 / 拦截)──► [ 边缘节点直接响应 ] │ (仅首次回源) ▼ [ Go 原生服务 :3008 ] │ (磁盘读写) ▼ [ /data/images ]- 客户端(浏览器):拦截上传前先通过 HTML5 Canvas 转码为 WebP,原图几 MB 瞬间压至数十 KB;
- EdgeOne CDN:国内各省 Anycast 节点覆盖。针对上传与查询接口做 Bypass(不缓存),针对静态图片配置 30天节点强缓存 与 忽略 Query 参数;
- 反向代理(Caddy):接收 EdgeOne 的 HTTP 回源,将动态接口反代至后端,将静态资源交由自身高性能静态引擎托管;
- 后端服务(Go 编译二进制):仅使用 Go 原生标准库实现,无任何第三方框架,常驻内存仅 ~8MB,负责鉴权、写盘、按时间倒序扫盘及防越权删除。
一、 后端实现:原生 Go 极简轻量服务
不需要 Gin、Echo 等大型框架,Go 的 net/http、os、filepath 原生库足以支撑高并发的文件读写。
1. 源码编写与接口逻辑
在 VPS 上创建目录 /opt/image-uploader,编写 main.go。核心接口包含:
/upload:校验 Header 中的X-Upload-Token,以年月(2026/09)分目录落盘;/list:校验 Token 后扫描存储目录,提取最新上传的文件列表返回前端;/delete:校验 Token 并进行严格的路径清洗(防止../目录穿越攻击),物理删除指定文件。
package main
import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time")
// 配置项const UploadSecret = "YOUR_CUSTOM_SECRET_KEY" // 替换为你的私有密钥const StoragePath = "/data/images"const PublicDomain = "https://img.example.com"
type Response struct { Success bool `json:"success"` URL string `json:"url,omitempty"` Images []string `json:"images,omitempty"` Error string `json:"error,omitempty"`}
type DeleteRequest struct { URL string `json:"url"`}
// 跨域处理与身份校验func checkAuth(w http.ResponseWriter, r *http.Request) bool { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Upload-Token")
if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return false }
w.Header().Set("Content-Type", "application/json") if r.Header.Get("X-Upload-Token") != UploadSecret { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(Response{Success: false, Error: "Unauthorized"}) return false } return true}
func uploadHandler(w http.ResponseWriter, r *http.Request) { if !checkAuth(w, r) { return } if r.Method != http.MethodPost { json.NewEncoder(w).Encode(Response{Success: false, Error: "Method not allowed"}) return }
// 限制请求体上限 30MB r.Body = http.MaxBytesReader(w, r.Body, 30<<20)
now := time.Now() dateDir := now.Format("2006/01") fullDir := filepath.Join(StoragePath, dateDir) if err := os.MkdirAll(fullDir, 0755); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(Response{Success: false, Error: "Failed to create directory"}) return }
ext := ".webp" contentType := r.Header.Get("Content-Type") if contentType == "image/png" { ext = ".png" } else if contentType == "image/jpeg" { ext = ".jpg" } else if contentType == "image/gif" { ext = ".gif" }
fileName := fmt.Sprintf("img-%d%s", now.UnixNano(), ext) dstPath := filepath.Join(fullDir, fileName)
dst, err := os.Create(dstPath) if err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(Response{Success: false, Error: "Failed to save file"}) return } defer dst.Close()
if _, err := io.Copy(dst, r.Body); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(Response{Success: false, Error: "Failed to write content"}) return }
publicURL := fmt.Sprintf("%s/%s/%s", PublicDomain, dateDir, fileName) json.NewEncoder(w).Encode(Response{Success: true, URL: publicURL})}
func listHandler(w http.ResponseWriter, r *http.Request) { if !checkAuth(w, r) { return }
type fileItem struct { url string modTime time.Time } var items []fileItem
_ = filepath.Walk(StoragePath, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(path)) if ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" { rel, err := filepath.Rel(StoragePath, path) if err == nil { items = append(items, fileItem{ url: fmt.Sprintf("%s/%s", PublicDomain, filepath.ToSlash(rel)), modTime: info.ModTime(), }) } } return nil })
sort.Slice(items, func(i, j int) bool { return items[i].modTime.After(items[j].modTime) })
var result []string limit := 80 for i, it := range items { if i >= limit { break } result = append(result, it.url) }
json.NewEncoder(w).Encode(Response{Success: true, Images: result})}
func deleteHandler(w http.ResponseWriter, r *http.Request) { if !checkAuth(w, r) { return }
var req DeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(Response{Success: false, Error: "Invalid payload"}) return }
u, err := url.Parse(req.URL) if err != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(Response{Success: false, Error: "Invalid URL"}) return }
cleanPath := filepath.Clean(strings.TrimPrefix(u.Path, "/")) if strings.Contains(cleanPath, "..") || cleanPath == "index.html" || cleanPath == "" { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(Response{Success: false, Error: "Forbidden target"}) return }
targetFile := filepath.Join(StoragePath, cleanPath) if !strings.HasPrefix(targetFile, StoragePath) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(Response{Success: false, Error: "Path traversal denied"}) return }
if err := os.Remove(targetFile); err != nil { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(Response{Success: false, Error: "File deletion failed"}) return }
json.NewEncoder(w).Encode(Response{Success: true})}
func main() { http.HandleFunc("/upload", uploadHandler) http.HandleFunc("/list", listHandler) http.HandleFunc("/delete", deleteHandler)
// 绑定内网避开常见端口 fmt.Println("Image Uploader running at 127.0.0.1:3008") _ = http.ListenAndServe("127.0.0.1:3008", nil)}2. 编译与 Systemd 守护进程托管
cd /opt/image-uploadergo build -o /opt/image-uploader/uploader main.go
# 创建 systemd 服务cat << 'EOF' > /etc/systemd/system/image-uploader.service[Unit]Description=Lightweight Image Uploader ServiceAfter=network.target
[Service]Type=simpleUser=rootWorkingDirectory=/opt/image-uploaderExecStart=/opt/image-uploader/uploaderRestart=alwaysRestartSec=3
[Install]WantedBy=multi-user.targetEOF
systemctl daemon-reloadsystemctl enable --now image-uploader二、 Caddy 网关与防重定向死循环配置
在 CDN 加速架构中,一个极为经典的踩坑点是:CDN 到源站走 HTTP (80 端口),而源站 Caddy 默认开启全自动 HTTPS 跳转(308),导致浏览器触发 ERR_TOO_MANY_REDIRECTS 死循环。
在 /etc/caddy/Caddyfile 中显式指定 http://,告知 Caddy 信任前置 CDN 代理,不再自动发起重定向:
http://img.example.com { # 1. 动态接口转发给 Go 本地后端 @api path /upload /list /delete handle @api { reverse_proxy 127.0.0.1:3008 }
# 2. 静态图片直接由 Caddy 高性能响应,根目录访问工作台 handle { root * /data/images file_server { index index.html } @images path *.webp *.png *.jpg *.jpeg *.gif header @images Cache-Control "public, max-age=2592000" }}执行 systemctl reload caddy 重载即可。
三、 EdgeOne 边缘规则引擎配置核心
EdgeOne(或任意国内具备规则引擎的 CDN)的配置核心是两点:动态穿透 与 静态锁死。
1. 源站配置
- 源站类型:IP
- 回源协议:HTTP(80 端口)
- 回源 Host:
img.example.com - 前端客户端:在 EdgeOne 后台申请免费证书并开启 强制 HTTPS
2. 规则引擎编排(自上而下按顺序匹配)
分支一:动态接口不缓存(IF₁)
- 匹配条件:
URL path正则匹配^/($|index\.html|upload|list|delete) - 操作行为:
节点缓存 TTL:不缓存(Bypass)
- 效果:保证控制面板、上传、拉取列表与删除操作每次都能直通 VPS,绝不发生数据脏读。
分支二:静态媒体资源强缓存(IF₂)
- 匹配条件:
文件后缀正则匹配\.(webp|png|jpe?g|gif|svg)$ - 操作行为:
节点缓存 TTL:自定义 30 天强制缓存:开启(忽略源站可能返回的私有头,全网节点直接固化)自定义 Cache Key:查询字符串(Query String)选择 全部忽略
- 效果:图片仅在初次请求时回源海外 VPS 一次,随后 30 天内全部由境内边缘 Anycast 节点直接向读者分发,毫秒级加载。
四、 客户端体验优化:前端 WebP 压缩与无缝接入
1. 为什么在前端(浏览器)做图片压缩?
传统方案往往由后端 Go/Python 借助 libvips 或 imagemagick 转码,这会给 1H1.5G 的廉价 VPS 带来巨大的 CPU 峰值压力。 我们在前端通过 HTML5 Canvas 实现无感转码:
- 访客在粘贴或拖入截图瞬间,浏览器利用本地客户端算力直接完成 1920px 限制缩放与 85% 质量 WebP 编码;
- 原本 3
5MB 的高清 PNG 截图瞬间压缩为 100300KB,传输带宽与 VPS 存储占用下降 90% 以上。
2. 博客管理端(Decap CMS)无感粘贴与凭证脱敏
为了彻底避免将 Secret 密钥推送到公开/私有仓库的源码历史中,采用 localStorage 懒加载记忆方案:
// 从本地安全读取,首访自动弹窗询问并持久化function getUploadSecret() { let secret = localStorage.getItem('site_image_token'); if (!secret) { secret = prompt('【首次配置】请输入专属图床 Token(仅持久化保存在当前浏览器):'); if (secret && secret.trim()) { secret = secret.trim(); localStorage.setItem('site_image_token', secret); } else { return null; } } return secret;}
// 监听编辑区内粘贴window.addEventListener('paste', async (e) => { const items = (e.clipboardData || e.originalEvent.clipboardData).items; let imageFile = null; for (const item of items) { if (item.type.indexOf('image') !== -1) { imageFile = item.getAsFile(); break; } }
if (imageFile) { e.preventDefault(); const imgUrl = await uploadImageDirect(imageFile); if (imgUrl) { insertMarkdownAtCursor(`\n\n`); } }});五、 运维与安全思考小结
- 权限隔离:虽然最终产出的图片外链是公网可读的(符合公开博客传播的本质需求),但通过
X-Upload-Token与服务端绝对路径判断,彻底封死了未授权写盘与目录遍历; - CDN 缓存特性与删除机制:当在图床后台执行物理删除后,源站磁盘空间已即时释放。如果个别文件需要立即在全网失效,只需在 CDN 控制台提交单条“清除缓存”即可;
- 极轻量常驻:整套系统没有数据库依赖、无需 Redis,依靠原生文件系统结构自组织索引,不仅能在廉价配置的 VPS 上稳定自愈运行,还能兼作跨设备的通用贴图工具。
从零打造极简高可用博客图床:海外大盘VPS + Go原生轻量后端 + EdgeOne边缘加速实践
https://rqly.com/blog/posts/2026-09-13-从零打造极简高可用博客图床海外大盘vps-go原生轻量后端-edgeone边缘加速实践/