这篇文章来聊一个实际的问题:如果过去的项目、文章示例、CI 环境或者小型私有化部署里用了 MinIO,现在想切换到 OtterIO,应该怎么改、怎么部署、怎么验证数据确实迁移成功,以及哪些地方是不兼容的。
写在前面
前两篇文章里,我已经把 OtterIO 的来龙去脉整理了一遍:为什么需要一条 Apache License 2.0 的衍生版本,为什么不是简单改名,为什么要保留原始版权声明,也为什么要和当前 MinIO 主线、MinIO 商标、MinIO 官方发行物保持清楚边界。

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

开源项目地址: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
我们的验证标准不是“命令跑完了”,也不是“对象数量差不多”,而是:
- 三个源端都动态生成随机对象。
- 每个源端对象都记录
Key、Size和SHA256。 - 同步到 OtterIO 后,从 OtterIO 重新下载对象。
- 对目标对象重新计算
Size和SHA256。 - 只有源端和目标端的
Size、SHA256都一致,才认为该对象同步成功。 - 所有对象都通过校验后,才认为本轮迁移验证通过。
准备测试目录
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 程序动态生成随机对象,并且每个对象都记录 Size 和 SHA256。
程序行为
程序连接四个 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
同步流程是:
- 从源端
GetObject下载对象。 - 先写入本地临时文件。
- 对临时文件重新计算
Size和SHA256。 - 确认临时文件和源端记录一致。
- 再上传到 OtterIO。
- 上传后从 OtterIO 重新下载。
- 对目标对象重新计算
Size和SHA256。 - 全部一致后输出成功摘要。
这里没有直接把 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
才表示本轮迁移验证通过。
这里的“通过”包含三层含义:
- 三个源端对象创建成功。
- 所有源端对象都同步到了 OtterIO 对应路径。
- 每个目标对象都能重新下载,并且
Size和SHA256与源端记录完全一致。
不要只看 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 DeleteObjects 报 MissingContentMD5
在 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 批量删除,而是:
ListObjectsV2列出对象;- 对每个对象逐个调用
DeleteObject; - 避免旧版 MinIO 对批量删除接口的
Content-MD5要求。
这个方式速度慢一点,但迁移测试里的对象数量不大,换来的是更好的兼容性和更清晰的错误定位。
为什么同步时先下载到临时文件
直接把源端 GetObject.Body 接到目标端 PutObject.Body 看起来更省事:
source.GetObject -> target.PutObject
但迁移验证时,不建议这么做。
本文选择:
source.GetObject -> temp file -> sha256 -> target.PutObject -> target.GetObject -> sha256
好处是:
- 可以明确记录源端对象的
Size和SHA256; - 可以在上传到目标端之前先校验本地临时文件;
- 可以在目标端上传后重新下载并再次校验;
- 更容易定位问题发生在源端读取、网络传输、目标端写入,还是目标端读取;
- 更接近真实迁移程序里的“可审计”流程。
生产部署提醒
单节点 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 命名要约束 |
其中最容易踩坑的是:
- Notification;
- Gateway;
- LDAP DN;
- Admin API 端口;
- root 环境变量;
- 内部 header / metadata;
- Bucket / Object ACL;
- 旧工具链和旧
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