阿里云 OSS S3 兼容接口:LIST 必须用 Virtual-Host 模式
把 AWS S3 SDK 指向阿里云 OSS 的 S3 兼容入口,上传、下载都跑通了,代码里的注释也骄傲地写着"两种访问风格均兼容,统一用 path-style 简化配置"。就这样在生产跑了好几个月,直到某天需要枚举 Bucket 里的文件——数据迁移脚本、备份校验、文件恢复——ListObjectsV2 炸出一个 403:
name: SecondLevelDomainForbidden
message: Please use virtual hosted style to access.
httpStatusCode: 403
同一套代码打 MinIO 完全没问题。翻来覆去怀疑 AK/SK、Region、Endpoint,折腾一小时找不到原因。
现象
ListObjectsV2/ListBuckets→ 403SecondLevelDomainForbiddenPutObject/HeadObject/GetObject用明确 key 调用 → 正常(或偶尔 403Unknown但无 signature-mismatch)- 同一份客户端配置打 MinIO → 全部正常
- 关键嫌疑行:
forcePathStyle: true
根因
S3 有两种寻址风格:
| 风格 | URL 形态 | 谁需要它 |
|------|----------|---------|
| Path-style | https://endpoint/bucket/key | MinIO(无 DNS 通配符) |
| Virtual-host style | https://bucket.endpoint/key | 阿里云 OSS 的 LIST 操作 |
MinIO 必须用 path-style,因为它根本不配置 *.minio:9000 这类 DNS 通配;阿里云 OSS 的 Bucket 级操作(LIST、GetBucketLocation、GetBucketACL 等)只接受 virtual-host 风格,用 path-style 请求就直接拒绝。
两者的要求刚好相反。
偏偏 PUT/HEAD 在 path-style 下往往能打通——阿里云对 object 级操作更宽松。这让问题藏得极深:你可以把上传和读取测试全部跑过,误以为 path-style 对两端都没问题,然后写下一行注释:
// MinIO 必须 path-style;阿里云 OSS S3 入口两种都支持,统一 path-style 简化。
forcePathStyle: true,这行注释是错的。 它只验证了 PUT 没验证 LIST,而 LIST 才是迁移/审计/恢复场景的入口。注释反而会阻止后来人怀疑这里。
阿里云 OSS S3 兼容接口对 object 级操作(PUT/GET/HEAD)相对宽容,路径风格下也能工作。但 Bucket 级操作(LIST)只接受 virtual-host 风格,路径风格会直接返回 403 SecondLevelDomainForbidden。
因为 MinIO 要求 path-style,而 MinIO 和阿里云 OSS 常常共存于同一套代码里,开发者倾向统一用 forcePathStyle: true——结果 LIST 只在出问题时才会被发现,往往是迁移或恢复脚本跑起来的那一刻。
正解
方案 A:拆成两个客户端(推荐)
import { S3Client } from '@aws-sdk/client-s3'
// 阿里云 OSS — virtual-host 风格(LIST 必需)
const aliyunClient = new S3Client({
region: (process.env.OSS_REGION || 'auto').replace(/^oss-/, ''), // 去掉 oss- 前缀
endpoint: process.env.OSS_ENDPOINT.startsWith('http')
? process.env.OSS_ENDPOINT
: 'https://' + process.env.OSS_ENDPOINT, // SDK 要求带 protocol
credentials: {
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
secretAccessKey: process.env.OSS_ACCESS_KEY_SECRET,
},
forcePathStyle: false, // virtual-host
})
// MinIO — path-style(无 DNS 通配)
const minioClient = new S3Client({
region: process.env.MINIO_REGION || 'auto',
endpoint: process.env.MINIO_ENDPOINT, // e.g. http://192.168.x.x:9000
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY,
},
forcePathStyle: true, // path-style
})调用点根据操作目标的 Bucket 选对应客户端。
方案 B:单工厂,按调用方临时切换
const client = new S3Client({ ...baseConfig, forcePathStyle: !isAliyunBucket })适合暂时不想重构工厂函数的情况。
验证
修完后立即验证一次 LIST:
node -e "
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const c = new S3Client({
region: process.env.OSS_REGION.replace(/^oss-/, ''),
endpoint: 'https://' + process.env.OSS_ENDPOINT,
credentials: { accessKeyId: process.env.OSS_AK, secretAccessKey: process.env.OSS_SK },
forcePathStyle: false,
});
c.send(new ListObjectsV2Command({ Bucket: process.env.OSS_BUCKET, MaxKeys: 5 }))
.then(r => console.log('OK', (r.Contents||[]).length, 'keys'))
.catch(e => console.error('FAIL', e.name, e.message));
"预期输出:OK N keys。
不要试图让一个客户端同时服务 MinIO 和阿里云 OSS。两套后端对 path-style 的需求正好相反——分别初始化两个 S3Client,各自给各自正确的 forcePathStyle,是成本最低、最不容易出错的方案。
记住两个额外细节:Region 要去掉 oss- 前缀(oss-cn-beijing → cn-beijing),Endpoint 环境变量若不带协议头要手动补 https://。
一句话外卖
MinIO 和阿里云 OSS 的 path-style 要求完全相反,用一份共享
forcePathStyle配置同时连两者是定时炸弹——PUT 能通不代表 LIST 能通,拆成两个客户端才是正道。