使用 AWS lambda 做免錢的 Cloudflare cache preload

0元用 SBC 架設自己的 WordPress 網站 #3 速度優化 中提到使用 Cloudflare 做 cache,並安裝 WP Cloudflare Super Page Cache 外掛,讓他在發布新文章的時候自動做 preload。

但這個套件是透過 WordPress 主機所在位址對 Cloudflare 做 curl,因此 cache 並沒有發佈到全世界,於是我想到利用 AWS lambda 來實現。以下就來看看如何操作!

Cache Preload 原理

它的原理其實很簡單,比如說我今天新增了一個 A.html,Cloudflare 的 server 上肯定沒有這個檔案,所以當第一個使用者做 GET A.html 的時候,就會發生 cache miss,使他必須等比較久才能下載完成。

使用 AWS lambda 做免錢的 Cloudflare cache preload

但之後的使用者再次 GET A.html 時,因為已經有 cache,速度就會大幅提升!

使用 AWS lambda 做免錢的 Cloudflare cache preload

preload 的原理就是我們自己當作「第一個使用者」,先去對 A.html 做 GET,這樣真實使用者就能直接享用 cache 了!

使用 AWS lambda 做免錢的 Cloudflare cache preload

只是 Cloudflare 在世界各地都有 server,因此 cache 也會有地區性。如果第一個使用者在美國,那 A.html 的 cache 只會存在美國的 server,而日本 server 依然沒有這個 cache。因此我們需要在世界各地發出一樣的 GET A.html,這樣就能把 cache 推到全世界大部分的 Cloudflare server!

Preload script 原理

其實 Cloudflare 有 prefetch 的功能,但是要付費!節省的我當然是把目光轉向每個月有免費扣打的 AWS lambda!這邊我們使用 nodejs 為例

  1. 讀取 sitemap.xml
    以 WordPress 為例,只要安裝了 Yoast SEO,他就會幫你產生 sitemap.xml。透過 script 去抓 sitemap.xml 並解析出所有網址。
const https = require('https')
const xml2js = require('xml2js')

const hostname = '<your hostname>'
const domain = `https://${hostname}`
// Yoast SEO 會再依照類型去區分成不同的子 sitemap,因此這邊我們給定要抓的 sitemap 檔名
const sitemapPost = '/post-sitemap.xml'
const sitemapPage = '/page-sitemap.xml'
const sitemapCategory = '/category-sitemap.xml'
const sitemapPaths = [sitemapPost, sitemapPage, sitemapCategory]

// 給定偽裝成 browser 的 header
const headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36' }


// 從 sitemap 中抓 url
async function parseSitemap() {
    // 一次灑出所有 sitemap 的 request
	let getBodyTasks = sitemapPaths.map(path => getPageBody(path))
	let results = await Promise.all(getBodyTasks)
   
    // 對所有 sitemap 並行解析 xml
	let parseTasks = results.map(r => xml2js.parseStringPromise(r))
	let parseResults = await Promise.all(parseTasks)
	console.log('parse sitemap finish')
   
    // 從 xml 中抓出所有 url,因為 https 套件只需要給定不同的 path,所以這邊同時把 url 中的 domain 去除
	let paths = []
	for (let i in parseResults) {
		for (let j in parseResults[i].urlset.url) {
			paths.push(parseResults[i].urlset.url[j].loc[0].replace(domain, ''))
		}
	}
	return paths
}

function getPageBody(path) {
	return new Promise(resolve => {
		let options = {
			hostname: hostname,
			path: path + '?preview=true',
			headers: headers
		}
		console.log(`start get ${domain}${path}?preview=true`)

		https.get(options, (res) => {
			let body = ''
			res.on('data', data => {
				body += data
			})

			res.on('end', () => {
				resolve(body)
			})
		});
	})
}
  1. 發出 request
    偽裝成 browser 對全部網址發出 GET request 。
// 傳入 paths 灑出所有 request,如果怕一次丟太多 request,可以先把原來的 paths 切成每次丟二十個進來跑
async function startPreload(paths) {
	console.log('preload start')
	await Promise.all(paths.map(p => startRequest(p)))
	console.log('finish')
}

function startRequest(path) {
	return new Promise(resolve => {
		var options = {
			hostname: hostname,
			path: path,
			headers: headers
		}

		var t = new Date();
		https.get(options, (res) => {
			let headers = res.headers

			res.on('data', data => { })

			res.on('end', () => {
				let totalTime = (new Date() - t);
				console.log(`prefetch finish => cache status: ${headers['cf-cache-status']}, age: ${headers['age']}, response time: ${totalTime}ms, url: https://${hostname}${path}`)
				resolve(true)
			})
		});
	})
}
  1. 部署世界各地
    把 lambda 部署到 AWS 各個 AZ,最後透過 API gateway 暴露出接口統一呼叫即可。
使用 AWS lambda 做免錢的 Cloudflare cache preload
依照不同區域分開不同的 api 入口

為了錢錢著想,記得到 API gateway 的 Resource Policy 設定 ip 限制,避免別人亂呼叫浪費扣打哦!可參考官方部落格 How do I use a resource policy to allow certain IP addresses to access my API Gateway REST API?

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:<AZ>:<your_account_id>:<api_gateway_id>/*/*/*"
        },
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:<AZ>:<your_account_id>:<api_gateway_id>/*/*/*",
            "Condition": {
                "NotIpAddress": {
                    "aws:SourceIp": [
                        "xxx.xxx.xxx.xxx/32",
                        "xxx.xxx.xxx.xxx/32"
                    ]
                }
            }
        }
    ]
}

趕快試試看吧!

延伸閱讀:
0元用 SBC 架設自己的 WordPress 網站 #3 速度優化
使用 AWS Parameter Store 實現 Config 和 Credentials 外置

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。