目录

先看架构图

基于资源角色鉴权

对象存储预授权链接

写在后面


在部署上云服务中,做存算分离是云原生实践架构的基本门槛。

存指的是数据的存储,云原生架构中根据结构和读写需求的不同,数据会放在数据库或对象存储中,和计算资源分离。

在虚拟机里直接跑数据库+文件服务,这不叫云原生实践架构。

关于为什么使用对象存储而不是其他,什么情况下应该放对象存储还是放数据库,不是本文要讲的点,如果有兴趣可以阅读下云原生王四条

但很多业务场景下需要的是私有读写,所以本文就来详细分享一下如何处理对象存储私有读写,以及附带着分享一些基于资源角色做授权处理的经验。

先看架构图

我们先假设个人相册应用场景,需要在客户端判定身份后自由读写相册文件内容,并且其他用户不能访问。

这个假设场景用到的就是存储的私有读写。

对象存储在云上是一个独立的服务,读存文件操作均通过 API 来完成。

在各种实践中主流的有两种做法:

  • 第一种:直接上传文件给业务服务器,身份鉴权后业务服务器转存到对象存储;

  • 第二种:业务服务器做完身份鉴权后,下发一个预授权链接给客户端,客户端通过该授权链接直接上传文件到对象存储。

文件读取也是同理的两种方式。

第二种方式的文件上传和下载均与对象存储通信,业务服务器只负责鉴权和授权分发,因此不会占用其带宽和 IO 吞吐(文件转存)。网络方面,大部分云厂商的对象存储服务上传流量不收费,下载流量收费(但相比服务器流量费要便宜),并且上传和下载带宽可以达到10-20Gps级别(不同公有云不同地域规格不同),可以满足业务应用需求。

第一种方式非常占用业务服务器资源,最要命的点是网络。如果服务器按带宽计费,用户上传下载文件的大小和数量如果太大,就容易达到瓶颈,影响服务效率。而如果是流量计费(设置高的带宽阈值),除了国字头的云之外,每 GB 流量费用也比对象存储高。

在类似个人网盘的应用场景下,显然第二种直接跟对象存储通信的方式最好。

并不是所有场景都适合直接与对象存储通信,请根据自身业务选择合适的方式。

相关架构图如下:

 

基于资源角色鉴权

首先我想先补充一下资源角色鉴权部分国内云关于对象存储 API 的文档或实践中,经常用「授予特定权限的云账户」下发 AK 密钥,并将这些密钥硬编码到应用程序里或者以配置变量的形式来读取。

大家可以移步看下:https://www.alibabacloud.com/help/zh/oss/use-cases/node-js

https://cloud.tencent.com/document/product/436/35153

https://support.huaweicloud.com/intl/zh-cn/bestpractice-obs/obs_05_1203.html

目前访问是这样的指引:

建议用户使用子账号密钥 + 环境变量的方式调用 SDK,提高 SDK 使用的安全性。为子账号授权时,请遵循 最小权限指引原则,防止泄露目标存储桶或对象之外的资源。

如果您一定要使用永久密钥,建议遵循 最小权限指引原则 对永久密钥的权限范围进行限制。

虽然大部分文档中备注了 「有风险」 这种提示,但是没有给用户展示其他的方法,这就导致很多刚入门的用户被迫实践,如果其没有接触过安全的指导,坏习惯就形成了,会在实施时潜意识认为这就是合格的。

另外最让我感到疑惑的是,腾讯云里的各个云产品几乎没有与其他云产品结合的指引,完全就把自身当成一个独立的服务来教育用户。

我上面提到的最基本的私有读写,需要在服务器做预签名处理。当服务器实例中配置绑定相关角色后,所有应用程序都可以获取临时密钥,我们就可以基于此密钥做对象存储的预签名。

你问我怎么知道能在服务器中直接获取密钥这个事?我是从 AWS 看到的,然后举一反三出来的。

这种典型的服务器产品+对象存储产品的实践,我一开始愣是没从腾讯云的文档中学到在翻阅时没找到这类指引。

大家也可以阅读下 AWS 关于所有 API 鉴权的指引文档:

https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html

文档里面关于为计算资源分配 IAM 角色,在计算资源中获取密钥部分,我以最基础的服务器为例,找到的国内云厂商对应获取文档如下:

腾讯云:查看实例元数据

https://cloud.tencent.com/document/product/213/4934

阿里云:通过元数据从 ECS 实例内部获取实例属性等信息

https://help.aliyun.com/zh/ecs/user-guide/view-instance-metadata

华为云:元数据获取

https://support.huaweicloud.com/intl/zh-cn/usermanual-ecs/ecs_03_0166.html

可能有些人对资源分配角色这个比较陌生,没听过。我这里简单解释一下:

资源角色指的就是给账户内的云资源授予某种角色,使其具备访问其他云资源的权限。

比如你在开通CDN服务时,控制台会要求你授予CDN服务一些权限策略,当你授予后,CDN服务背后的运管程序就能以你授予的策略通过临时密钥去调用你账号上其他云产品 API,从而实现CDN运行时的功能。


 

在本文的案例中,我们使用云服务器来做上传下载的预签名,我们希望云服务器可以有权操作对象存储某个存储桶文件读写

因此我们就可以在访问管理中创建一个角色,并授予角色对应的对象存储桶访问策略。然后对云服务实例绑定这个角色。

腾讯云内实现上述过程的terraform文件如下:

provider "tencentcloud" {
  region     = "ap-guangzhou"
}

# 创建COS存储桶
resource "tencentcloud_cos_bucket" "bucket" {
  bucket = "test"
  acl    = "private"
}

# 创建策略
resource "tencentcloud_cam_policy" "policy" {
  name     = "cos_rw_policy"
  document = <<EOF
  {
    "version": "2.0",
    "statement": [
      {
        "effect": "allow",
        "action": [
          "name/cos:GetObject",
          "name/cos:PutObject",
          "name/cos:DeleteObject"
        ],
        "resource": ["${tencentcloud_cos_bucket.bucket.id}/*"]
      }
    ]
  }
  EOF
}

# 创建角色并绑定策略
resource "tencentcloud_cam_role" "role" {
  name          = "cos_rw_role"
  document      = <<EOF
  {
    "version": "2.0",
    "statement": [
      {
        "action": "sts:AssumeRole",
        "effect": "allow",
        "principal": {
          "service": ["cvm.tencentcloudapi.com"]
        }
      }
    ]
  }
  EOF
  description   = "role for cvm to access cos"
  console_login = true
}

resource "tencentcloud_cam_role_policy_attachment" "attachment" {
  role_id  = tencentcloud_cam_role.role.id
  policy_id = tencentcloud_cam_policy.policy.id
}

# 创建服务器并绑定角色
# 此部分只展示关键的角色授予,其他的内容请按照自己的情况配置
resource "tencentcloud_instance" "instance" {
  cam_role_name              = tencentcloud_cam_role.role.name
}

接下来在云服务器中运行的应用程序,可以通过访问实例元数据地址,来获取绑定角色的临时密钥

腾讯云服务器中获取cos_rw_role角色的临时访问密钥,NodeJS 代码如下:

async function getMetaInfo(key){
    try{
        const response = await fetch(`http://metadata.tencentyun.com/latest/meta-data/${key}`)
        try{
            return await response.json()
        } catch(e){
            return await response.text()
        }
    } catch(e){
        return {
            Code: "Error",
            Data: e.toString()
        }
    }
}
const credential = await getMetaInfo(`cam/security-credentials/cos_rw_role`)
console.log(credential)

对象存储预授权链接

我们解决了访问密钥问题,接下来我们就可以遵循各家的云文档来进一步实现了。

阿里云关于 OSS 直传的文档:

https://www.alibabacloud.com/help/zh/oss/use-cases/uploading-objects-to-oss-directly-from-clients

腾讯云关于预签名文档:

https://cloud.tencent.com/document/product/436/35153

华为云使用预签名直传 OBS 文档:

https://support.huaweicloud.com/intl/zh-cn/bestpractice-obs/obs_05_1203.html

直接使用 SDK,并将密钥初始化部分替换成资源角色的临时密钥,很容易就能实现整个过程。

在这里我给出在腾讯云上,不使用 SDK 实现获取预授权链接:

在腾讯云服务器中,创建 index.js 文件,代码如下

const crypto = require('crypto')
const globalInfo = {}
const config = {
    cam: 'CVM-N', // 需要替换,绑定服务器的角色
    bucket: 'test', // 需要替换,目标对象存储桶,不包含后面的appid
    cosurl: null, // 如果对象存储桶配置自定义域名,可以填
    region: 'ap-guangzhou' // 对象存储桶所在地域
}

async function getGlobalInfo(){
    if(globalInfo.appid==null){
        globalInfo.appid = await getMetaInfo('app-id')
    }
    if(globalInfo.secretId==null|| globalInfo.expiredTime-30<(new Date().getTime()/1000)){
        const credential = await getMetaInfo(`cam/security-credentials/${config.cam}`)
        if(typeof credential === 'object' && credential.Code==='Success'){
            globalInfo.token = credential.Token
            globalInfo.secretId = credential.TmpSecretId
            globalInfo.secretKey = credential.TmpSecretKey
            globalInfo.expiredTime = credential.ExpiredTime
        }
    }
    return globalInfo
}

async function getMetaInfo(key){
    try{
        const response = await fetch(`http://metadata.tencentyun.com/latest/meta-data/${key}`)
        try{
            return await response.json()
        } catch(e){
            return await response.text()
        }
    } catch(e){
        return {
            Code: "Error",
            Data: e.toString()
        }
    }
}

async function getCredential(options){
    const params = {
        Action: 'GetFederationToken',
        Version: '2018-08-13',
        Name: 'cos-sts',
        Region: options.region ?? 'ap-beijing',
        SecretId: options.secretId,
        Token: options.token,
        Timestamp:  parseInt(+new Date() / 1000),
        Nonce: Math.round((1 + Math.random()) * 10000),
        DurationSeconds: options.durationSeconds ?? 1800,
        Policy: encodeURIComponent(JSON.stringify(options.policy))
    };
    params.Signature =  crypto.createHmac('sha1', options.secretKey).update(Buffer.from('POSTsts.tencentcloudapi.com/?' + Object.keys(params).sort().map(function(item) { return item + '=' + (params[item] || '')}).join('&'), 'utf8')).digest('base64')
    return new Promise((resolve)=>{
        fetch('https://sts.tencentcloudapi.com', {
            headers: {
                Host: 'sts.tencentcloudapi.com',
                'Content-Type': 'application/x-www-form-urlencoded'
            },
            method: 'POST',
            body: new URLSearchParams(params).toString()
        }).then(response => {
            response.json().then(data=>{
                const info = data.Response
                resolve({
                    StartTime: info.ExpiredTime - (options.durationSeconds ?? 1800),
                    ...info
                })
            }).catch(()=>{
                resolve({
                    Code: "Fail"
                })
            })
        }).catch(error => {
            resolve({
                Code: "Error",
                Data: error.toString()
            });
        })
    })
}

async function getuploadUrl(path='test.json', durationSeconds = 60) {
    const { bucket, region, cosurl } = config
    const { appid, secretId, secretKey, token } = await getGlobalInfo()
    const { StartTime, ExpiredTime, Credentials, Expiration } = await getCredential({
        secretId: secretId,
        secretKey: secretKey,
        token: token,
        durationSeconds: durationSeconds,
        region: region,
        policy: {
            version: '2.0',
            statement: [{
                action: [
                    'name/cos:PostObject'
                ],
                effect: 'allow',
                principal: { qcs: ['*'] },
                resource: [
                    `qcs::cos:${region}:uid/${appid}:prefix//${appid}/${bucket}/${path}`
                ]
            }]
        }
    })
    const { TmpSecretKey, TmpSecretId, Token } = Credentials
    const keytime = `${StartTime};${ExpiredTime}`
    const policy = `{"expiration": "${Expiration}","conditions": [{ "q-sign-algorithm": "sha1" },{ "q-ak": "${TmpSecretId}" },{ "q-sign-time": "${keytime}" }]}`
    const keysign = crypto.createHmac('sha1', TmpSecretKey).update(keytime).digest('hex')
    const tosign = crypto.createHash('sha1').update(policy).digest('hex')
    const signature = crypto.createHmac('sha1', keysign).update(tosign).digest('hex')
    return JSON.stringify({
        url: cosurl||`https://${bucket}-${appid}.cos.${region}.myqcloud.com`,
        form: {
            key: path,
            policy: Buffer.from(policy, 'utf-8').toString('base64'),
            'q-sign-algorithm': 'sha1',
            'q-ak': TmpSecretId,
            'q-key-time': keytime,
            'q-signature': signature,
            'x-cos-security-token': Token,
            'success_action_status': 200,
            'Content-Type': ''
        }
    })
}

getuploadUrl().then(console.log)
// getuploadUrl(‘/asset/test.json’).then(console.log)

服务器中运行下列命令:

# 安装nodejs步骤
apt-get update
curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash -
apt-get install nodejs -y
# 运行index.js文件
node index.js

然后我们创建一个 index.html 文件,写入如下代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件上传例子</title>
</head>
<body>
    <div style="display: flex; flex-direction: column; align-items: center;">
        <input type="file" id="fileInput" style="margin: 50px 0;">
        <textarea id="infoInput" style="width: 400px;height: 200px;"></textarea>
        <button onclick="handleUpload()" style="margin: 50px 0;">上传文件</button>
        <div id="log"></div>
    </div>
    <script>
        function handleUpload() {
            const fileInput = document.getElementById('fileInput');
            const infoInput = document.getElementById('infoInput');
            const log =  document.getElementById('log');
            try{
                const file = fileInput.files[0];
                const uploadInfo = JSON.parse(infoInput.value);
                log.innerText = '上传中....'
                uploadFile(uploadInfo, file, console.log).then(response => {
                    if (response === 0) {
                        log.innerText = '文件上传成功!'
                    } else {
                        log.innerText = '文件上传失败!可能是授权已过期'
                    }
                });
            } catch(e){
                log.innerText = '无法上传,需要填写基本的信息!'
            }
        }
        function uploadFile(info, file, onUploadProgress = () => { }) {
            return new Promise(function (resolve, reject) {
                const xhr = new window.XMLHttpRequest()
                xhr.withCredentials = true
                xhr.onreadystatechange = function () {
                    if (this.readyState === 4) {
                        if (this.status === 200) {
                            resolve(0)
                        } else {
                            resolve(-1)
                        }
                    }
                }
                xhr.upload.onprogress = onUploadProgress
                xhr.open('POST', info.url)
                const data = new window.FormData()
                for (const i in info.form) {
                    data.append(i, info.form[i])
                }
                data.append('file', file)
                xhr.send(data)
            })
        }
    </script>
</body>
</html>

 

开启本地 live 服务器,访问 html 文件,选择文件并填入内容

需要注意在对象存储桶中需要设置CORS加本地域。

具体实现细节可以阅读代码,为了演示现写的,代码比较草率还望理解~

我们可以在控制台看到上传的文件内容:

 

下载对象的签名原理类似,大家可以自己按照文档指引封装方法。

写在后面

在写这篇文章之前,我详细翻阅了阿里、腾讯、华为三个云关于对象存储预签名链接的文档,有的注重架构展示,有的在代码的花样上下功夫,也只就阿里算是合格的。

当你的应用架构变得复杂时,有些实现会成为阻碍,就比如我文中说的密钥传入部分就曾经因为文档指引的糊里糊涂,对我自己的架构演进造成影响,后面在不断学习中才掌握了这部分经验。

国内云对于中小用户教育属实不是很友好,有些文档的杂乱指引会对用户带来很大的困扰,甚至起到负面效果,我曾经也深受其害。

可能有些大V会嘲笑我,小用户没资格用云(用的不是云)。你没有接触过企业的IT技术培训,这些基础的东西好意思拿出来说,你就是一个草根用户在这自嗨。

我承认我是一个小用户,我趟过了很多云的坑,有一些自己的感受。我接触到的也都是这种小用户,我只希望他们在面对同样的文档和指导环境下,不要重复我走过的坑。至于高水平的大V,很抱歉浪费你时间了~

我计划不断探索和整理一些有意义且可复制的案例,以帮助更多的小型用户全面理解云服务对自己的价值。既然各大云服务商对此类案例不够重视,那我们就自己来填补这一空白。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐