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

对个人博客而言阿里云的 OSS 对象存储服务非常适合存放图片、视频、JS 和 CSS 之类的静态资源,尤其非备案的海外站点,把资源托管在国内云上是在无法使用 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 的加速初衷已经背道而驰。

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

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

函数计算

按照以上思路,需要搭建 HTTP 服务执行 OSS 的权限变更操作,基于 Serverless 的 Function 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 事件。阿里云支持的自定义运行时则更通用化,只是转发请求,能使用流行的 Spring、Ktor 等框架。HTTP 触发器收到请求后会在指定配置的实例中启动用户上传的程序,把请求转发到实例的 80 端口,收到响应即判断任务完成,然后终止程序。

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

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

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

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

arrow_upward