解决阿里云OSS无CDN时的流量风险

对个人博客而言阿里云的OSS对象存储服务非常适合存放图片、视频、JSCSS之类的静态资源,尤其非备案的海外站点,把资源托管在国内云上是在无法使用CDN的条件下加速访问的几乎唯一解,从我2年的使用体验来看对速度和稳定性都十分满意。

Aliyun OSS overview

OSS并不“廉价”,但普通博客存储的数据不会很多,流量也不会很大,以标准存储+按量计费计算,每GB存储0.12元,每GB流量0.25~0.5元,我的博客每月费用不过1元左右,约等于免费。

非正常

正常情况下费用可以忽略,但却存在一些“非正常”情况费用可能会高的吓人。最近看到一些恶意刷OSS流量的帖子,有人一晚上几个小时被盗刷(下载)几TB流量产生了几千元账单:

很多案例都有一个特点就是阿里云在用户余额不足的情况下并没有及时停止服务,而是随流量的不断增加继续产生账单,一叠加就成百上千元。但是它的计费说明里明明写着欠费后停止服务,为什么还会出现这种情况?

原因是计费周期账单处理时延,阿里云在《降低因恶意访问流量导致大额资金损失的风险》里有提到,虽然OSS会在用户欠费时停止服务但这个“欠费”并非实时。按量付费账单周期是1小时,再加上处理账单的时延,受到攻击时的峰值流量可能1小时后才会生成账单扣费,“余额不足”的判断也要在1小时后才会出现,这个时候即使停止服务在此时间差里消耗的流量也会产生费用,导致余额为负值。比如在7:00~8:00受到攻击,因为账单周期和系统的处理时延可能直到9:00才生成账单,此时已经被攻击2个小时,以普通家庭的千兆带宽被盗刷几TB流量是很轻松的。

解决

有些案例阿里云会减免一部分受攻击的费用,但毕竟它确实向运营商付出了外网流量成本,所以剩余费用还是要用户支付。对使用OSS的小博主,这种巨额流量像一把悬在头上的达摩克利斯之剑,随时落下。

首先Referer防盗链对恶意流量攻击是没用的,伪造Referer非常简单,它只能起到防止被其它站点盗链的作用。此外最直观的解决方法就是流量封顶带宽限制IP请求数限制,但奇怪的是不止阿里云,其它云服务商的对象存储也都不提供这类细粒度控制功能。而它们的CDN内容分发网络却提供,并且流量费用更便宜,大概只有OSS的一半,所以外套一层CDN再配合它细致的流量、带宽控制手段就是应对这种攻击行为最合适的方法。

只不过大陆法规不允许非备案站点使用国内CDN,如果用国外Cloudflare这样无需备案、无限流量的免费CDN在国内访问又会是南辕北辙。本来低延时的OSS请求经CDN到国外绕一圈后,虽然面对流量攻击无需回源不会产生流量费用,但速度必然下降,与使用OSS的加速初衷背道而驰,称其“减速CDN”也不为过。

上面已经确认阿里云并没有给OSS提供细粒度的用量控制功能,但文档中提到可以通过云监控设置对OSS实时运行状态的报警规则。比如连续多少时间段请求数、流量达到多少时触发报警,在通知用户的同时还会触发一个可配置的HTTP请求,配合OSS本身的控制API可以把Bucket权限由公开访问变为Private私有,这样停止OSS对外服务后流量攻击也就随之失效。

是的,这就是解决流量风险的唯一方法,实时监控、触发报警、停止服务…我可以接受临时停服,不接受飞来账单😶。

函数计算

按照以上思路,需要搭建一个HTTP服务来执行OSS的权限变更操作,基于ServerlessFunction Compute函数计算是一个好选择。Serverless即无需购置服务器,只要提供一个匹配HTTP请求的程序,它会在云服务商收到请求时被触发执行,其余时间不消耗计算资源,也不产生费用,是按量付费思路的最优解。

阿里云的函数计算费用非常低,低到什么程度呢,调用价格0.009元/万次,算力价格0.00009元/vCPUx秒,内存价格0.000009元/GBx秒,对这种很少触发的监控需求是等于0的。

一个调用OSS权限变更API的示例:

// Ktor的HTTP server
fun Application.configureRouting() {
    routing {
        // 处理报警触发的POST请求
        post("/oss-shutdown") {
            LogUtil.debug("oss shutdown start...")
            val timeMs = measureTimeMillis {
                // 执行时间可能长达数秒
                shutdownOSS()
            }
            LogUtil.debug("oss shutdown done, time = $timeMs ms")
            call.respondText("response to request: done")
        }
    }
}

private fun shutdownOSS() {
    // OSS的外网接入点,这个是杭州地区的
    val endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
    // RAM用户的访问ID和密钥,用于鉴权
    val accessKeyId = "****************"
    val accessKeySecret = "****************"
    val credentialsProvider: CredentialsProvider = DefaultCredentialProvider(accessKeyId, accessKeySecret)
    // 阿里云提供的控制OSS的SDK
    val ossClient = OSSClientBuilder().build(endpoint, credentialsProvider)
    try {
        // 设置存储Bucket的读写权限为Private私有,终止外部访问
        ossClient.setBucketAcl("bucket name", CannedAccessControlList.Private)
    } catch (e: Exception) {
        LogUtil.error("oss shutdown error, $e.cause")
    } finally {
        ossClient?.shutdown()
    }
}

不同云服务商的函数计算接口不同,很多都需要引入SDK按照规定的方式接收Event事件。阿里云支持的自定义运行时则更通用化,只是转发请求,能使用流行的SpringKtor等框架。其HTTP触发器收到请求后会在指定配置的实例中启动用户上传的程序,直接把请求转发到实例的80端口,收到响应即判断任务完成,然后终止程序。

注意函数计算启动的Ktor,其生命周期和在普通服务器上并不一样,返回HTTP响应后会被立即终止,抛入后台线程的任务不会存活,所以必须在执行完所有操作后才能返回响应。而且函数默认是同步调用,如果超过一定时间不回复报警请求函数计算收到超时反馈也会导致程序在未完成的情况下被终止。

函数计算中与同步调用相对的还有异步调用,阿里云会对Header里标识异步调用请求先返回202,再触发对应程序的后台执行,更适合处理耗时任务。操作OSSJVM程序从冷启动到执行完成需耗时数秒(本例是2秒),为避免报警等待超时,应该定义一个轻量的同步Trigger,让其在触发异步任务后立即返回,这样既能启动耗时操作也不会让报警请求等待过长时间。

创建Trigger是因为报警配置只能指定URL,不能自定义Header,导致不能触发异步调用,所以需要中间人。

做完测试一下,如果成功那把悬在头上的达摩克利斯之剑就可以扔掉了。

arrow_upward