3.1 存储桶开通:COS 与托管的衔接

2.4 节压完图那天下班前,我把 dist 在本地点开预览,首页 1.1 秒打开,一切完美。然后问题来了:这堆文件往哪传?组里争论了一晚上,有人说直接传网站托管平台,有人坚持先放 COS 存储桶。其实两者不矛盾:腾讯云网站托管背后装静态文件的就是 COS 对象存储,平台帮你把桶、CDN 加速、证书串成一条流水线。理解桶的存在不是为了绕开平台,而是出问题时知道去哪看日志。COS 官方文档入口是 cloud.tencent.com/document/product/436,本节只讲我们项目实际踩过的部分,概念性的选型 reasoning 见 1.2 选型对比。

3.1.1 先搞清关系:平台替你管了桶

自己从零搭的话,链路是四段:COS 存储桶放文件 → CDN 回源到桶 → 域名解析到 CDN → 证书挂在 CDN 上。用了 腾讯云网站托管,这四段被收进一个控制台页面,平台甚至帮你建桶。但有个细节必须知道:平台建的桶归平台子账号管,你在 COS 控制台里不一定能直接操作它。我们的做法是——站点走平台一键部署,同时自己另建一个桶存构建归档和原始图片素材,互不污染。

产物放哪,团队拍板的方案如下:

方案 存放位置 谁维护 适用场景
平台一键托管 平台同名桶 平台 正式站点,省心事
自建桶加 SDK 上传 自己的桶 我们脚本 归档、素材、备份
公有读直链 桶设公有读 谁传谁负责 临时分享,不推荐长期

第三种我们只用过一次,给客户预览传了个 zip,传完立刻关回私有读,理由后面讲。

3.1.2 开通存储桶:三个选择

在 COS 控制台新建桶,真正要动脑的只有三项。

一是地域。桶建在 ap-shanghai 还是 ap-guangzhou,对终端用户区别不大(CDN 会兜底),但对你自己的上传脚本有区别——跨地域上传大文件慢且走公网流量。团队在上海,构建机器也在上海,选了 ap-shanghai,传 30 张压缩图只要几秒。

二是名称。桶名全局唯一,还得带 APPID 后缀,完整名字类似 cloud-pages-prod-1250000000。我们第一次填 cloud-pages-prod,提交时被告知已存在,连换三个名字才注册成功,索性把 prod、staging、assets 三个名字一次性全占下,免得以后被人抢注。

三是访问权限,这里最容易出事:

权限 含义 我们的选择
私有读写 全部走签名访问 素材归档桶用这个
公有读私有写 匿名可读 曾用于临时预览
公有读写 谁都能删文件 全组没人敢选

正式站点看似需要公有读——用户浏览器要拉文件啊。实际上不需要:CDN 回源时平台自动用内网签名回源,桶保持私有,外面谁也直接扒不到文件。只有当你想绕过 CDN 用直链分发时才需要公有读,而这个口子一旦开着,哪天 Key 泄露或者文件被当成图床白嫖,刷的都是你的流量费。腾讯云实践案例集 里就有私有桶配错权限被刷出账单的复盘,我们读完马上把临时预览桶关回了私有。

3.1.3 Node 上传脚本

素材桶归我们管,上传自然写成脚本。依赖只有一个:cos-nodejs-sdk-v5。核心代码如下,逻辑是遍历目录、逐个 putObject:

import COS from 'cos-nodejs-sdk-v5'
import { readdirSync, statSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'

const cos = new COS({
  SecretId: process.env.TC_SECRET_ID,
  SecretKey: process.env.TC_SECRET_KEY,
})

const Bucket = 'cloud-pages-assets-1250000000'
const Region = 'ap-shanghai'

function walk(dir: string): string[] {
  return readdirSync(dir).flatMap(name => {
    const p = join(dir, name)
    return statSync(p).isDirectory() ? walk(p) : [p]
  })
}

async function upload(dir: string) {
  for (const file of walk(dir)) {
    await cos.putObject({
      Bucket,
      Region,
      Key: relative(dir, file),
      Body: readFileSync(file),
    })
  }
}

密钥从环境变量读,绝不写进代码——这条红线在 2.1 仓库搭建 里强调过,也是全组唯一写进 README 的规矩。TC_SECRET_ID 和 TC_SECRET_KEY 放进 CI 的环境变量配置,本地用 .env 文件(已在 .gitignore 里挡死),脚本两端读法一致,不存在线上本地两套配置的分裂。脚本跑通后挂进 npm scripts,起名 "sync": "node scripts/sync-assets.ts",以后每加一批素材就是一行命令的事。

上传之外还有个反向需求:检查线上素材与本地是否一致。脚本里另写了一个 diff 模式,列出远端有但本地没有的孤儿文件,以及本地新增但未上传的文件。这份清单每周跑一次,素材桶从此再没出现过“设计师本地有、线上没有”的灵异截图事件。

3.1.4 大文件上传:分片与并发

素材桶里迟早会进几个几十兆的设计稿和视频。putObject 是简单上传,超过 5MB 的文件走它又慢又容易因为网络抖动整个重来。SDK 提供分片上传 sliceUploadFile,我们给 sync 脚本加了个判断:大于 10MB 的走分片,小文件批量并发。调整后 200MB 的视频素材从原来的三四分钟降到 40 秒左右,断点还能续传。

另外两个省钱的小设置也在这里顺手做了。一是存储桶的生命周期规则:超过 90 天没被访问的归档素材自动转为低频存储,单价直接降一档,反正客户也不会翻三个月前的草图。二是开启版本管理,误删或者覆盖素材时还能从历史版本捞回来,配合 3.1.4 坑三的 dry-run 习惯,素材桶基本没再出过事故。

3.1.5 三个坑

坑一:Key 大小写。同事在 Mac 上传了 About.png,页面里写 about.png,本地预览好好的,线上 404。原因是 COS 的 Key 区分大小写,而 macOS 文件系统对大小写不敏感。约定:上传前文件名统一 lowercase,脚本里加一行断言,不合规直接抛异常。

坑二:Content-Type。SDK 上传 .webp 时默认给了 application/octet-stream,浏览器不认,直接走下载。解法是 putObject 时显式传 ContentType,我们在脚本里维护了一张后缀到 MIME 的映射表,查表传值,webp 对应 image/webp,svg 对应 image/svg+xml。

坑三:删除是真空操作。cos.deleteObject 没有回收站,误删原始素材就彻底没了。脚本的删除功能一律加 --dry-run 参数,先打印待删列表,人工二次确认后才真删。危险操作先预览这个习惯,是从 2.3 模板与框架 选型阶段延续下来的。

坑四:批量上传没做幂等。第一次跑 sync 时网络断了重跑,结果部分文件传了两遍、Key 冲突覆盖倒不怕,怕的是失败的文件被成功的统计掩盖。后来脚本每次结束打印一份清单:本次成功数、失败数、失败文件名,失败的一律脚本重试一次,再不行就大声报警让人工介入。部署链路里凡是涉及批量操作的地方,5.1 CI/CD 自动部署 都沿用了这份清单模式。

小结

存储桶是整个托管链路的底座:平台建的桶负责线上站点,自己的私有桶负责素材与归档;权限默认私有,让 CDN 回源走内网签名;上传脚本读环境变量、先预览后删除。桶开通之后,下一节 3.2 域名与解析 把那串 apa 默认域名换成我们自己的正式域名。

延伸阅读