一. 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就可以做一些上傳下載的操作了。

