4576 字
6 分钟
14 次
- 2025-12-29
1 前言
近期 MinIO 宣布开源版本进入 “维护模式”。从 5 月 22 日版起,阉割了 Web 的大部分功能,以及至今的种种迹象来看,MinIO 似乎要朝着闭源商业版发展,对我这种穷小子来说,还是很无奈的。
我用的是 4 月 22 日版的 MinIO,包括这个博客也是,但是这个版本现在只有 Linux 版和 Docker 版了,Window 版的我在网上找遍了都找不到。
因为我的博客是要长期维护和更新的,所以我打算从 MinIO 迁移到最近很火的 RustFS 上,并记录我的迁移过程。
我将从 RustFS 学习、SpringBoot 的集成和从 MinIO 迁移的过程三个方向来记录,这样思路比较清晰一点。
2 RustFS 学习
2.1 RustFS 介绍
先贴一下我从官网找到的资料
- RustFS 是使用目前全球最流行的、内存安全的 Rust 语言开发的
高性能,分布式对象存储软件。RustFS 100% 兼容 S3 协议,使用 Apache2 许可证发行的开源软件。RustFS 即将从商业应用正式向开源,向全球进行发布,帮助全世界降低存储成本、提升数据安全。 - 官网说过 RustFS 是 MinIO 的平替产品,从官网的对比上看,性能和 MinIO 相同、使用难度低、但是资源占用更少、内存更加稳定、存储速度很快。最主要的是许可证友好、社区活跃度高,生态趋势十分向好。
除此之外,官网还说明了 Nginx 如何进行反向代理的配置: RustFS Nginx反向代理配置 | RustFS 文档中心
提供了 Java 的 SDK: Java SDK | RustFS 文档中心
在 SDK 中,对集成和使用都做了详细的说明,并且附加了代码示例,其中还有如何进行预签名上传和下载,分片上传等等,相当周到。
2.2 使用 Docker 安装 RustFS
我电脑是 Windows 11 系统,服务器是 Linux 的,所以我平时都是使用 Docker 来安装这些东西,本次我也主要使用 Docker 来安装 RustFS。
Docker 安装就很简单了,官网给出了指南: Docker 安装 RustFS | RustFS 文档中心
我使用 Docker Compose 来安装,Docker Compose 的配置可以参考 RustFS 官方仓库的文件: rustfs/docker-compose-simple.yml at main · rustfs/rustfs · GitHub
首先新建一个 rustfs_demo_docker 文件夹,在文件夹中新建一个 docker-compose.yml 文件,然后填入以下配置
ymlversion: "3.9" services: rustfs: image: rustfs/rustfs:latest container_name: rustfs-server security_opt: - "no-new-privileges:true" ports: - "9000:9000" # S3 API 对外端口 - "9001:9001" # 控制台对外端口 environment: # 数据卷(多个路径用逗号分隔) - RUSTFS_VOLUMES=/data/rustfs0 # API 和控制台监听地址 - RUSTFS_ADDRESS=0.0.0.0:9000 - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 - RUSTFS_CONSOLE_ENABLE=true - RUSTFS_EXTERNAL_ADDRESS=:9000 # CORS 设置,控制台与 S3 API 都放开来源 - RUSTFS_CORS_ALLOWED_ORIGINS=* - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=* # 访问密钥 - RUSTFS_ACCESS_KEY=rustfsadmin - RUSTFS_SECRET_KEY=rustfsadmin volumes: - ./rustfs_data_0:/data/rustfs0 - ./logs:/app/logs networks: - rustfs-network restart: unless-stopped healthcheck: test: [ "CMD", "sh", "-c", "curl -f http://localhost:9000/health && curl -f http://localhost:9001/rustfs/console/health" ] interval: 30s timeout: 10s retries: 3 start_period: 40s networks: rustfs-network: driver: bridge
我都是参照官网和仓库改的
打开命令行,输入 docker-compose up

打开浏览器,输入 http://localhost:9001 访问 Web 端

喔吼,居然还是中文的,太贴心了。
输入 docker-compose.yml 中,环境变量里定义的 RUSTFS_ACCESS_KEY 和 RUSTFS_SECRET_KEY 也就是账号密码。

成功了,非常顺利。我记得一开始安装 MinIO 还遇到了很多问题,特别是那恼人的 Invalid Login。
简单创建一个存储桶(bucket),上传几个文件试试。

桶名不能用下划线哦



在存储桶列表中,点击设置,将同的访问权限改为公有

然后在浏览器打开新的标签页,按照格式:http://localhost:9000/[bucket_name]/[file_name] 访问一下上传的图片

成功了,很顺利。接下来就是 RustFS 的使用了。
2.3 SpringBoot 集成
demo 仓库地址:rustfs_demo: RustFS Learning Demo
先快速搭一个 SpringBoot 项目。
首先创建一个 Java 项目,选好项目地址,Build system 选 Maven,我用 JDK 17 比较多,所以 JDK 我就选 17 了。

Name 标黄是因为我以及创建好了一个,项目名重复了。
打开 pom.xml 文件,在 <properties> 标签下添加下面几个配置:
xml<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.0.0</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>
正好 SpringBoot 4 出了,顺便弄来耍耍。 这里的依赖不需要写版本号,因为 spring-boot-starter-parent 已经预定义好了,如果不清楚哪些不需要写版本号,可以去 Maven 仓库看看:Maven Repository: org.springframework.boot » spring-boot-starter-parent » 4.0.0
将示例的 Main 类,更名为 RustFSDemoApplication,然后将内容修改
javapackage com.example; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class RustFSDemoApplication { public static void main(String[] args) { SpringApplication.run(RustFSDemoApplication.class, args); } }
这时候就可以跑了,运行 RustFSDemoApplication。

当然,能直接跑还是归功于 SpringBoot 约定大于配置和自动配置的特点,为了更加灵活的适配我们的项目,我们还需要编写配置文件。
在 resource 目录下,创建 application.yml 文件,添加一些无关紧要的简单配置
ymlserver: port: 9220 # 端口命名随意 spring: application: name: rustfs-demo-application # 应用名也随意
大功告成了。
现在,参照官网的 SDK 来简单集成 RustFS。 Java SDK | RustFS 文档中心
首先在 pom.xml 文件中,在 <dependencies> 中添加依赖
xml<dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>s3</artifactId> <version>2.40.8</version> </dependency>
为了方便配置的管理,我们将项目需要的配置,写在 application.yml 中,然后通过 ConfigurationProperties + EnableConfigurationProperties 这两个注解读取配置,并创建 S3 客户端,将其注册为 Bean。
首先在 application.yml 中,添加如下配置
ymlrustfs: endpoint: http://localhost:9000 access-key: rustfsadmin secret-key: rustfsadmin
在和 RustFSDemoApplication 同级目录下,创建 model.properties.RustFSProperties 类(model 和 properties 是包名,点击新建 Java 类的时候直接复制上面这个,IDEA 会自动创建包)。然后写入以下内容
javapackage com.example.model.properties; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; @Data @ConfigurationProperties(prefix = "rustfs") public class RustFSProperties { private String endpoint; private String accessKey; private String secretKey; }
这个类的主要作用的读取
application.yml中的配置
然后同样在和 RustFSDemoApplication 同级目录下,创建 config.RustFSConfig ,写入以下内容
javapackage com.example.config; import ch.qos.logback.core.net.server.Client; import com.example.model.properties.RustFSProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import java.net.URI; @Configuration @EnableConfigurationProperties(RustFSProperties.class) public class RustFSConfig { @Autowired private RustFSProperties rustFSProperties; @Bean public S3Client s3Client() { return S3Client.builder() .endpointOverride(URI.create(rustFSProperties.getEndpoint())) // 中国北京区域,官网说这个可以写死,因为 RustFS 不会校验 .region(Region.CN_NORTH_1) .credentialsProvider( StaticCredentialsProvider.create( AwsBasicCredentials.create( // 写 accessKey 和 secretKey rustFSProperties.getAccessKey(), rustFSProperties.getSecretKey() ) ) ) // 这个一定要开启 .forcePathStyle(true) .build(); } }
这个类作用是启用
RustFSProperties配置,并且根据配置将S3Client注册为 Bean。
至此,RustFS 就集成好了,这里可以跑一下程序看看有没有报错。接下来就是要使用这个 S3Client 编写一个简单工具类,实现单个文件的上传和下载。
这里就不多废话了,直接贴代码
utils.RustFSUtil
javapackage com.example.utils; import com.example.model.properties.RustFSProperties; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; @Component @Slf4j public class RustFSUtil { @Autowired private S3Client s3Client; @Autowired private RustFSProperties rustFSProperties; /** * 创建 Bucket * * @param bucketName Bucket 名称 */ public void createBucket(String bucketName) { try { s3Client.createBucket( CreateBucketRequest.builder() .bucket(bucketName) .build() ); log.info("Bucket: {} 创建成功", bucketName); } catch (BucketAlreadyExistsException | BucketAlreadyOwnedByYouException e) { log.error("Bucket: {} 已经存在", bucketName, e); throw new RuntimeException("Bucket 创建失败,原因:Bucket: " + bucketName + " 已经存在"); } } /** * 上传单个文件 * * @param file 要上传的文件 * @param bucketName Bucket 名称 * @return 文件的 URL */ public String uploadSingleFile(MultipartFile file, String bucketName) { try { s3Client.putObject( PutObjectRequest.builder() .bucket(bucketName) .key(file.getOriginalFilename()) .build(), RequestBody.fromInputStream(file.getInputStream(), file.getSize()) ); log.info("文件: {} 上传成功", file.getOriginalFilename()); return rustFSProperties.getEndpoint() + "/" + bucketName + "/" + file.getOriginalFilename(); } catch (Exception e) { log.error("上传文件失败,原因: {}", e.getMessage(), e); throw new RuntimeException(e); } } /** * 下载单个文件 * * @param bucketName Bucket 名称 * @param fileName 文件名称 * @return 文件的二进制数据 */ public byte[] downloadSingleFile(String bucketName, String fileName) { try { ResponseBytes<GetObjectResponse> objectAsBytes = s3Client.getObjectAsBytes( GetObjectRequest.builder() .bucket(bucketName) .key(fileName) .build() ); byte[] bytes = objectAsBytes.asByteArray(); log.info("文件: {} 下载成功", fileName); return bytes; } catch (Exception e) { log.error("上传文件失败,原因: {}", e.getMessage(), e); throw new RuntimeException(e); } } }
service.FileService
javapackage com.example.service; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.multipart.MultipartFile; public interface FileService { String createBucket(String bucketName); String uploadSingleFile(MultipartFile file, String bucketName); void downloadSingleFile(String bucketName, String fileName, HttpServletResponse response); }
service.impl.FileServiceImpl
javapackage com.example.service.impl; import com.example.service.FileService; import com.example.utils.RustFSUtil; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @Slf4j @Service public class FileServiceImpl implements FileService { @Autowired private RustFSUtil rustFSUtil; @Override public String createBucket(String bucketName) { try { rustFSUtil.createBucket(bucketName); return "Bucket: " + bucketName + " 创建成功"; } catch (Exception e) { return e.getMessage(); } } @Override public String uploadSingleFile(MultipartFile file, String bucketName) { return rustFSUtil.uploadSingleFile(file, bucketName); } @Override public void downloadSingleFile(String bucketName, String fileName, HttpServletResponse response) { byte[] bytes = rustFSUtil.downloadSingleFile(bucketName, fileName); response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=" + fileName); response.setContentLength(bytes.length); try (ServletOutputStream outputStream = response.getOutputStream()) { outputStream.write(bytes); outputStream.flush(); } catch (IOException e) { log.error("下载文件失败,原因: {}", e.getMessage(), e); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); throw new RuntimeException(e); } } }
controller.FileController
javapackage com.example.controller; import com.example.service.FileService; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/file") public class FileController { @Autowired private FileService fileService; /** * 创建 Bucket * @param bucketName Bucket 名称 * @return 创建结果 */ @PostMapping("/createBucket") public String createBucket(String bucketName) { return fileService.createBucket(bucketName); } /** * 上传单个文件 * * @param file 要上传的文件 * @param bucketName Bucket 名称 * @return 文件的 URL */ @PostMapping("/uploadSingleFile") public String uploadSingleFile(MultipartFile file, String bucketName) { return fileService.uploadSingleFile(file, bucketName); } /** * 下载单个文件 * * @param bucketName Bucket 名称 * @param fileName 文件名称 * @param response HttpServletResponse 对象 */ @GetMapping("/downloadSingleFile") public void downloadSingleFile(String bucketName, String fileName, HttpServletResponse response) { fileService.downloadSingleFile(bucketName, fileName, response); } }
使用 ApiFox 进行接口测试:
- 创建桶



- 上传文件


- 下载文件

都没问题,相当顺利。
2.4 预签名上传、预签名下载
上面的文件上传和下载功能,在生产环境不是很优雅。
因为本质上,上传是先把文件上传到后端,后端再把文件上传到 RustFS。下载则是先把文件从 RustFS 下载到后端,然后响应给前端。
文件在中间中转了两次,后端服务则相当于一个中转站,但实际上这样有点多此一举了。如果是大文件上传的话,还会长时间占用后端线程。
预签名上传和预签名下载,本质上是后端通过 RustFS,给前端用户一个临时的上传/下载凭证,让前端用户通过这个凭证,直接将文件上传到 RustFS ,或者直接从 RustFS 将文件下载下来。
如果像我的博客一样,前后端分开两个服务器部署,担心这么做会直接暴露 RustFS 的话,就可以在前端服务器,使用 Nginx 做反向代理,让 Nignx 帮你转发。
首先,需要注册预签名所需的 Bean,在 config.RustFSConfig 中,添加如下代码
java@Bean public S3Presigner s3Presigner() { return S3Presigner.builder() .endpointOverride(URI.create(rustFSProperties.getEndpoint())) .region(Region.US_EAST_1) .credentialsProvider( StaticCredentialsProvider.create( AwsBasicCredentials.create( // 写 accessKey 和 secretKey rustFSProperties.getAccessKey(), rustFSProperties.getSecretKey() ) ) ) .serviceConfiguration( S3Configuration.builder() // 启用 pathStyleAccessEnabled .pathStyleAccessEnabled(true) .build() ) .build(); }
注意
serviceConfiguration这个配置,pathStyleAccessEnabled一定要启用,官网没说这个。如果不启用,预签名的 url 就会使用虚拟主机风格,就是http://[bucket_name].localhost:9000这种,用这个上传和下载文件会失败!我花了三个小时才排查出来的(泪)。如果启用了路径风格,也就是 path Style,就不会了。
然后创建一个专门用于处理预签名的工具类 utils.RustFSPresignedUtil ,然后添加如下代码
javapackage com.example.utils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.time.Duration; @Component @Slf4j public class RustFSPresignedUtil { @Autowired private S3Presigner s3Presigner; /** * 预签名上传文件 * * @param fileName 要上传的文件 * @param bucketName Bucket 名称 * @return 预签名的 URL */ public String uploadSingleFileByPresignedUrl(String fileName, String bucketName) { try { PresignedPutObjectRequest presignedPutObjectRequest = s3Presigner.presignPutObject( PutObjectPresignRequest.builder() .putObjectRequest( PutObjectRequest.builder() .bucket(bucketName) .key(fileName) // 移除强制Content-Type,让客户端自由设置 .build() ) // 十分钟有效时间 .signatureDuration(Duration.ofMinutes(10)) .build() ); return presignedPutObjectRequest.url().toString(); } catch (Exception e) { log.error("上传预签名获取失败,原因: {}", e.getMessage(), e); throw new RuntimeException("上传预签名获取失败,原因: " + e.getMessage()); } } /** * 预签名下载文件 * * @param bucketName Bucket 名称 * @param fileName 文件名称 * @return 预签名的 URL */ public String downloadSingleFileByPresignedUrl(String bucketName, String fileName) { try { PresignedGetObjectRequest presignedGetObjectRequest = s3Presigner.presignGetObject( GetObjectPresignRequest.builder() .getObjectRequest( GetObjectRequest.builder() .bucket(bucketName) .key(fileName) .build() ) // 十分钟有效时间 .signatureDuration(Duration.ofMinutes(10)) .build() ); return presignedGetObjectRequest.url().toString(); } catch (Exception e) { log.error("下载预签名获取失败,原因: {}", e.getMessage(), e); throw new RuntimeException("下载预签名获取失败,原因: " + e.getMessage()); } } }
添加接口方法:
FileService
javapackage com.example.service; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.multipart.MultipartFile; public interface FileService { String createBucket(String bucketName); String uploadSingleFile(MultipartFile file, String bucketName); void downloadSingleFile(String bucketName, String fileName, HttpServletResponse response); String uploadSingleFileByPresignedUrl(String fileName, String bucketName); String downloadSingleFileByPresignedUrl(String bucketName, String fileName); }
FileServiceImpl
java@Autowired private RustFSPresignedUtil rustFSPresignedUtil; @Override public String uploadSingleFileByPresignedUrl(String fileName, String bucketName) { return rustFSPresignedUtil.uploadSingleFileByPresignedUrl(fileName, bucketName); } @Override public String downloadSingleFileByPresignedUrl(String bucketName, String fileName) { return rustFSPresignedUtil.downloadSingleFileByPresignedUrl(bucketName, fileName); }
FileController
java@Autowired private FileService fileService; /** * 获取上传文件的预签名 URL * * @param fileName 要上传的文件 * @param bucketName Bucket 名称 * @return 预签名 URL */@GetMapping("/uploadSingleFileByPresignedUrl") public String uploadSingleFileByPresignedUrl(String fileName, String bucketName) { return fileService.uploadSingleFileByPresignedUrl(fileName, bucketName); } /** * 获取下载文件的预签名 URL * * @param bucketName Bucket 名称 * @param fileName 文件名称 * @return 预签名 URL */@GetMapping("/downloadSingleFileByPresignedUrl") public String downloadSingleFileByPresignedUrl(String bucketName, String fileName) { return fileService.downloadSingleFileByPresignedUrl(bucketName, fileName); }
可以启动项目测试一下,先是获取上传文件的预签名 url:

使用 ApiFox 来上传:

注意使用 put 方法,并且文件要使用 binary 来传
上传成功

获取下载文件的预签名 url

用 get 方法直接请求该 url ,也可以直接用浏览器访问,访问成功。

3 从 MinIO 迁移至 RustFS
可以参考 RustFS 官方微信公众号的教程: # 如何从MinIO迁移至RustFS? 手把手教程
喔吼,没想到 MinIO Client(mc)这么万能,居然还能跨服务迁移哈哈。那这样数据迁移就很简单了,因为我之前发布过使用 mc 进行数据迁移的教程,这边就再复习一次吧。非常简单。 MinIO-Windows系统下Docker高版本与本机低版本的安装、数据迁移记录
在官网下载 mc.exe ,也就是 MinIO Client 工具。 MinIO下载和安装 | 用于创建高性能对象存储的代码和下载内容
随便放到一个目录下,然后在该目录打开 CMD,按照以下格式输入命令:
shellmc alias set <别名1> < MinIO 的 ip:端口> <Access Key> <Secret Key> mc alias set <别名2> < RustFS 的 ip:端口> <Access Key> <Secret Key> mc cp --recursive <(源)别名1>/<桶名>/ <(目标)别名2>/<桶名>/
注意桶名后面要加斜杠 /,不然可能会出现迁移后路径不对的问题。

我这里是将我服务器的数据,迁移到了本地。
这样就迁移完成了,我这里就不展示了。因为我迁移完就把本地的这个 RustFS 容器删了,只留了数据卷。
4 总结
- 了解了 RustFS 是什么。
- 学会了 RustFS 的安装与部署。
- 学会了如何在 SpringBoot 中操作 RustFS。
- 学会了在 SpringBoot 中通过预签名实现 RustFS 的文件上传和下载。
- 复习了对象存储服务的数据迁移操作。
