
RustFS学习、SpringBoot 集成与MinIO迁移
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 文件,然后填入以下配置
version: "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> 标签下添加下面几个配置:
<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,然后将内容修改
package 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 文件,添加一些无关紧要的简单配置
server:
port: 9220 # 端口命名随意
spring:
application:
name: rustfs-demo-application # 应用名也随意
大功告成了。
现在,参照官网的 SDK 来简单集成 RustFS。 Java SDK | RustFS 文档中心
首先在 pom.xml 文件中,在 <dependencies> 中添加依赖
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.40.8</version>
</dependency>
为了方便配置的管理,我们将项目需要的配置,写在 application.yml 中,然后通过 ConfigurationProperties + EnableConfigurationProperties 这两个注解读取配置,并创建 S3 客户端,将其注册为 Bean。
首先在 application.yml 中,添加如下配置
rustfs:
endpoint: http://localhost:9000
access-key: rustfsadmin
secret-key: rustfsadmin
在和 RustFSDemoApplication 同级目录下,创建 model.properties.RustFSProperties 类(model 和 properties 是包名,点击新建 Java 类的时候直接复制上面这个,IDEA 会自动创建包)。然后写入以下内容
package 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 ,写入以下内容
package 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
package 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
package 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
package 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
package 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 中,添加如下代码
@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 ,然后添加如下代码
package 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
package 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
@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
@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,按照以下格式输入命令:
mc 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 的文件上传和下载。
- 复习了对象存储服务的数据迁移操作。
0 人赞过
评论
评论加载中…
