打开网易新闻 查看精彩图片

去年有个做电商的朋友跟我吐槽:用户上传的头像存在MySQL里,数据库半年涨了800G,备份一次要通宵。我让他把图片迁到S3,只存引用,DB瞬间瘦回30G——这就是对象存储和关系型数据库的分工逻辑。

但迁上去只是第一步。真正让后端头疼的是:怎么防盗链?怎么让前端安全读取?怎么不让桶变成公共图床?

这篇指南从Node.js上传逻辑到IAM策略、CORS锁死,把每一层都拆开讲。适合已经会用AWS SDK、但还没搞懂权限体系怎么搭的开发者。

第一步:桶的创建和"假公开"陷阱

第一步:桶的创建和"假公开"陷阱

很多人创建S3桶时顺手点了"允许公共访问",或者以为Block Public Access全开就万事大吉——这两种都错。

正确的姿势是:用CLI创建时显式指定区域,然后关闭ACL级别的公共访问,但保留桶策略的口子。

代码如下:

aws s3api create-bucket \

--bucket your-app-images \

--region ap-south-1 \

--create-bucket-configuration LocationConstraint=ap-south-1

aws s3api put-public-access-block \

--bucket your-app-images \

--public-access-block-configuration \

"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=false"

注意最后两个参数是false。我们要的不是"完全封闭",而是"只认规则的封闭"——接下来用桶策略精确控制谁可以读。

第二步:桶策略的"Referer白名单"机制

第二步:桶策略的"Referer白名单"机制

S3的权限体系有两层:IAM(谁可以操作桶)和桶策略(什么条件下允许访问)。这里我们专注后者。

核心思路是利用HTTP的Referer头。当浏览器从你的网站加载图片时,会自动带上你的域名;直接复制URL到地址栏,或者别的网站嵌套你的图,Referer就不匹配。

策略文件长这样:

"Version": "2012-10-17",

"Statement": [

"Sid": "AllowOnlyFromMyWebsite",

"Effect": "Allow",

"Principal": "*",

"Action": "s3:GetObject",

"Resource": "arn:aws:s3:::your-app-images/public/*",

"Condition": {

"StringLike": {

"aws:Referer": [

"https://yourwebsite.com/*",

"https://www.yourwebsite.com/*"

应用策略:

aws s3api put-bucket-policy \

--bucket your-app-images \

--policy file://s3-bucket-policy.json

这个方案只适用于"公开但防 hotlink"的场景,比如商品图、文章配图。如果是用户私有文件(身份证、合同),别用Referer策略,直接走预签名URL——下面会讲。

第三步:CORS配置和前端的"跨域焦虑"

第三步:CORS配置和前端的"跨域焦虑"

桶策略管的是"能不能读",CORS管的是"浏览器让不让读"。如果你的前端用fetch或XMLHttpRequest直接请求S3,没配CORS会报经典错误:

Access to fetch at 'https://your-bucket.s3...' from origin 'https://yourwebsite.com' has been blocked by CORS policy.

配置CORS允许你的域名:

"AllowedHeaders": ["*"],

"AllowedMethods": ["GET", "HEAD"],

"AllowedOrigins": ["https://yourwebsite.com"],

"ExposeHeaders": [],

"MaxAgeSeconds": 3000

这里有个细节:AllowedOrigins不要写*,哪怕你暂时只有一个域名。未来加子域名或换主域时,你会感谢自己当初没偷懒。

第四步:Node.js上传逻辑——流式处理+元数据

第四步:Node.js上传逻辑——流式处理+元数据

前端把文件传给Node.js API,API再上传到S3。这个设计有两个好处:一是可以预处理(压缩、格式校验、病毒扫描),二是隐藏真实的S3桶名和路径结构。

用@aws-sdk/client-s3和@aws-sdk/lib-storage实现流式上传:

import { S3Client } from '@aws-sdk/client-s3';

import { Upload } from '@aws-sdk/lib-storage';

const s3Client = new S3Client({ region: 'ap-south-1' });

async function uploadImage(fileBuffer, fileName, mimeType) {

const s3Key = `public/${Date.now()}-${fileName}`;

const upload = new Upload({

client: s3Client,

params: {

Bucket: 'your-app-images',

Key: s3Key,

Body: fileBuffer,

ContentType: mimeType,

Metadata: {

'uploaded-by': 'user-id-123',

'original-name': fileName

const result = await upload.done();

return s3Key; // 只返回key,不返回URL

关键细节:Metadata字段可以存业务信息,比如上传者ID、原始文件名,方便后续审计和迁移。但别存敏感信息,S3元数据是明文的。

数据库只存s3Key,查询时动态生成URL。这样即使未来迁移到别的存储(Cloudflare R2、MinIO),只需改URL生成逻辑,不用改数据库。

第五步:预签名URL——私有文件的"临时通行证"

第五步:预签名URL——私有文件的"临时通行证"

对于用户私有文件,桶策略那套Referer机制不够用:一旦URL泄露,任何人都能看。这时候需要预签名URL(Pre-signed URL)。

原理:用你账号的IAM凭证对请求进行签名,生成一个带过期时间的临时链接。S3验证签名有效且未过期,才返回文件。

实现:

import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

import { GetObjectCommand } from '@aws-sdk/client-s3';

async function getPrivateImageUrl(s3Key, expiresInSeconds = 300) {

const command = new GetObjectCommand({

Bucket: 'your-app-images',

Key: s3Key

const signedUrl = await getSignedUrl(s3Client, command, {

expiresIn: expiresInSeconds

return signedUrl;

过期时间建议:用户头像5分钟,合同文件30秒,下载链接10分钟。根据场景调,太短用户体验差,太长有泄露风险。

预签名URL的生成是计算密集型操作吗?不是。它只是本地做HMAC签名,不调用AWS API,可以放在高频接口里。

第六步:IAM最小权限原则

第六步:IAM最小权限原则

你的Node.js应用需要IAM凭证来操作S3。千万别用根账号密钥,要创建专用IAM用户,并绑定最小权限策略。

"Version": "2012-10-17",

"Statement": [

"Effect": "Allow",

"Action": [

"s3:PutObject",

"s3:GetObject",

"s3:DeleteObject"

],

"Resource": "arn:aws:s3:::your-app-images/*"

注意Resource末尾的/*。如果写成arn:aws:s3:::your-app-images,策略对桶内对象不生效,这是新手常见坑。

生产环境建议用IAM Role而不是长期凭证。如果部署在EC2或ECS,直接绑定Role;如果是Lambda,用执行角色。这样密钥不会出现在代码或环境变量里。

一个容易忽略的细节:Content-Type

一个容易忽略的细节:Content-Type

上传时如果不指定Content-Type,S3会默认application/octet-stream。这会导致浏览器下载图片而不是直接展示,用户体验崩掉。

务必从上传请求中读取MIME类型,或者通过文件魔数(file-type库)检测,显式设置Content-Type。

另外,如果你用了CloudFront做CDN,记得配置缓存行为,让Content-Type参与缓存键。否则不同格式的同名文件会互相污染缓存。

这套架构跑通后,一个中等规模的UGC平台(日活50万,人均3张图)的存储成本大概在每月200-400美元区间,取决于访问频率和是否走CDN。相比自建MinIO集群加运维人力,这个账不难算。

你现在的图片存储方案是什么?有没有遇到过Referer被绕过、或者预签名URL过期时间调不准的坑?