一. Minio S3介绍
Minio 是一个基于Apache License v2.0开源协议的对象存储服务。它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等,而一个对象文件可以是任意大小,从几kb到最大5T不等。
Minio是一个非常轻量的服务,可以很简单的和其他应用的结合,类似 NodeJS, Redis 或者 MySQL。
大致分为三个部分:
1.Minio 服务器: 服务器支持多种方式的部署,如:docker, 分布式,多租户部署,支持多种网关配置:如Azure网关,GGS网关,NAS网关;支持超大容量。
2.Minio 客户端: Minio Client (mc)为ls,cat,cp,mirror,diff,find等UNIX命令提供了一种替代方案。它支持文件系统和兼容Amazon S3的云存储服务(AWS Signature v2和v4)。
支持多种格式的下载安装:docker方式,macOS的Homebrew,二进制文件
具体的服务器配置和客户端的安装可以参考:https://www.bookstack.cn/read/MinioCookbookZH/17.md
3.Minio SDKs
SDK提供简单的API来访问任何Amazon S3兼容的对象存储服务;目前已有的SDKs实现有Javascript Client, Java Client,Python Client, Golang Client以及.NET Client
具体的SDK提供的api文档参考:https://www.bookstack.cn/read/MinioCookbookZH/19.md
二.项目背景
之所以会使用Minio S3存储服务,是为了优化项目中的业务场景:镜像上传;对于一个镜像文件来说,大小从几M到几十G,上百G不等。
痛点:在最初的项目中采用的存储服务中,上传采用文件分片的方式,将一个大的文件按指定的分片大小分片分次的上传,以减少接口接收对象的压力;分片过程中存在:1.上传过程慢,2.文件上传的存储路径较长;3.接口压力较大的问题;对于用户来说等待时间过久
是非常不好的体验。因此才决定通过改进上传过程,来优化该镜像上传业务。
Minio S3优点:采用mino S3的的存储服务,通过Javascript Client提供的api去做镜像文件的上传;
该方案具有如下优点:1.js client直接通过api与存储服务联通,减少接口需要二次接收大文件传参的压力;
2. Minio的上传速度快,自带上传优化,比如续传,分片等;
3.文件导出/下载也是直接链接minio 服务就可以实现下载,下载实现简单,快捷;
因此查看了api文档和前期的技术预研之后就决定了使用Minio S3来替换目前的镜像上传方案,撸起袖子开干!具体的实施过程如下:
三.项目实施
第一步:约定文件存储方式:最初约定:承接之前的文件存储规范,继续使用uuid来做文件标识,用来约束文件的唯一性;后来发现使用uuid之后,在业务上体验不太好,比如场景:用户上传一个文件A.iso,上传之后存在储存服务器上文件变成了xxxxxx.iso,这对于服务去管理这些上传后的文件时不太直观。而Minio S3使用的是文件名 + bucket的方法做的对象存储。SDK提供的api都是基于这两者做的操作,因此后来调整为:直接使用文件名的方式,存放在一个特定的Bucket的目录下;
第二步:建立Minio S3的服务端,同时查看Javascript Client提供的示例(提供了一个公共的Minio S3的服务器以及简单配置),捣鼓了一下,使用示例的minio S3完成了文件上传和下载,上传和下载的具体api调用如下:
/**
* 创建客户端实例:
* endPoint: Minio Service服务端的地址
* port: 端口
* useSSL: 是否开启SSL,开启后连接以HTTPS的方式访问,默认是开启的
* ak: 接入授权公钥
* sk: 接入授权私钥
*/
var Minio = require('minio') var minioClient = new Minio.Client({ endPoint: 'play.minio.io', port: 9000, useSSL: true, accessKey: 'Q3AM3UQ867SPQQA43P2F', secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG' });
// 第2步:生成上传连接:选用的方案是:使用Presigned操作,对每一个文件生成一个临时的,有过期时间的可用连接,在使用完之后,连接会在过期时间后自动失效,因此合理的设置该值,就可以在上传完之后,不用去手动处理连接的清理问题。
// 代码中创建了一个Minio Client的封装对象,用来生成下载,上传连接
// 使用Presigned的presignedPutObject方法来生成文件的上传链接
import { Client } from 'minio'; /** * 保存一个客户端,用于调用 * @param config * @constructor */ const defaultConfig = { endPoint: 'play.minio.io', port: 9000, useSSL: true, accessKey: 'Q3AM3UQ867SPQQA43P2F', secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', bucketName: 'upload' }; const MinioClient = function (config) { if (!config) { throw new Error('config is necessary'); } if (!config.endPoint) { throw new Error('endPoint is necessary'); } if (!config.accessKey) { throw new Error('accesss is necessary'); } if (!config.secretKey) { throw new Error('secretKey is necessary'); } if (!config.bucketName) { throw new Error('bucketName is necessary'); } config = config || defaultConfig; this.expiry = 24 * 60 * 60; this.bucketName = config.bucketName; this.config = config; this.client = new Client(config); }; /** * 获取文件下载地址,以供下载使用 * @param fileName * @returns {*} */ MinioClient.prototype.getDownloadUrl = function (fileName) { const bucketName = this.bucketName; const expiry = this.expiry; return this.client.presignedGetObject(bucketName, fileName, expiry); }; /** * 获取文件上传地址,用于文件上传 * @param file */ MinioClient.prototype.getUploadUrl = function (file) { const { name } = file; const bucketName = this.bucketName; const expiry = this.expiry; return this.client.presignedPutObject(bucketName, name, expiry); }; /** * 检查同名文件: 获取文件,若存在,则返回false,否则返回true * @param fileName * @param callback */ MinioClient.prototype.checkSameFile = function (fileName, callback) { const bucketName = this.bucketName; this.client.getObject(bucketName, fileName, function (err) { // eslint-disable-next-line standard/no-callback-literal callback && callback(!err); }); }; export default MinioClient;
// 第3步: 上传实现
// 创建一个封装工具来操作该client实例: 因为MinioClient使用了presignedPutObject来生成的链接,因此这里使用axios.put来请求该链接,并把需要上传的文件传递上去,这样就实现了一个Minio S3的文件上传方案
import { Message } from 'hui';
import MinioClient from '@/libs/minioClient';
import axios from 'axios';
const CancelToken = axios.CancelToken;
const FileHelper = function () {
this.minioClient = null;
this.cancelSource = CancelToken.source();
this.onProgress = null;
this.onSuccess = null;
this.onError = null;
this.fileSize = 0;
};
/**
* 初始化
* @param options
*/
FileHelper.prototype.init = function (options) {
if (!options) {
throw new Error('options is necessary');
}
const { minioConfig, onProgress, onSuccess, onError } = options;
if (!minioConfig) {
throw new Error('minio Config is necessary');
}
this.minioClient = new MinioClient(minioConfig);
this.onProgress = onProgress;
this.onSuccess = onSuccess;
this.onError = onError;
};
FileHelper.prototype.upload = async function (file) {
if (!this.minioClient) {
throw new Error('must be init MinioOption before use');
}
try {
const self = this;
const url = await this.minioClient.getUploadUrl(file);
const config = {
headers: { 'Content-Type': 'multipart/form-data' },
cancelToken: this.cancelSource.token,
onUploadProgress: function (progressEvent) {
const { loaded, total } = progressEvent;
const progress = (100 * loaded / total).toFixed(2);
self.onProgress && self.onProgress(progress);
}
};
const formData = new FormData();
formData.append('file', file);
axios.put(url, formData, config).then(res => {
self.onSuccess && self.onSuccess(res);
});
} catch (e) {
self.onError && self.onError(e);
console.log('error', e);
}
};
FileHelper.prototype.download = async function (fileName) {
try {
const url = await this.minioClient.getDownloadUrl(fileName);
const fileLink = document.createElement('a');
fileLink.setAttribute('href', url);
fileLink.setAttribute('download', fileName);
fileLink.style.visibility = 'hidden';
document.body.appendChild(fileLink);
fileLink.click();
document.body.removeChild(fileLink);
} catch (e) {
Message({ type: 'error', message: e });
}
};
FileHelper.prototype.stop = function () {
this.cancelSource.cancel('中止上传');
this.onSuccess = null;
this.onError = null;
this.onProgress = null;
this.minioClient = null;
};
export default FileHelper;
到目前为止,使用Minio S3提供的公共的Minio Service已经能正常的上传和下载文件,在项目中得到的验证;看起来很完美;但是在真实的项目应用中,却碰到了各种各样的坑了;
第三步:使用实际项目的Minio Service联调功能: 开始使用项目实际使用的Minio Service: xxxx.xxx.xx。
问题一: 项目使用的Minio Service是http协议的(处于某些原因,以及未来可能扩展的需要),而web使用的https协议:直接使用就会报错:
Mixed Content: The page at 'xxx/xxx' was loaded over HTTPS,......This content should also be served over HTTPS.
https协议的站点发送的请求同样需要是https的,无法直接请求http;经沟通,项目的Minio Service无法变更为https。
解决方法:
使用nginx代理来转发https的请求到http的Minio Service上去;后经过调整,协议的问题解决,但是出现另外的问题:
问题二:js 客户端使用的presignedPutObject生成的是put方法,项目那边nginx无法代理过去,接口405 Method Not Allowed,调整nginx后仍然无法过去(具体原因待查)........这里镜像上传的业务是存在跨域请求的,接口的发起来源与实际需要的响应来源是不同的
端口项目实际已经是经过nginx做了代理的。例如: 来源xxxx:446 to xxx:447;使用前缀标识来做了代理转发:xxx/aaa:446 to xxx:447
解决方法:
发现发送post请求是可以正常发送的,因此考虑使用post方法来做上传连接的获取,但是Minio Service并不直接提供Post方式的presigned操作。后来查阅api文档,发现有一个presignedPostPolicy这样的一个api,允许给POST请求的presigned URL设置条件策略。比如接收上传的存储桶名称、名称前缀、过期策略。查阅实例应用后,接口先设置postPolicy: 传递以下参数:
var policy = minioClient.newPostPolicy()
// 设置bucket桶名
policy.setBucket('theme')
// 设置key值,这里就是需要上传的文件名
policy.setKey('r4.ico')
// 设置过期使劲按
policy.setExpires()
// 设置允许上传的文件大小
policy.setContentLengthRange(1024, 1024*1024*1024)
// 获取post方式下url策略
minioClient.presignedPostPolicy(policy, function (error, data) {})
// 这里的data返回的是针对该key的一个认证信息,在上传文件时,需要一并的放入formData中,按key一一对应,返回的内容如下
// 这里主要返回:bucket,key,policy,x-amz-algorithm,x-amz-credential,x-amz-data,x-amz-signature
// 上传请求
在presignedPostPolicy回调中执行文件上传, 这里的文件对象已经获取到了,之前policy.setKey(fileName)的时候就是在获取file之后做的,这里url是Minio 服务器上文件存储的资源uri,比如存储在theme桶下,那么url为/theme/r4.ico
function(error, data){
const formData = new FormData()
formData.append('file', file)
Object.keys(data).forEach(key => {
formData.append(key, data[key])
})
const url = '/theme/r4.icon'
axios.post(url, formData, callback)
}
// 这样就能完成了Post方式下的文件上传
问题三:sk,ak直接硬编码或是存在前端项目中是不安全的。
解决方法:
把刚才上面的过程放在服务器上去做,而且这里有个天然的优势就是 presignedPostPolicy本身就是返回了认证信息的,因此无需要前端传递sk,ak这些;利用对应的后端语言的client来完成之前使用javascript Client做的事情即可;因此项目直接请求后端给的一个正常
的api地址来获取policy认证信息,然后由前端传递带有file文件对象的formData对象给接口即可,因此这里的传递过程如下:
// 已获取file对象,这里input type=file 的change事件中就可以获取 // 这里是伪代码,这是核心代码 // 1. 获取postPolicy信息的后端接口:getFilePostPolicy, policy中需要的contentLength, expireDate都有后端接口读取配置获取,根据实际业务场景指定一定的大小即可; const file = e.target.files[0] const { data } = await getFilePostPolicy(fileName) // 然后生成formData,调用后端的上传接口,传递file对象过去 axios.post(url, formData, callback)
// 这里使用到后端的url的原因在于 之前问题一,解决https 向 http协议的Minio Service请求的问题,这是构造特定的url前缀,通过nginx做代理,转发到Minio Service上去。
大致的ngxin配置项为:
这里的aaa为后端url前缀,可以看到代理路径为http的地址
location /aaa {
proxy_http_version: 1.1,
proxy_request_buffering: off,
proxy_pass: http://minio/image
}
到此,就算是实现了镜像上传这个业务的minio S3的上传改造了。但是在正常的上传业务使用过程中,又发现了一个"深坑",也就是万里长征的最后一步了: 问题四。
问题四:在使用过程中,经常发现比较大的文件在上传到100%进度之后就一直挂载中,始终无法完成上传。
解决方法:
经过不断尝试,发现文件大于2G的时候,就会出现这种情况了,初步讨论应该有什么地方限制了文件大小了。
第一怀疑的是我们的Policy对象中setContentLengthRange,经查阅文档和不断尝试,发现这里没有问题,默认不传递则是不做限制
第二怀疑的就是我们的nginx配置了,尝试了proxy_max_temp_file_size和proxy_max_temp_file_size之后 发现还是不行,后面继续查看文档和资料,最后找到proxy_request_buffering/proxy_buffering
proxy_buffering: off, 表示禁用缓存,响应会在收到响应时立即同步传递给客户端. nginx不会尝试从代理服务器读取整个响应. nginx一次可以从服务器接收的数据的最大大小由proxy_buffer_size指令设置。
proxy_request_buffering: off: 对当前代理禁用缓存:请求主体在收到时立即发送到代理服务器;就是上图的nginx配置中的配置。
至此,完整的上传改造方案落地了........
附注:另外查看git上源码,发现直接使用sk,ak去走web登录的那一套流程,也是可以正确的获取到token,然后利用token来走上传下载的流程;具体可以参考:
https://hub.fastgit.org/minio/minio/tree/RELEASE.2020-10-28T08-16-50Z.hotfix.sets
里面有一个minio 客户端的web应用,其中输入ak,sk就可以做一些上传下载的操作了。