返回博客

阿里云 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 → 403 SecondLevelDomainForbidden
  • PutObject / HeadObject / GetObject 用明确 key 调用 → 正常(或偶尔 403 Unknown 但无 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 才是迁移/审计/恢复场景的入口。注释反而会阻止后来人怀疑这里。

坑:forcePathStyle: true 让 PUT 通过但让 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

正解:阿里云 OSS 客户端独立设 forcePathStyle: false

不要试图让一个客户端同时服务 MinIO 和阿里云 OSS。两套后端对 path-style 的需求正好相反——分别初始化两个 S3Client,各自给各自正确的 forcePathStyle,是成本最低、最不容易出错的方案。

记住两个额外细节:Region 要去掉 oss- 前缀(oss-cn-beijingcn-beijing),Endpoint 环境变量若不带协议头要手动补 https://

一句话外卖

MinIO 和阿里云 OSS 的 path-style 要求完全相反,用一份共享 forcePathStyle 配置同时连两者是定时炸弹——PUT 能通不代表 LIST 能通,拆成两个客户端才是正道。