本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年06月09日 统计字数: 31804字 阅读时间: 64分钟阅读 本文链接: https://soulteary.com/2026/06/09/migrate-minio-examples-to-otterio-usage-deployment-and-verification.html ----- # 把 MinIO 示例迁到 OtterIO:使用、部署与迁移验证 这篇文章来聊一个实际的问题:如果过去的项目、文章示例、CI 环境或者小型私有化部署里用了 MinIO,现在想切换到 OtterIO,应该怎么改、怎么部署、怎么验证数据确实迁移成功,以及哪些地方是不兼容的。 ## 写在前面 前两篇文章里,我已经把 OtterIO 的来龙去脉整理了一遍:为什么需要一条 Apache License 2.0 的衍生版本,为什么不是简单改名,为什么要保留原始版权声明,也为什么要和当前 MinIO 主线、MinIO 商标、MinIO 官方发行物保持清楚边界。 ![OtterIO 取意于 Otter(水獭)+ I/O:像水獭守护贝壳一样,轻巧、可靠地守护你的对象数据。](https://attachment.soulteary.com/2026/06/08/otterio-banner.jpg) 如果你还不清楚,可以翻阅《[重新审视 MinIO:许可证、归档、社区 fork 与我的 Apache 2.0 基线](https://soulteary.com/2026/06/07/revisiting-minio-license-archive-community-forks-and-my-apache-2-baseline.html)》、《[从 MinIO 到 OtterIO:整理一条 Apache 2.0 开源对象存储代码线](https://soulteary.com/2026/06/08/from-minio-to-otterio-curating-an-apache-2-object-storage-codeline.html)》了解。 ![soulteary/otterio](https://attachment.soulteary.com/2026/06/08/otterio-github.jpg) 开源项目地址:[soulteary/otterio](https://github.com/soulteary/otterio/),如果对你有帮助,欢迎一键三连。 这篇文章,让我们回到操作层面。 本文不试图证明 OtterIO 是当前 MinIO 的完整替代品。相反,本文的重点是提供一个可复现的验证方法:通过标准 S3 API,把几个不同版本、不同来源的 MinIO / S3 兼容对象存储里的对象同步到 OtterIO,并用对象级校验确认迁移结果。 ## 先说结论 如果你的旧 MinIO 用法只是本地开发、CI、测试环境、文章示例、小型私有化环境,迁移通常比较直接。 把镜像从: ```yaml image: minio/minio ``` 换成: ```yaml image: soulteary/otterio:latest ``` 或者: ```yaml image: ghcr.io/soulteary/otterio:latest ``` 把旧的 MinIO root 环境变量: ```yaml MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: minio123456 ``` 换成 OtterIO 的环境变量: ```yaml OTTERIO_ROOT_USER: otterio OTTERIO_ROOT_PASSWORD: please-change-this-password ``` 把启动命令确认成: ```yaml command: server --address ":9000" --console-address ":9001" /data ``` S3 endpoint 通常仍然可以保持为: ```text http://127.0.0.1:9000 ``` 如果拆分了 S3 API 和 Web Console 监听端口,控制台可以通过下面的地址访问: ```text http://127.0.0.1:9001/otterio/ ``` 这里需要特别注意:拆分监听后,`:9000` 只服务 S3 API、STS、health 和 metrics;`:9001` 服务 Web Console 和 Admin API。 也就是说,普通对象读写仍然指向 S3 端口;如果使用 `mc admin ...` 这类管理命令,则需要指向 Console/Admin 端口。 因为 OtterIO 在独立整理过程中修复了一些鉴权相关问题,并且项目身份、路由、管理接口边界都已经和 MinIO 主线不同,所以 MinIO 官方 `mc` 的并不是所有管理命令都兼容。 建议业务读写优先使用事实标准的 S3 API,也就是 AWS CLI、AWS SDK、s3cmd 或其他标准 S3 客户端。原本 MinIO 的 `mc` 可以作为辅助工具,但不要把 `mc admin` 的兼容性当成迁移成功的唯一依据。 ### 一条 Docker 命令启动 OtterIO 先准备数据目录: ```bash mkdir -p ./data ``` 启动单节点 OtterIO: ```bash docker run --rm \ --name otterio \ -p 9000:9000 \ -p 9001:9001 \ -e OTTERIO_ROOT_USER=otterio \ -e OTTERIO_ROOT_PASSWORD=please-change-this-password \ -v "$PWD/data:/data" \ soulteary/otterio:latest \ server --address ":9000" --console-address ":9001" /data ``` 本地体验时,默认凭据通常只适合临时测试。实际环境里应该显式设置: ```text OTTERIO_ROOT_USER OTTERIO_ROOT_PASSWORD ``` 并使用足够强度的密码。 ### Docker Compose 示例 旧文章里如果有 MinIO Compose,可以改成下面这样: ```yaml services: otterio: image: soulteary/otterio:latest container_name: otterio command: server --address ":9000" --console-address ":9001" /data environment: OTTERIO_ROOT_USER: otterio OTTERIO_ROOT_PASSWORD: please-change-this-password ports: - "9000:9000" - "9001:9001" volumes: - ./data:/data restart: unless-stopped ``` 启动: ```bash docker compose up -d ``` 查看日志: ```bash docker logs -f otterio ``` 如果你只在本机开发,也可以不拆分控制台端口,直接: ```yaml command: server /data ports: - "9000:9000" ``` 个人建议默认拆分 S3 和 Console:“业务流量”和“管理流量”不是一回事,也更方便我们后续处理反向代理、防火墙策略和访问控制。 ### 用 AWS CLI 验证 AWS CLI 不需要特殊适配,只需要指定 `endpoint` 即可: ```bash export AWS_ACCESS_KEY_ID=otterio export AWS_SECRET_ACCESS_KEY=please-change-this-password export AWS_DEFAULT_REGION=us-east-1 aws --endpoint-url http://127.0.0.1:9000 s3 mb s3://aws-cli-test aws --endpoint-url http://127.0.0.1:9000 s3 cp hello.txt s3://aws-cli-test/ aws --endpoint-url http://127.0.0.1:9000 s3 ls s3://aws-cli-test/ ``` 对于应用代码来说,也应该优先使用各语言的 AWS SDK 或标准 S3 客户端。这样迁移判断会更接近真实业务路径,而不是只验证某个特定命令行工具的行为。 ### 旧 MinIO Compose 迁移对照 旧示例可能长这样: ```yaml services: minio: image: minio/minio:RELEASE.xxxx command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: minio123456 ports: - "9000:9000" - "9001:9001" volumes: - ./data:/data ``` 迁移后建议写成: ```yaml services: otterio: image: soulteary/otterio:latest command: server --address ":9000" --console-address ":9001" /data environment: OTTERIO_ROOT_USER: otterio OTTERIO_ROOT_PASSWORD: please-change-this-password ports: - "9000:9000" - "9001:9001" volumes: - ./data:/data ``` 迁移关系可以从下面这张表中详细了解: | MinIO 示例 | OtterIO 示例 | 说明 | | ----------------------- | -------------------------------------- | ----------------------- | | `minio/minio` | `soulteary/otterio` | 镜像变更 | | `quay.io/minio/minio` | `ghcr.io/soulteary/otterio` | 镜像仓库变更 | | `minio server ...` | `otterio server ...` 或容器内 `server ...` | 二进制 / 入口名称变更 | | `MINIO_ROOT_USER` | `OTTERIO_ROOT_USER` | root 用户环境变量变更 | | `MINIO_ROOT_PASSWORD` | `OTTERIO_ROOT_PASSWORD` | root 密码环境变量变更 | | `minioadmin:minioadmin` | `otterioadmin:otterioadmin` | 默认凭据变更 | | `http://127.0.0.1:9000` | `http://127.0.0.1:9000` | S3 endpoint 通常可保持 | | Console 同端口或 `:9001` | `:9001/otterio/` | 控制台路径变化 | | `mc admin` 指向原 endpoint | 拆分后指向 Console/Admin endpoint | Admin API 不应和 S3 API 混用 | ### 迁移已有数据:不要从原地替换目录开始 如果只是本地开发环境,MinIO 中的旧数据并不重要。迁移数据,我推荐最干净的方式是创建新的 OtterIO 数据目录,然后通过 SDK 对应用重新做数据上传。 以及,如果要迁移已有数据,不建议第一步就把生产使用的 MinIO 的数据目录直接挂给 OtterIO。虽然在单盘模式下,OtterIO 可能支持复用数据目录中已经存在的对象,但这不等于可以把生产 MinIO 的系统目录、元数据目录、历史 IAM 配置、生命周期配置、通知配置都无脑原地替换。 相对稳妥的迁移方式,是把 OtterIO 当成一个新的 S3 endpoint,通过 S3 API 迁移对象: ```bash mc alias set old-minio http://127.0.0.1:9000 old-access-key old-secret-key mc alias set new-otterio http://127.0.0.1:9100 new-access-key new-secret-key mc mb new-otterio/my-bucket mc mirror --overwrite --preserve old-minio/my-bucket new-otterio/my-bucket ``` 建议迁移前检查这些内容: | 检查项 | 迁移建议 | | ---------------------------------- | ---------------------------------- | | 对象数据 | 优先用 S3 API 做副本迁移,不要直接覆盖源目录 | | Bucket policy | 单独导出、重建、验证 | | IAM 用户和策略 | 单独核对,尤其是 LDAP / AD 场景 | | Service account | 检查权限是否仍满足最小权限要求 | | Lifecycle / Retention / Versioning | 按 bucket 单独验证 | | Object Lock | 单独做写入、保留、删除测试 | | Bucket Notification | 先确认目标类型是否仍支持 | | Replication | 核对权限、目标、header 行为 | | 应用 SDK | 确认 endpoint、region、path-style、签名方式 | | 回滚路径 | 保留旧 MinIO 服务、源数据备份和迁移记录 | ## 本地迁移验证实验 接下来,我们用一个可复现的实验,来验证最基础、也是最重要的数据路径:对象能不能通过 S3 API 从不同源端同步到 OtterIO,并且内容完全一致。 ### 实验目标 为了让测试更有代表性,本文准备四个对象存储实例: | 服务 | 镜像 | 角色 | | ----------------- | ------------------------------------------------ | --- | | OtterIO | `soulteary/otterio:RELEASE.2026-06-07T11-32-46Z` | 目标端 | | MinIO 2025 | `minio/minio:RELEASE.2025-09-07T16-13-09Z` | 源端 | | MinIO Apache 2021 | `minio/minio:RELEASE.2021-04-22T15-44-28Z` | 源端 | | pgsty/minio | `pgsty/minio:RELEASE.2026-04-17T00-00-00Z` | 源端 | 本文实验选用这些版本作为迁移样本,OtterIO、MinIO、`pgsty/minio` 目前最新的版本,以及Apache路线原本最后一个版本的 MinIO。 我们要验证的迁移方向是: ```text minio-2025 -> otterio minio-apache-2021 -> otterio pgsty-minio -> otterio ``` 我们的验证标准不是“命令跑完了”,也不是“对象数量差不多”,而是: 1. 三个源端都动态生成随机对象。 2. 每个源端对象都记录 `Key`、`Size` 和 `SHA256`。 3. 同步到 OtterIO 后,从 OtterIO 重新下载对象。 4. 对目标对象重新计算 `Size` 和 `SHA256`。 5. 只有源端和目标端的 `Size`、`SHA256` 都一致,才认为该对象同步成功。 6. 所有对象都通过校验后,才认为本轮迁移验证通过。 ### 准备测试目录 ```bash mkdir -p ~/otterio-minio-migration-lab cd ~/otterio-minio-migration-lab mkdir -p data/{otterio,minio-2025,minio-apache-2021,pgsty-minio} ``` 目录结构最终类似: ```text otterio-minio-migration-lab/ ├── docker-compose.yml ├── .env ├── data/ │ ├── otterio/ │ ├── minio-2025/ │ ├── minio-apache-2021/ │ └── pgsty-minio/ └── s3-migration-check/ ├── go.mod ├── go.sum ├── main.go └── Dockerfile ``` 创建 `.env`: ```bash cat > .env <<'EOF' ROOT_USER=admin ROOT_PASSWORD=otterio123456 EOF ``` ### 编写 `docker-compose.yml` 这个实验只暴露 S3 API 端口,不额外暴露 Console 端口。这样兼容性最好,尤其是对 `RELEASE.2021-04-22T15-44-28Z` 这种较老版本。 ```bash cat > docker-compose.yml <<'EOF' services: otterio: image: soulteary/otterio:RELEASE.2026-06-07T11-32-46Z container_name: otterio command: server /data environment: OTTERIO_ROOT_USER: ${ROOT_USER} OTTERIO_ROOT_PASSWORD: ${ROOT_PASSWORD} volumes: - ./data/otterio:/data ports: - "9101:9000" networks: - object-storage-lab restart: unless-stopped minio-2025: image: minio/minio:RELEASE.2025-09-07T16-13-09Z container_name: minio-2025 command: server /data environment: MINIO_ROOT_USER: ${ROOT_USER} MINIO_ROOT_PASSWORD: ${ROOT_PASSWORD} MINIO_ACCESS_KEY: ${ROOT_USER} MINIO_SECRET_KEY: ${ROOT_PASSWORD} volumes: - ./data/minio-2025:/data ports: - "9102:9000" networks: - object-storage-lab restart: unless-stopped minio-apache-2021: image: minio/minio:RELEASE.2021-04-22T15-44-28Z container_name: minio-apache-2021 command: server /data environment: MINIO_ROOT_USER: ${ROOT_USER} MINIO_ROOT_PASSWORD: ${ROOT_PASSWORD} MINIO_ACCESS_KEY: ${ROOT_USER} MINIO_SECRET_KEY: ${ROOT_PASSWORD} volumes: - ./data/minio-apache-2021:/data ports: - "9103:9000" networks: - object-storage-lab restart: unless-stopped pgsty-minio: image: pgsty/minio:RELEASE.2026-04-17T00-00-00Z container_name: pgsty-minio command: server /data environment: MINIO_ROOT_USER: ${ROOT_USER} MINIO_ROOT_PASSWORD: ${ROOT_PASSWORD} MINIO_ACCESS_KEY: ${ROOT_USER} MINIO_SECRET_KEY: ${ROOT_PASSWORD} volumes: - ./data/pgsty-minio:/data ports: - "9104:9000" networks: - object-storage-lab restart: unless-stopped networks: object-storage-lab: name: object-storage-lab driver: bridge EOF ``` 使用命令,启动四个服务: ```bash docker compose up -d docker compose ps ``` 在日志中,我们能够得到宿主机访问端口对应关系: ```text OtterIO http://127.0.0.1:9101 MinIO 2025 http://127.0.0.1:9102 MinIO Apache 2021 http://127.0.0.1:9103 pgsty/minio http://127.0.0.1:9104 ``` 容器网络内访问地址: ```text http://otterio:9000 http://minio-2025:9000 http://minio-apache-2021:9000 http://pgsty-minio:9000 ``` 检查容器网络: ```bash docker network inspect object-storage-lab \ --format '{{range .Containers}}{{.Name}} -> {{.IPv4Address}}{{println}}{{end}}' ``` 预期能看到类似: ```text pgsty-minio -> 172.18.0.4/16 minio-2025 -> 172.18.0.2/16 otterio -> 172.18.0.3/16 minio-apache-2021 -> 172.18.0.5/16 ``` 这里的 IP 不重要,重要的是四个容器都在同一个 `object-storage-lab` 网络里,能够和我们后续的测试程序网络互通。 ### 可选:用 `mc` 做连通性检查 如果直接执行: ```bash docker run --rm -it \ --network object-storage-lab \ minio/mc:latest \ sh ``` 可能会看到: ```text mc: `sh` is not a recognized command. ``` 原因是 `minio/mc:latest` 镜像的入口命令默认就是 `mc`,你后面传的 `sh` 会被当成 `mc sh` 子命令。 正确方式是覆盖 entrypoint: ```bash docker run --rm -it \ --network object-storage-lab \ --entrypoint /bin/sh \ minio/mc:latest ``` 进入容器后执行: ```bash mc alias set otterio http://otterio:9000 admin otterio123456 mc alias set minio2025 http://minio-2025:9000 admin otterio123456 mc alias set minio2021 http://minio-apache-2021:9000 admin otterio123456 mc alias set pgstyminio http://pgsty-minio:9000 admin otterio123456 mc admin info otterio mc admin info minio2025 mc admin info minio2021 mc admin info pgstyminio ``` 这一步只用于连通性确认,不同的 `mc` 版本可能访问不到 `otterio`,真正的迁移验证,我们使用 Go 和 AWS S3 SDK 完成。 ## 使用 Go + AWS S3 SDK v2 做迁移验证 让我们使用靠谱的方案来做验证。 ### 为什么不用固定测试文件 固定测试文件只能验证很有限的路径。迁移时真正容易出问题的地方还有很多,包括: * 随机二进制内容; * 大小不一的对象; * 多级目录; * 中文对象名; * 带空格的对象名; * 不同 `Content-Type`; * Metadata; * 跨 endpoint 下载再上传; * 目标端重新下载校验。 因此,这里使用 Go 程序动态生成随机对象,并且每个对象都记录 `Size` 和 `SHA256`。 ### 程序行为 程序连接四个 S3 endpoint: ```text minio2025 -> http://minio-2025:9000 minio2021 -> http://minio-apache-2021:9000 pgstyminio -> http://pgsty-minio:9000 otterio -> http://otterio:9000 ``` 其中: ```text minio2025 minio2021 pgstyminio ``` 是源端。 ```text otterio ``` 是目标端。 程序会在三个源端分别创建随机对象,然后同步到 OtterIO。 目标端路径格式: ```text from// ``` 例如: ```text 源端: s3://migration-test/small/20260609T041223Z-minio2025-001-random.txt 目标端: s3://migration-test/from/minio2025/small/20260609T041223Z-minio2025-001-random.txt ``` 对象 Key 覆盖这些典型场景: ```text small/ medium/ nested/year=2026/month=06/day=09/ unicode/中文文件名.txt unicode/空 格 文件.txt metadata/content-type.json ``` 同步流程是: 1. 从源端 `GetObject` 下载对象。 2. 先写入本地临时文件。 3. 对临时文件重新计算 `Size` 和 `SHA256`。 4. 确认临时文件和源端记录一致。 5. 再上传到 OtterIO。 6. 上传后从 OtterIO 重新下载。 7. 对目标对象重新计算 `Size` 和 `SHA256`。 8. 全部一致后输出成功摘要。 这里没有直接把 `GetObject.Body` 接到 `PutObject.Body`,是有意为之。临时文件方式更适合做迁移验证:可以明确计算校验值,也能避开部分 S3 兼容服务在流式上传、重试、checksum、不可 seek stream 场景下的差异。 ### 初始化 Go 项目 ```bash cd ~/otterio-minio-migration-lab mkdir -p s3-migration-check cd s3-migration-check go mod init s3-migration-check go get github.com/aws/aws-sdk-go-v2 go get github.com/aws/aws-sdk-go-v2/config go get github.com/aws/aws-sdk-go-v2/credentials go get github.com/aws/aws-sdk-go-v2/service/s3 ``` ### 完整 `main.go` 下面这个程序是完整可运行版本。清理逻辑使用逐个 `DeleteObject`,没有使用批量 `DeleteObjects`,这样可以兼容旧版 MinIO (2021 年及之前)对 `Content-MD5` 的要求。 ```go package main import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "flag" "fmt" "io" "log" "math/big" "mime" "os" "path/filepath" "sort" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" awscred "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type Endpoint struct { Name string Endpoint string AccessKey string SecretKey string Region string Client *s3.Client } type ObjectRecord struct { SourceName string Bucket string Key string Size int64 SHA256 string } type Options struct { Bucket string TargetName string ObjectCount int MinSize int64 MaxSize int64 Cleanup bool ListObjects bool } func env(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func parseSize(s string) (int64, error) { original := s s = strings.TrimSpace(strings.ToLower(s)) multiplier := int64(1) switch { case strings.HasSuffix(s, "kb"): multiplier = 1024 s = strings.TrimSuffix(s, "kb") case strings.HasSuffix(s, "mb"): multiplier = 1024 * 1024 s = strings.TrimSuffix(s, "mb") case strings.HasSuffix(s, "gb"): multiplier = 1024 * 1024 * 1024 s = strings.TrimSuffix(s, "gb") case strings.HasSuffix(s, "b"): multiplier = 1 s = strings.TrimSuffix(s, "b") } var n int64 _, err := fmt.Sscanf(strings.TrimSpace(s), "%d", &n) if err != nil { return 0, fmt.Errorf("invalid size %q: %w", original, err) } if n < 0 { return 0, fmt.Errorf("invalid size %q: size must be positive", original) } return n * multiplier, nil } func randomInt64(min, max int64) (int64, error) { if max < min { return 0, fmt.Errorf("max size must be >= min size") } if min == max { return min, nil } span := max - min + 1 if span <= 0 { return 0, fmt.Errorf("invalid random range: min=%d max=%d", min, max) } n, err := rand.Int(rand.Reader, big.NewInt(span)) if err != nil { return 0, err } return min + n.Int64(), nil } func randomBytes(size int64) ([]byte, string, error) { if size < 0 { return nil, "", fmt.Errorf("size must be positive") } buf := make([]byte, size) if _, err := rand.Read(buf); err != nil { return nil, "", err } sum := sha256.Sum256(buf) return buf, hex.EncodeToString(sum[:]), nil } func sha256File(path string) (string, int64, error) { f, err := os.Open(path) if err != nil { return "", 0, err } defer f.Close() h := sha256.New() n, err := io.Copy(h, f) if err != nil { return "", 0, err } return hex.EncodeToString(h.Sum(nil)), n, nil } func newS3Client(ctx context.Context, ep Endpoint) (*s3.Client, error) { cfg, err := config.LoadDefaultConfig( ctx, config.WithRegion(ep.Region), config.WithCredentialsProvider( awscred.NewStaticCredentialsProvider(ep.AccessKey, ep.SecretKey, ""), ), ) if err != nil { return nil, err } client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(ep.Endpoint) o.UsePathStyle = true }) return client, nil } func ensureBucket(ctx context.Context, client *s3.Client, bucket string) error { _, err := client.CreateBucket(ctx, &s3.CreateBucketInput{ Bucket: aws.String(bucket), }) if err == nil { return nil } var owned *s3types.BucketAlreadyOwnedByYou if errors.As(err, &owned) { return nil } var exists *s3types.BucketAlreadyExists if errors.As(err, &exists) { return nil } msg := err.Error() if strings.Contains(msg, "BucketAlreadyOwnedByYou") || strings.Contains(msg, "BucketAlreadyExists") || strings.Contains(msg, "Your previous request to create the named bucket succeeded") { return nil } return err } func deleteBucketObjects(ctx context.Context, client *s3.Client, bucket string) error { var token *string for { out, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), ContinuationToken: token, }) if err != nil { if strings.Contains(err.Error(), "NoSuchBucket") { return nil } return err } for _, obj := range out.Contents { key := aws.ToString(obj.Key) if key == "" { continue } _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) if err != nil { return fmt.Errorf("delete object %s: %w", key, err) } log.Printf("[cleanup] deleted: s3://%s/%s", bucket, key) } if out.IsTruncated == nil || !*out.IsTruncated { break } token = out.NextContinuationToken } return nil } func contentTypeForKey(key string) string { ext := filepath.Ext(key) if ext == "" { return "application/octet-stream" } if ct := mime.TypeByExtension(ext); ct != "" { return ct } switch ext { case ".bin": return "application/octet-stream" case ".json": return "application/json" case ".txt": return "text/plain; charset=utf-8" default: return "application/octet-stream" } } func makeObjectKey(sourceName string, index int) string { now := time.Now().UTC().Format("20060102T150405Z") patterns := []string{ "small/%s-%03d-random.txt", "medium/%s-%03d-random.bin", "nested/year=2026/month=06/day=09/%s-%03d-file.txt", "unicode/%s-%03d-中文文件名.txt", "unicode/%s-%03d-空 格 文件.txt", "metadata/%s-%03d-content-type.json", } pattern := patterns[(index-1)%len(patterns)] return fmt.Sprintf(pattern, now+"-"+sourceName, index) } func uploadRandomObjects(ctx context.Context, ep Endpoint, opt Options) ([]ObjectRecord, error) { log.Printf("[%s] ensure bucket: %s", ep.Name, opt.Bucket) if err := ensureBucket(ctx, ep.Client, opt.Bucket); err != nil { return nil, fmt.Errorf("[%s] create bucket: %w", ep.Name, err) } records := make([]ObjectRecord, 0, opt.ObjectCount) for i := 1; i <= opt.ObjectCount; i++ { size, err := randomInt64(opt.MinSize, opt.MaxSize) if err != nil { return nil, err } key := makeObjectKey(ep.Name, i) data, digest, err := randomBytes(size) if err != nil { return nil, err } _, err = ep.Client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(opt.Bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentLength: aws.Int64(size), ContentType: aws.String(contentTypeForKey(key)), Metadata: map[string]string{ "source": ep.Name, "sha256": digest, }, }) if err != nil { return nil, fmt.Errorf("[%s] put object %s: %w", ep.Name, key, err) } log.Printf("[%s] uploaded: s3://%s/%s size=%d sha256=%s", ep.Name, opt.Bucket, key, size, digest, ) records = append(records, ObjectRecord{ SourceName: ep.Name, Bucket: opt.Bucket, Key: key, Size: size, SHA256: digest, }) } return records, nil } func downloadToTempFile(ctx context.Context, client *s3.Client, bucket, key string) (string, error) { out, err := client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) if err != nil { return "", err } defer out.Body.Close() tmp, err := os.CreateTemp("", "s3-migrate-*") if err != nil { return "", err } defer tmp.Close() if _, err := io.Copy(tmp, out.Body); err != nil { _ = os.Remove(tmp.Name()) return "", err } return tmp.Name(), nil } func uploadFile( ctx context.Context, client *s3.Client, bucket string, key string, path string, sourceName string, digest string, size int64, ) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() _, err = client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: f, ContentLength: aws.Int64(size), ContentType: aws.String(contentTypeForKey(key)), Metadata: map[string]string{ "source": sourceName, "source-sha256": digest, }, }) return err } func syncOneObject(ctx context.Context, source Endpoint, target Endpoint, record ObjectRecord, opt Options) error { targetKey := fmt.Sprintf("from/%s/%s", source.Name, record.Key) tmpPath, err := downloadToTempFile(ctx, source.Client, record.Bucket, record.Key) if err != nil { return fmt.Errorf("[%s] download %s: %w", source.Name, record.Key, err) } defer os.Remove(tmpPath) digest, size, err := sha256File(tmpPath) if err != nil { return fmt.Errorf("[%s] sha256 temp file %s: %w", source.Name, record.Key, err) } if size != record.Size || digest != record.SHA256 { return fmt.Errorf( "[%s] local verify failed key=%s source_size=%d local_size=%d source_sha=%s local_sha=%s", source.Name, record.Key, record.Size, size, record.SHA256, digest, ) } if err := uploadFile(ctx, target.Client, opt.Bucket, targetKey, tmpPath, source.Name, digest, size); err != nil { return fmt.Errorf("[otterio] upload %s: %w", targetKey, err) } log.Printf("[%s -> %s] synced: s3://%s/%s size=%d sha256=%s", source.Name, target.Name, opt.Bucket, targetKey, size, digest, ) return nil } func verifyTargetObject(ctx context.Context, target Endpoint, opt Options, record ObjectRecord) error { targetKey := fmt.Sprintf("from/%s/%s", record.SourceName, record.Key) tmpPath, err := downloadToTempFile(ctx, target.Client, opt.Bucket, targetKey) if err != nil { return fmt.Errorf("[verify] download target %s: %w", targetKey, err) } defer os.Remove(tmpPath) digest, size, err := sha256File(tmpPath) if err != nil { return fmt.Errorf("[verify] sha256 target %s: %w", targetKey, err) } if size != record.Size || digest != record.SHA256 { return fmt.Errorf( "[verify] mismatch target=%s source_size=%d target_size=%d source_sha=%s target_sha=%s", targetKey, record.Size, size, record.SHA256, digest, ) } log.Printf("[verify] ok: s3://%s/%s size=%d sha256=%s", opt.Bucket, targetKey, size, digest, ) return nil } func countObjectsWithPrefix(ctx context.Context, client *s3.Client, bucket, prefix string) (int, error) { var token *string count := 0 for { out, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), Prefix: aws.String(prefix), ContinuationToken: token, }) if err != nil { return 0, err } count += len(out.Contents) if out.IsTruncated == nil || !*out.IsTruncated { break } token = out.NextContinuationToken } return count, nil } func listTargetObjects(ctx context.Context, target Endpoint, bucket string) error { var token *string var keys []string for { out, err := target.Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), ContinuationToken: token, }) if err != nil { return err } for _, obj := range out.Contents { keys = append(keys, fmt.Sprintf("%s %d", aws.ToString(obj.Key), aws.ToInt64(obj.Size))) } if out.IsTruncated == nil || !*out.IsTruncated { break } token = out.NextContinuationToken } sort.Strings(keys) log.Printf("[otterio] object list:") for _, key := range keys { log.Printf(" %s", key) } return nil } func printSyncSummary( ctx context.Context, target Endpoint, sources []Endpoint, opt Options, allRecords []ObjectRecord, verifiedCount int, ) error { expected := len(sources) * opt.ObjectCount log.Printf("") log.Printf("SYNC SUMMARY") log.Printf(" source endpoints: %d", len(sources)) log.Printf(" objects per source: %d", opt.ObjectCount) log.Printf(" expected objects: %d", expected) log.Printf(" uploaded source objects: %d", len(allRecords)) log.Printf(" verified target objects: %d", verifiedCount) log.Printf(" target endpoint: %s", target.Endpoint) log.Printf(" target bucket: %s", opt.Bucket) if len(allRecords) != expected { return fmt.Errorf("uploaded object count mismatch: expected=%d actual=%d", expected, len(allRecords)) } if verifiedCount != expected { return fmt.Errorf("verified object count mismatch: expected=%d actual=%d", expected, verifiedCount) } totalFromPrefixCount, err := countObjectsWithPrefix(ctx, target.Client, opt.Bucket, "from/") if err != nil { return fmt.Errorf("count target objects: %w", err) } log.Printf(" target from/ objects: %d", totalFromPrefixCount) if totalFromPrefixCount != expected { return fmt.Errorf("target object count mismatch under from/: expected=%d actual=%d", expected, totalFromPrefixCount) } for _, source := range sources { prefix := fmt.Sprintf("from/%s/", source.Name) count, err := countObjectsWithPrefix(ctx, target.Client, opt.Bucket, prefix) if err != nil { return fmt.Errorf("count target prefix %s: %w", prefix, err) } log.Printf(" target %s objects: %d", prefix, count) if count != opt.ObjectCount { return fmt.Errorf("target prefix count mismatch prefix=%s expected=%d actual=%d", prefix, opt.ObjectCount, count) } } log.Printf(" result: ALL OBJECTS SYNCED AND VERIFIED") return nil } func main() { var ( bucket string objectCount int minSizeText string maxSizeText string cleanup bool listObjects bool ) flag.StringVar(&bucket, "bucket", env("S3_TEST_BUCKET", "migration-test"), "bucket name") flag.IntVar(&objectCount, "count", 12, "random object count per source") flag.StringVar(&minSizeText, "min-size", "1kb", "min random object size, e.g. 1kb, 1mb") flag.StringVar(&maxSizeText, "max-size", "10mb", "max random object size, e.g. 10mb, 100mb") flag.BoolVar(&cleanup, "cleanup", false, "delete all objects in test bucket before running") flag.BoolVar(&listObjects, "list", false, "list all objects in target bucket after sync") flag.Parse() minSize, err := parseSize(minSizeText) if err != nil { log.Fatal(err) } maxSize, err := parseSize(maxSizeText) if err != nil { log.Fatal(err) } if objectCount <= 0 { log.Fatal("count must be greater than 0") } if maxSize < minSize { log.Fatal("max-size must be greater than or equal to min-size") } opt := Options{ Bucket: bucket, TargetName: "otterio", ObjectCount: objectCount, MinSize: minSize, MaxSize: maxSize, Cleanup: cleanup, ListObjects: listObjects, } accessKey := env("S3_ACCESS_KEY", "admin") secretKey := env("S3_SECRET_KEY", "otterio123456") region := env("S3_REGION", "us-east-1") endpoints := []Endpoint{ { Name: "minio2025", Endpoint: env("MINIO2025_ENDPOINT", "http://127.0.0.1:9102"), AccessKey: accessKey, SecretKey: secretKey, Region: region, }, { Name: "minio2021", Endpoint: env("MINIO2021_ENDPOINT", "http://127.0.0.1:9103"), AccessKey: accessKey, SecretKey: secretKey, Region: region, }, { Name: "pgstyminio", Endpoint: env("PGSTYMINIO_ENDPOINT", "http://127.0.0.1:9104"), AccessKey: accessKey, SecretKey: secretKey, Region: region, }, { Name: "otterio", Endpoint: env("OTTERIO_ENDPOINT", "http://127.0.0.1:9101"), AccessKey: accessKey, SecretKey: secretKey, Region: region, }, } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() for i := range endpoints { client, err := newS3Client(ctx, endpoints[i]) if err != nil { log.Fatalf("[%s] create client: %v", endpoints[i].Name, err) } endpoints[i].Client = client log.Printf("[%s] endpoint=%s", endpoints[i].Name, endpoints[i].Endpoint) } var target Endpoint var sources []Endpoint for _, ep := range endpoints { if ep.Name == opt.TargetName { target = ep } else { sources = append(sources, ep) } } if target.Client == nil { log.Fatalf("target endpoint %q not found", opt.TargetName) } log.Printf("[otterio] ensure target bucket: %s", opt.Bucket) if err := ensureBucket(ctx, target.Client, opt.Bucket); err != nil { log.Fatalf("[otterio] create bucket: %v", err) } if opt.Cleanup { log.Printf("[cleanup] delete all objects in bucket %s on all endpoints", opt.Bucket) for _, ep := range endpoints { if err := deleteBucketObjects(ctx, ep.Client, opt.Bucket); err != nil { log.Fatalf("[%s] cleanup failed: %v", ep.Name, err) } } } var allRecords []ObjectRecord for _, source := range sources { records, err := uploadRandomObjects(ctx, source, opt) if err != nil { log.Fatal(err) } allRecords = append(allRecords, records...) } for _, source := range sources { for _, record := range allRecords { if record.SourceName != source.Name { continue } if err := syncOneObject(ctx, source, target, record, opt); err != nil { log.Fatal(err) } } } verifiedCount := 0 for _, record := range allRecords { if err := verifyTargetObject(ctx, target, opt, record); err != nil { log.Fatal(err) } verifiedCount++ } if opt.ListObjects { if err := listTargetObjects(ctx, target, opt.Bucket); err != nil { log.Fatal(err) } } if err := printSyncSummary(ctx, target, sources, opt, allRecords, verifiedCount); err != nil { log.Fatal(err) } } ``` ### 创建 `Dockerfile` ```bash cat > Dockerfile <<'EOF' FROM golang:1.26 WORKDIR /src ENV GO111MODULE=on \ GOPROXY=https://goproxy.cn,direct \ GOSUMDB=sum.golang.google.cn COPY main.go ./ RUN go env GOPROXY GOSUMDB RUN go mod init s3-migration-check RUN go mod tidy -x RUN CGO_ENABLED=0 GOOS=linux go build -o /usr/local/bin/s3-migration-check ENTRYPOINT ["/usr/local/bin/s3-migration-check"] EOF ``` 如果你的环境里没有 `golang:1.26`,可以换成已经存在的 Go 镜像标签,例如: ```dockerfile FROM golang:latest ``` 这里的 Go 镜像只是用来构建测试程序,和 OtterIO 源码本身的构建要求不是一回事。 ### 构建测试镜像 回到实验根目录: ```bash cd ~/otterio-minio-migration-lab ``` 构建镜像: ```bash docker build -t local/s3-migration-check:latest ``` 确认程序参数: ```bash docker run --rm local/s3-migration-check:latest -h ``` 预期能看到类似: ```text Usage of s3-migration-check: -bucket string bucket name -cleanup delete all objects in test bucket before running -count int random object count per source -list list all objects in target bucket after sync -max-size string max random object size, e.g. 10mb, 100mb -min-size string min random object size, e.g. 1kb, 1mb ``` ### 运行迁移验证 确保四个对象存储服务已经启动: ```bash docker compose ps ``` 然后运行测试容器: ```bash docker run --rm \ --network object-storage-lab \ -e S3_ACCESS_KEY=admin \ -e S3_SECRET_KEY=otterio123456 \ -e S3_REGION=us-east-1 \ -e MINIO2025_ENDPOINT=http://minio-2025:9000 \ -e MINIO2021_ENDPOINT=http://minio-apache-2021:9000 \ -e PGSTYMINIO_ENDPOINT=http://pgsty-minio:9000 \ -e OTTERIO_ENDPOINT=http://otterio:9000 \ -e S3_TEST_BUCKET=migration-test \ local/s3-migration-check:latest \ -bucket migration-test \ -count 12 \ -min-size 1kb \ -max-size 10mb \ -cleanup ``` 这里的参数含义是: | 参数 | 含义 | | ------------------------ | -------------------------------- | | `-bucket migration-test` | 使用 `migration-test` 作为测试 bucket | | `-count 12` | 每个源端创建 12 个随机对象 | | `-min-size 1kb` | 单个对象最小 1KB | | `-max-size 10mb` | 单个对象最大 10MB | | `-cleanup` | 运行前清理四个 endpoint 中该 bucket 下的旧对象 | 如果要把对象列表也打印出来,可以增加: ```bash -list ``` 例如: ```bash docker run --rm \ --network object-storage-lab \ -e S3_ACCESS_KEY=admin \ -e S3_SECRET_KEY=otterio123456 \ -e S3_REGION=us-east-1 \ -e MINIO2025_ENDPOINT=http://minio-2025:9000 \ -e MINIO2021_ENDPOINT=http://minio-apache-2021:9000 \ -e PGSTYMINIO_ENDPOINT=http://pgsty-minio:9000 \ -e OTTERIO_ENDPOINT=http://otterio:9000 \ -e S3_TEST_BUCKET=migration-test \ local/s3-migration-check:latest \ -bucket migration-test \ -count 12 \ -min-size 1kb \ -max-size 10mb \ -cleanup \ -list ``` 如果去掉 `-cleanup`,历史对象会影响 `from/` 前缀下的数量统计。更严谨的做法是保留 `-cleanup`,或者每次使用新的 bucket 名称: ```bash -bucket migration-test-$(date +%s) ``` ### 如何判断全部同步成功 如果每个源端创建 12 个对象,三个源端总计应该是: ```text 12 × 3 = 36 ``` OtterIO 中应该有: ```text migration-test/from/minio2025/ 12 个 migration-test/from/minio2021/ 12 个 migration-test/from/pgstyminio/ 12 个 ``` 最终程序会输出类似摘要: ```text SYNC SUMMARY source endpoints: 3 objects per source: 12 expected objects: 36 uploaded source objects: 36 verified target objects: 36 target endpoint: http://otterio:9000 target bucket: migration-test target from/ objects: 36 target from/minio2025/ objects: 12 target from/minio2021/ objects: 12 target from/pgstyminio/ objects: 12 result: ALL OBJECTS SYNCED AND VERIFIED ``` 看到最后这一行: ```text result: ALL OBJECTS SYNCED AND VERIFIED ``` 才表示本轮迁移验证通过。 这里的“通过”包含三层含义: 1. 三个源端对象创建成功。 2. 所有源端对象都同步到了 OtterIO 对应路径。 3. 每个目标对象都能重新下载,并且 `Size` 和 `SHA256` 与源端记录完全一致。 不要只看 `ListObjects` 数量。数量一致只能说明“看起来对象都在”,不能证明对象内容没有损坏。 ## 其他:实验中可能会遇到的兼容性问题 ### `minio/mc:latest sh` 报错 错误示例: ```text mc: `sh` is not a recognized command. ``` 原因是 `minio/mc:latest` 镜像入口命令默认就是 `mc`。你传入的 `sh` 会被解释成 `mc sh`。 解决方式是覆盖 entrypoint: ```bash docker run --rm -it \ --network object-storage-lab \ --entrypoint /bin/sh \ minio/mc:latest ``` ### 旧版 MinIO `DeleteObjects` 报 `MissingContentMD5` 在 `minio/minio:RELEASE.2021-04-22T15-44-28Z` 上,如果使用 AWS SDK v2 发起 `DeleteObjects` 批量删除请求,可能会遇到: ```text MissingContentMD5: Missing required header for this request: Content-Md5. ``` 这是因为旧版 MinIO 对批量删除请求要求 `Content-MD5` 请求头。 为了让测试程序兼容不同年代的 S3 实现,本文的清理逻辑没有使用 `DeleteObjects` 批量删除,而是: 1. `ListObjectsV2` 列出对象; 2. 对每个对象逐个调用 `DeleteObject`; 3. 避免旧版 MinIO 对批量删除接口的 `Content-MD5` 要求。 这个方式速度慢一点,但迁移测试里的对象数量不大,换来的是更好的兼容性和更清晰的错误定位。 ### 为什么同步时先下载到临时文件 直接把源端 `GetObject.Body` 接到目标端 `PutObject.Body` 看起来更省事: ```text source.GetObject -> target.PutObject ``` 但迁移验证时,不建议这么做。 本文选择: ```text source.GetObject -> temp file -> sha256 -> target.PutObject -> target.GetObject -> sha256 ``` 好处是: 1. 可以明确记录源端对象的 `Size` 和 `SHA256`; 2. 可以在上传到目标端之前先校验本地临时文件; 3. 可以在目标端上传后重新下载并再次校验; 4. 更容易定位问题发生在源端读取、网络传输、目标端写入,还是目标端读取; 5. 更接近真实迁移程序里的“可审计”流程。 ## 生产部署提醒 单节点 OtterIO 适合开发、测试、CI 和文章示例,不应该直接当作高可用生产部署。 生产环境至少要考虑: | 事项 | 建议 | | ---------- | ---------------------------------------- | | 数据副本 / 纠删码 | 不要把单节点当高可用 | | 节点规划 | 多节点、多磁盘部署要提前规划 | | 数据目录 | 分布式模式应使用新的、干净的数据目录 | | 时间同步 | 多节点部署应开启 NTP | | 网络 | 节点之间网络要稳定、延迟可控 | | 凭据 | 所有节点 root 凭据应一致 | | 回滚 | 保留旧服务、旧数据和迁移记录 | | 监控 | health、metrics、日志都要纳入监控 | | 权限 | IAM、bucket policy、service account 需要单独验证 | | 合规 | Retention、Object Lock、审计日志要按业务要求单独验证 | 对象存储是基础设施组件,生产上线前不能只看“能不能启动”。更合理的上线标准应该包括: * 应用真实读写路径测试; * 多尺寸对象测试; * multipart upload 测试; * 断点 / 重试测试; * 权限边界测试; * lifecycle / retention / versioning 测试; * 故障恢复测试; * 回滚演练。 ### 不兼容清单:迁移前必读 迁移前,我们可以通过下面这张表格快速扫雷: | 分类 | 不兼容或行为变化 | 迁移影响 | | ------------------------------------ | ------------------------------------------------- | -------------------------------------------------------------- | | 项目身份 | OtterIO 是独立社区 fork,不是 MinIO 官方项目 | 文档、镜像、商标表述都不能写成 MinIO 官方 | | 许可证基线 | 基于 MinIO Apache 2.0 代码线继续整理 | 不能假设当前 MinIO 主线功能会自动存在 | | 镜像 | `soulteary/otterio` / `ghcr.io/soulteary/otterio` | 旧 `minio/minio`、`quay.io/minio/minio` 要替换 | | 二进制 | `otterio server` 或容器入口 `server` | 裸机部署脚本里的 `minio server` 要替换 | | root 环境变量 | `OTTERIO_ROOT_USER` / `OTTERIO_ROOT_PASSWORD` | 旧 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 不应继续作为 OtterIO 主配置 | | 默认凭据 | `otterioadmin:otterioadmin` | 旧 `minioadmin:minioadmin` 不再适用 | | S3 API 端口 | 通常仍为 `:9000` | 应用侧 endpoint 多数情况下只需改 host / 凭据 | | Console 路径 | 拆分端口后是 `/otterio/` | 旧控制台地址要改 | | Admin API | 拆分端口后走 Console/Admin listener | `mc admin` alias 不要和 S3 alias 混用 | | 控制台 TLS | 独立 Console 证书配置需要和 Console listener 配合 | 反向代理和证书目录要重新验证 | | Notification target | 只保留部分通知目标 | 依赖 Kafka、NATS、AMQP、MQTT 等目标的旧场景要重构 | | Gateway | 只保留部分 gateway 场景 | Azure、GCS、HDFS 等旧 gateway 用法不能默认迁移 | | Gateway notification | Gateway 模式通知能力有限 | 依赖 gateway 通知的旧场景要单独验证 | | Go 工具链 | 源码构建要求以当前仓库文档为准 | 老构建环境不能直接假设可编译 | | Release 架构 | 以项目发布流水线实际产物为准 | 非常用架构需要自行构建 / 验证 | | 分布式目录 | 分布式模式应使用 fresh directories | 不能把旧生产目录直接挂进新分布式集群 | | 纠删码集合 | EC set 规划需要满足项目约束 | 磁盘数量规划要提前确认 | | LDAP DN | DN 规范化可能改变历史身份映射 | 依赖大小写差异区分身份的旧部署要特别小心 | | 内部 header / metadata | 可能从 `X-Minio-*` 迁移到 `X-Otterio-*` | 依赖内部 header 的集成要修改 | | WORM 环境变量 | 旧全局 WORM 用法不应继续依赖 | 应迁移到 bucket / object retention 语义 | | Bucket ACL | 不支持或不建议依赖 | 用 bucket policy 替代 | | Bucket CORS | 行为需要按文档确认 | 依赖自定义 CORS 的应用要回归测试 | | Bucket Website | 不支持或不建议依赖 | 用 Caddy / Nginx 等反向代理替代 | | Bucket Analytics / Metrics / Logging | 部分 Bucket API 不支持 | 用外部监控、日志和指标系统替代 | | Bucket RequestPayment | 不支持 | 依赖该 API 的应用不能直接迁移 | | Object ACL | 不支持或不建议依赖 | 用 bucket policy 替代 | | ObjectTorrent | 不支持 | 依赖 torrent 的场景不能迁移 | | 浏览器上传 | 大对象浏览器上传要单独验证 | 大对象建议走 API / multipart | | 单次 PUT | 单次 PUT 有上限 | 大文件需要 multipart upload | | 最大对象 | 最大对象大小有上限 | 超大对象要拆分或确认业务限制 | | Windows 对象名 | Windows 上对象名不能包含部分特殊字符 | 跨平台 key 命名要约束 | 其中最容易踩坑的是: 1. Notification; 2. Gateway; 3. LDAP DN; 4. Admin API 端口; 5. root 环境变量; 6. 内部 header / metadata; 7. Bucket / Object ACL; 8. 旧工具链和旧 `mc admin` 命令行为。 如果你的业务只使用标准 S3 Put/Get/List/Delete、multipart upload 和 bucket policy,迁移通常更容易。如果依赖 MinIO 的管理 API、通知系统、gateway、LDAP、特殊 header 或非标准行为,就必须逐项验证。 ## 最后 写到这里,OtterIO 这件事差不多就说清楚了。 如果你的应用只是把 MinIO 当成一个 S3 兼容对象存储来用,迁移通常不会太麻烦。服务端换成 OtterIO,调整镜像、启动参数和环境变量;客户端继续使用 AWS CLI、s3cmd 或各语言 SDK,改一下 endpoint 和凭据,大部分上传、下载、列举对象的代码都不用动。 但它不是当前 MinIO 主线的完整替代品。Notification、Gateway、部分 S3 API、LDAP DN、Admin API 端口、环境变量命名、内部 header / metadata 命名,都有差异。越是依赖 MinIO 私有行为和管理接口的系统,迁移前越要多验证。 开发环境、CI、文章示例可以简单一点;生产环境不要从原地挂载旧目录开始。把 OtterIO 当成一个新的 S3 endpoint,用标准 S3 API 做副本迁移,再用对象级校验确认结果,会更稳妥。 这也是我整理 OtterIO 的初衷:给过去那些依赖 MinIO 的示例和小型私有化场景,留一个许可证边界清楚、能继续验证、也方便自己维护的选择。 **它不需要被包装成“无脑替换”。能替的地方,就少改代码;不能替的地方,就提前写清楚。** --EOF