这篇文章来聊一个实际的问题:如果过去的项目、文章示例、CI 环境或者小型私有化部署里用了 MinIO,现在想切换到 OtterIO,应该怎么改、怎么部署、怎么验证数据确实迁移成功,以及哪些地方是不兼容的。

写在前面

前两篇文章里,我已经把 OtterIO 的来龙去脉整理了一遍:为什么需要一条 Apache License 2.0 的衍生版本,为什么不是简单改名,为什么要保留原始版权声明,也为什么要和当前 MinIO 主线、MinIO 商标、MinIO 官方发行物保持清楚边界。

OtterIO 取意于 Otter(水獭)+ I/O:像水獭守护贝壳一样,轻巧、可靠地守护你的对象数据。

如果你还不清楚,可以翻阅《重新审视 MinIO:许可证、归档、社区 fork 与我的 Apache 2.0 基线》、《从 MinIO 到 OtterIO:整理一条 Apache 2.0 开源对象存储代码线》了解。

soulteary/otterio

开源项目地址:soulteary/otterio,如果对你有帮助,欢迎一键三连。

这篇文章,让我们回到操作层面。

本文不试图证明 OtterIO 是当前 MinIO 的完整替代品。相反,本文的重点是提供一个可复现的验证方法:通过标准 S3 API,把几个不同版本、不同来源的 MinIO / S3 兼容对象存储里的对象同步到 OtterIO,并用对象级校验确认迁移结果。

先说结论

如果你的旧 MinIO 用法只是本地开发、CI、测试环境、文章示例、小型私有化环境,迁移通常比较直接。

把镜像从:

image: minio/minio

换成:

image: soulteary/otterio:latest

或者:

image: ghcr.io/soulteary/otterio:latest

把旧的 MinIO root 环境变量:

MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123456

换成 OtterIO 的环境变量:

OTTERIO_ROOT_USER: otterio
OTTERIO_ROOT_PASSWORD: please-change-this-password

把启动命令确认成:

command: server --address ":9000" --console-address ":9001" /data

S3 endpoint 通常仍然可以保持为:

http://127.0.0.1:9000

如果拆分了 S3 API 和 Web Console 监听端口,控制台可以通过下面的地址访问:

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

先准备数据目录:

mkdir -p ./data

启动单节点 OtterIO:

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

本地体验时,默认凭据通常只适合临时测试。实际环境里应该显式设置:

OTTERIO_ROOT_USER
OTTERIO_ROOT_PASSWORD

并使用足够强度的密码。

Docker Compose 示例

旧文章里如果有 MinIO Compose,可以改成下面这样:

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

启动:

docker compose up -d

查看日志:

docker logs -f otterio

如果你只在本机开发,也可以不拆分控制台端口,直接:

command: server /data
ports:
  - "9000:9000"

个人建议默认拆分 S3 和 Console:“业务流量”和“管理流量”不是一回事,也更方便我们后续处理反向代理、防火墙策略和访问控制。

用 AWS CLI 验证

AWS CLI 不需要特殊适配,只需要指定 endpoint 即可:

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 迁移对照

旧示例可能长这样:

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

迁移后建议写成:

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 迁移对象:

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。

我们要验证的迁移方向是:

minio-2025           -> otterio
minio-apache-2021    -> otterio
pgsty-minio          -> otterio

我们的验证标准不是“命令跑完了”,也不是“对象数量差不多”,而是:

  1. 三个源端都动态生成随机对象。
  2. 每个源端对象都记录 KeySizeSHA256
  3. 同步到 OtterIO 后,从 OtterIO 重新下载对象。
  4. 对目标对象重新计算 SizeSHA256
  5. 只有源端和目标端的 SizeSHA256 都一致,才认为该对象同步成功。
  6. 所有对象都通过校验后,才认为本轮迁移验证通过。

准备测试目录

mkdir -p ~/otterio-minio-migration-lab
cd ~/otterio-minio-migration-lab

mkdir -p data/{otterio,minio-2025,minio-apache-2021,pgsty-minio}

目录结构最终类似:

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

cat > .env <<'EOF'
ROOT_USER=admin
ROOT_PASSWORD=otterio123456
EOF

编写 docker-compose.yml

这个实验只暴露 S3 API 端口,不额外暴露 Console 端口。这样兼容性最好,尤其是对 RELEASE.2021-04-22T15-44-28Z 这种较老版本。

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

使用命令,启动四个服务:

docker compose up -d
docker compose ps

在日志中,我们能够得到宿主机访问端口对应关系:

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

容器网络内访问地址:

http://otterio:9000
http://minio-2025:9000
http://minio-apache-2021:9000
http://pgsty-minio:9000

检查容器网络:

docker network inspect object-storage-lab \
  --format '{{range .Containers}}{{.Name}} -> {{.IPv4Address}}{{println}}{{end}}'

预期能看到类似:

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 做连通性检查

如果直接执行:

docker run --rm -it \
  --network object-storage-lab \
  minio/mc:latest \
  sh

可能会看到:

mc: <ERROR> `sh` is not a recognized command.

原因是 minio/mc:latest 镜像的入口命令默认就是 mc,你后面传的 sh 会被当成 mc sh 子命令。

正确方式是覆盖 entrypoint:

docker run --rm -it \
  --network object-storage-lab \
  --entrypoint /bin/sh \
  minio/mc:latest

进入容器后执行:

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 程序动态生成随机对象,并且每个对象都记录 SizeSHA256

程序行为

程序连接四个 S3 endpoint:

minio2025      -> http://minio-2025:9000
minio2021      -> http://minio-apache-2021:9000
pgstyminio     -> http://pgsty-minio:9000
otterio        -> http://otterio:9000

其中:

minio2025
minio2021
pgstyminio

是源端。

otterio

是目标端。

程序会在三个源端分别创建随机对象,然后同步到 OtterIO。

目标端路径格式:

from/<sourceName>/<originalKey>

例如:

源端:
s3://migration-test/small/20260609T041223Z-minio2025-001-random.txt

目标端:
s3://migration-test/from/minio2025/small/20260609T041223Z-minio2025-001-random.txt

对象 Key 覆盖这些典型场景:

small/
medium/
nested/year=2026/month=06/day=09/
unicode/中文文件名.txt
unicode/空 格 文件.txt
metadata/content-type.json

同步流程是:

  1. 从源端 GetObject 下载对象。
  2. 先写入本地临时文件。
  3. 对临时文件重新计算 SizeSHA256
  4. 确认临时文件和源端记录一致。
  5. 再上传到 OtterIO。
  6. 上传后从 OtterIO 重新下载。
  7. 对目标对象重新计算 SizeSHA256
  8. 全部一致后输出成功摘要。

这里没有直接把 GetObject.Body 接到 PutObject.Body,是有意为之。临时文件方式更适合做迁移验证:可以明确计算校验值,也能避开部分 S3 兼容服务在流式上传、重试、checksum、不可 seek stream 场景下的差异。

初始化 Go 项目

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 的要求。

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

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 镜像标签,例如:

FROM golang:latest

这里的 Go 镜像只是用来构建测试程序,和 OtterIO 源码本身的构建要求不是一回事。

构建测试镜像

回到实验根目录:

cd ~/otterio-minio-migration-lab

构建镜像:

docker build -t local/s3-migration-check:latest

确认程序参数:

docker run --rm local/s3-migration-check:latest -h

预期能看到类似:

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

运行迁移验证

确保四个对象存储服务已经启动:

docker compose ps

然后运行测试容器:

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 下的旧对象

如果要把对象列表也打印出来,可以增加:

-list

例如:

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 名称:

-bucket migration-test-$(date +%s)

如何判断全部同步成功

如果每个源端创建 12 个对象,三个源端总计应该是:

12 × 3 = 36

OtterIO 中应该有:

migration-test/from/minio2025/      12 个
migration-test/from/minio2021/      12 个
migration-test/from/pgstyminio/     12 个

最终程序会输出类似摘要:

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

看到最后这一行:

result: ALL OBJECTS SYNCED AND VERIFIED

才表示本轮迁移验证通过。

这里的“通过”包含三层含义:

  1. 三个源端对象创建成功。
  2. 所有源端对象都同步到了 OtterIO 对应路径。
  3. 每个目标对象都能重新下载,并且 SizeSHA256 与源端记录完全一致。

不要只看 ListObjects 数量。数量一致只能说明“看起来对象都在”,不能证明对象内容没有损坏。

其他:实验中可能会遇到的兼容性问题

minio/mc:latest sh 报错

错误示例:

mc: <ERROR> `sh` is not a recognized command.

原因是 minio/mc:latest 镜像入口命令默认就是 mc。你传入的 sh 会被解释成 mc sh

解决方式是覆盖 entrypoint:

docker run --rm -it \
  --network object-storage-lab \
  --entrypoint /bin/sh \
  minio/mc:latest

旧版 MinIO DeleteObjectsMissingContentMD5

minio/minio:RELEASE.2021-04-22T15-44-28Z 上,如果使用 AWS SDK v2 发起 DeleteObjects 批量删除请求,可能会遇到:

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 看起来更省事:

source.GetObject -> target.PutObject

但迁移验证时,不建议这么做。

本文选择:

source.GetObject -> temp file -> sha256 -> target.PutObject -> target.GetObject -> sha256

好处是:

  1. 可以明确记录源端对象的 SizeSHA256
  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/minioquay.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