2418 字
12 分钟
从零打造极简高可用博客图床:海外大盘VPS + Go原生轻量后端 + EdgeOne边缘加速实践

折腾的出发点#

在运营基于 Astro / Hugo 等静态框架的独立博客时,图床选型始终是绕不开的话题:

  • 公共图床/第三方免费托管:随时面临防盗链、被限流甚至跑路的风险;
  • 商业对象存储(如 COS/OSS)+ 国内 CDN:按量计费虽然安全,但一旦遭遇恶意刷刷流量,容易产生高额账单;
  • Git 仓库内嵌媒体文件:随时间推移仓库体积快速膨胀,Clone 和 CI/CD 部署构建极度缓慢。

手头有一台闲置的大容量海外 VPS(1.5T 硬盘,Debian 12),既然硬盘空间充裕,将其改造成私有图床源站是极具性价比的选择。然而海外主机直连境内延迟高、且网络波动频繁,要达到商用级别的体验,必须解决三个关键需求:

  1. 资源极简:尽量避免引入重型面板或全家桶图床程序,内存占用控制在 10MB 以内;
  2. 边缘加速与回源保护:借助国内边缘加速节点接管大流量,海外源站仅作为物理存储底层;
  3. 闭环体验:支持前端自动 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 守护进程托管#

Terminal window
cd /opt/image-uploader
go build -o /opt/image-uploader/uploader main.go
# 创建 systemd 服务
cat << 'EOF' > /etc/systemd/system/image-uploader.service
[Unit]
Description=Lightweight Image Uploader Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/image-uploader
ExecStart=/opt/image-uploader/uploader
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl 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 编码;
  • 原本 35MB 的高清 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![](${imgUrl})\n`);
}
}
});

五、 运维与安全思考小结#

  1. 权限隔离:虽然最终产出的图片外链是公网可读的(符合公开博客传播的本质需求),但通过 X-Upload-Token 与服务端绝对路径判断,彻底封死了未授权写盘与目录遍历;
  2. CDN 缓存特性与删除机制:当在图床后台执行物理删除后,源站磁盘空间已即时释放。如果个别文件需要立即在全网失效,只需在 CDN 控制台提交单条“清除缓存”即可;
  3. 极轻量常驻:整套系统没有数据库依赖、无需 Redis,依靠原生文件系统结构自组织索引,不仅能在廉价配置的 VPS 上稳定自愈运行,还能兼作跨设备的通用贴图工具。
从零打造极简高可用博客图床:海外大盘VPS + Go原生轻量后端 + EdgeOne边缘加速实践
https://rqly.com/blog/posts/2026-09-13-从零打造极简高可用博客图床海外大盘vps-go原生轻量后端-edgeone边缘加速实践/
作者
L·Y
发布于
2026-09-13
许可协议
CC BY-NC-SA 4.0
1
折腾的出发点
2
架构设计概览
3
一、 后端实现:原生 Go 极简轻量服务
1. 源码编写与接口逻辑
2. 编译与 Systemd 守护进程托管
4
二、 Caddy 网关与防重定向死循环配置
5
三、 EdgeOne 边缘规则引擎配置核心
1. 源站配置
2. 规则引擎编排(自上而下按顺序匹配)
6
四、 客户端体验优化:前端 WebP 压缩与无缝接入
1. 为什么在前端(浏览器)做图片压缩?
2. 博客管理端(Decap CMS)无感粘贴与凭证脱敏
7
五、 运维与安全思考小结