Categories
程式開發

Serverless: 2020年函數計算的冷啟動怎麼樣了


前言

自從Serverless架構被提出,函數計算這個名詞變得越發的火熱,甚至在很多時候有人會認為Serverless就是函數計算。

作為Serverless架構中的一個重要組成部分,雲函數確實值得,也應該備受關注,無論是吐槽他的調試能力,還是抱怨他的冷啟動,亦或者對他的彈性伸縮表示懷疑,但是我們不得不承認,更多人正在越來越關注Serverless,也越來越關注Serverless中的FaaS部分。

經常看到有人在吐槽函數計算的冷啟動問題,時至今日,不知道各平台的冷啟動是什麼樣子的。本文將會通過相對客觀的數據來進行基本的驗證。

冷啟動驗證

首先說到冷啟動,就要先說明什麼是冷啟動,開發者提交代碼之後你不知道他調不調用,函數第一次調用會有一個函數冷啟動,把網絡的環境全部打通,這個函數才能提供服務。如果沒有優化好冷啟動優化這部分,可能對於一些比較關鍵的產品首次啟動會產生超時,體驗非常不好,以前開發者本地運行函數的時候,並不會關注本地函數執行多少毫秒和微妙,但是在雲函數場景下就不一樣了,雲函數有一個部署的過程;無論是公有云的平台上還是開源方案上,冷啟動都是值得不斷探討話題和優化的方向。

Serverless: 2020年函數計算的冷啟動怎麼樣了 1

在《Serverless: Cold Start War》這篇文章中,作者對AWS Lambda,Azure Function以及Google Cloud Function等三個工業級的Serverless架構產品的冷啟動測試。作者將函數啟動劃分成四個部分:

Serverless: 2020年函數計算的冷啟動怎麼樣了 2

然後作者通過對多種語言的“Hello World”與是否有依賴等進行搭配,進行測試,測試結果:

Serverless: 2020年函數計算的冷啟動怎麼樣了 3

通過《Understanding AWS Lambda Performance—How Much Do Cold Starts Really Matter?》與《Serverless: Cold Start War》這兩個文章的分析和結果,我們可以看到冷啟動問題確實存在,而且不同廠商,不同語言,不同測試方法得到的冷啟動數據都是有所差異的。這也充分說明,各個廠商也在通過一些規則和策略努力降低冷啟動率。除此之外文章《Understanding Serverless Cold Start》、《Everything you need to know about cold starts in AWS Lambda》、《Keeping Functions Warm》、《I’m afraid you’re thinking about AWS Lambda cold starts all wrong》等也均對冷啟動現像等進行描述和深入的探討,並且提出了一些業務側應對函數冷啟動的解決方案和策略。

再說回來,時至今日,主流雲廠商都已經開始開發探索Serverless架構,那麼各個雲廠商的冷啟動已經”熱”到了什麼程度?

由於我是國內的開發者,所以我將測試分為兩部分,一部分是國內云廠商(騰訊雲、阿里雲、華為雲),另一部分是國外雲廠商(AWS、谷歌)。

由於在實際生產過程中,冷啟動的誕生往往是和API網關共同體現,也就是說,實際上讓用戶感知相對明顯,或者比較常見感知到冷啟動出現的情況,通常是函數與網關結合,做了一個接口/服務,訪問該服務的時候,放大/體現了冷啟動。

所以,我的做法很簡單和暴力,通過函數與API網關觸發器的結合,針對不同廠商來創建一個API服務,通過本地機器來對該服務進行訪問,在客戶端判斷其耗時。當然,這種做法誠然不能精確的表現出冷啟動的具體數據,因為網絡因素也將會是影響其準確性的一個重要因素,但是至少可以大概的對比出,不同雲廠商的冷啟動優化情況,以及服務穩定情況。

國內的雲廠商,在測試的時候更多使用的是國內區,國外雲廠商則是國外區,這樣就會出現一個額外的問題,國內外雲廠商的數據不具有對比性,畢竟網絡因素佔了很大的一部分,所以本次對比將會是國內和國內對比,國外和國外對比。

客戶端程序:

import time, json
import urllib.request
import matplotlib.pyplot as plt
import numpy
from multiprocessing import Process, Manager

# 测试地址
# qcloud
url = "https://service-px5f98f4-1256773370.gz.apigw.tencentcs.com/release/scf_demo"
# # huaweicloud
# url = "https://2937587fe6ce4e6eb22d521d1d9b811c.apig.cn-east-2.huaweicloudapis.com/demo"
# # aliyun
# url = "https://50155512.cn-shanghai.fc.aliyuncs.com/2016-08-15/proxy/guide-hello_world/mydemo/"
# # aws
# url = "https://di6vbxf2lk.execute-api.us-east-1.amazonaws.com/default/mydemo"
# # GoogleCloud
# url = "https://us-central1-meta-imagery-277209.cloudfunctions.net/mydemo"

# 此时
times = 200

# 串行处理
serialColdStart = []
serialHotStart = []

for i in range(0,times):
timeStart = time.time()
responseAttr = urllib.request.urlopen(url)
endTime = time.time()

response = json.loads(responseAttr.read().decode("utf-8"))

if response['isNew']:
serialColdStart.append(endTime-timeStart)
else:
serialHotStart.append(endTime-timeStart)

# 并行处理
def worker(url, return_list):
timeStart = time.time()
responseAttr = urllib.request.urlopen(url)
endTime = time.time()
return_list.append({
"duration": endTime-timeStart,
"response": json.loads(responseAttr.read().decode("utf-8"))
})

manager = Manager()
return_list = manager.list()
jobs = []
for i in range(times):
p = Process(target=worker, args=(url ,return_list))
jobs.append(p)
p.start()

for proc in jobs:
proc.join()

parallelColdStart = []
parallelHotStart = []
for eveData in return_list:
if eveData['response']['isNew']:
parallelColdStart.append(eveData['duration'])
else:
parallelHotStart.append(eveData['duration'])

# 数据汇总
print("-"*10, "串行测试", "-"*10)
print("总触发次数:", len(serialColdStart) + len(serialHotStart))
print("冷启动次数:", len(serialColdStart))
print("热启动次数:", len(serialHotStart))
print("最大耗时量:", max(serialColdStart + serialHotStart))
print("最小耗时量:", min(serialColdStart + serialHotStart))
print("平均耗时量:", numpy.mean(serialColdStart + serialHotStart))

print("-"*10, "并行测试", "-"*10)
print("总触发次数:", len(parallelColdStart) + len(parallelHotStart))
print("冷启动次数:", len(parallelColdStart))
print("热启动次数:", len(parallelHotStart))
print("最大耗时量:", max(parallelColdStart + parallelHotStart))
print("最小耗时量:", min(parallelColdStart + parallelHotStart))
print("平均耗时量:", numpy.mean(parallelColdStart + parallelHotStart))

plt.figure(figsize=(15,10))
plt.subplot(4, 2, 1)
plt.title('(Serial) Cold Start Time')
plt.plot(range(0, len(serialColdStart)), serialColdStart)
plt.subplot(4, 2, 3)
plt.title('(Serial) Cold Start Time')
plt.hist(serialColdStart, bins=20)
plt.subplot(4, 2, 5)
plt.title('(Serial) Hot Start Time')
plt.plot(range(0, len(serialHotStart)), serialHotStart)
plt.subplot(4, 2, 7)
plt.title('(Serial) Hot Start Time')
plt.hist(serialHotStart, bins=20)
plt.subplot(4, 2, 2)
plt.title('(Parallel) Hot Start Time')
plt.plot(range(0, len(parallelColdStart)), parallelColdStart)
plt.subplot(4, 2, 4)
plt.title('(Parallel) Cold Start Time')
plt.hist(parallelColdStart, bins=20)
plt.subplot(4, 2, 6)
plt.title('(Parallel) Hot Start Time')
plt.plot(range(0, len(parallelHotStart)), parallelHotStart)
plt.subplot(4, 2, 8)
plt.title('(Parallel) Hot Start Time')
plt.hist(parallelHotStart, bins=20)
plt.show()import time, json
import urllib.request
import matplotlib.pyplot as plt
import numpy
from multiprocessing import Process, Manager

# 测试地址
# qcloud
url = "https://service-px5f98f4-1256773370.gz.apigw.tencentcs.com/release/scf_demo"
# # huaweicloud
# url = "https://2937587fe6ce4e6eb22d521d1d9b811c.apig.cn-east-2.huaweicloudapis.com/demo"
# # aliyun
# url = "https://50155512.cn-shanghai.fc.aliyuncs.com/2016-08-15/proxy/guide-hello_world/mydemo/"
# # aws
# url = "https://di6vbxf2lk.execute-api.us-east-1.amazonaws.com/default/mydemo"
# # GoogleCloud
# url = "https://us-central1-meta-imagery-277209.cloudfunctions.net/mydemo"

# 此时
times = 200

# 串行处理
serialColdStart = []
serialHotStart = []

for i in range(0,times):
timeStart = time.time()
responseAttr = urllib.request.urlopen(url)
endTime = time.time()

response = json.loads(responseAttr.read().decode("utf-8"))

if response['isNew']:
serialColdStart.append(endTime-timeStart)
else:
serialHotStart.append(endTime-timeStart)

# 并行处理
def worker(url, return_list):
timeStart = time.time()
responseAttr = urllib.request.urlopen(url)
endTime = time.time()
return_list.append({
"duration": endTime-timeStart,
"response": json.loads(responseAttr.read().decode("utf-8"))
})

manager = Manager()
return_list = manager.list()
jobs = []
for i in range(times):
p = Process(target=worker, args=(url ,return_list))
jobs.append(p)
p.start()

for proc in jobs:
proc.join()

parallelColdStart = []
parallelHotStart = []
for eveData in return_list:
if eveData['response']['isNew']:
parallelColdStart.append(eveData['duration'])
else:
parallelHotStart.append(eveData['duration'])

# 数据汇总
print("-"*10, "串行测试", "-"*10)
print("总触发次数:", len(serialColdStart) + len(serialHotStart))
print("冷启动次数:", len(serialColdStart))
print("热启动次数:", len(serialHotStart))
print("最大耗时量:", max(serialColdStart + serialHotStart))
print("最小耗时量:", min(serialColdStart + serialHotStart))
print("平均耗时量:", numpy.mean(serialColdStart + serialHotStart))

print("-"*10, "并行测试", "-"*10)
print("总触发次数:", len(parallelColdStart) + len(parallelHotStart))
print("冷启动次数:", len(parallelColdStart))
print("热启动次数:", len(parallelHotStart))
print("最大耗时量:", max(parallelColdStart + parallelHotStart))
print("最小耗时量:", min(parallelColdStart + parallelHotStart))
print("平均耗时量:", numpy.mean(parallelColdStart + parallelHotStart))

plt.figure(figsize=(15,10))
plt.subplot(4, 2, 1)
plt.title('(Serial) Cold Start Time')
plt.plot(range(0, len(serialColdStart)), serialColdStart)
plt.subplot(4, 2, 3)
plt.title('(Serial) Cold Start Time')
plt.hist(serialColdStart, bins=20)
plt.subplot(4, 2, 5)
plt.title('(Serial) Hot Start Time')
plt.plot(range(0, len(serialHotStart)), serialHotStart)
plt.subplot(4, 2, 7)
plt.title('(Serial) Hot Start Time')
plt.hist(serialHotStart, bins=20)
plt.subplot(4, 2, 2)
plt.title('(Parallel) Hot Start Time')
plt.plot(range(0, len(parallelColdStart)), parallelColdStart)
plt.subplot(4, 2, 4)
plt.title('(Parallel) Cold Start Time')
plt.hist(parallelColdStart, bins=20)
plt.subplot(4, 2, 6)
plt.title('(Parallel) Hot Start Time')
plt.plot(range(0, len(parallelHotStart)), parallelHotStart)
plt.subplot(4, 2, 8)
plt.title('(Parallel) Hot Start Time')
plt.hist(parallelHotStart, bins=20)
plt.show()

國內云廠商

騰訊雲

測試代碼:

# -*- coding: utf8 -*-
import json
import time
import uuid

requestId = None
containeId = str(uuid.uuid1())
createTime = time.time()

def main_handler(event, context):
time.sleep(1)
tempId = str(uuid.uuid1())
timeStart = time.time()
global requestId
if not requestId:
requestId = tempId

response = {
"isNew": True,
"oldRequestId": requestId,
"newRequestId": tempId,
"duration": time.time() - timeStart,
"containeId": containeId,
"createTime": createTime
}

response["isNew"] = True if requestId == tempId else False
return response

輸出數據:

---------- 串行测试 ----------
总触发次数: 200
冷启动次数: 8
热启动次数: 192
最大耗时量: 2.3507158756256104
最小耗时量: 1.0568928718566895
平均耗时量: 1.134293702840805
---------- 并行测试 ----------
总触发次数: 186
冷启动次数: 170
热启动次数: 16
最大耗时量: 14.849930047988892
最小耗时量: 1.092796802520752
平均耗时量: 7.125524929774705

Serverless: 2020年函數計算的冷啟動怎麼樣了 4

阿里雲

測試代碼:

# -*- coding: utf8 -*-
import json
import time
import uuid

requestId = None
containeId = str(uuid.uuid1())
createTime = time.time()

class AppClass:
"""Produce the same output, but using a class
"""
def __init__(self, environ, start_response, response):
self.environ = environ
self.start = start_response
self.response = response
def __iter__(self):
status = '200'
response_headers = [('Content-type', 'text/html;charset=utf-8')]
self.start(status, response_headers)
yield self.response.encode("utf-8")

def handler(environ, start_response):
time.sleep(1)
tempId = str(uuid.uuid1())
timeStart = time.time()
global requestId
if not requestId:
requestId = tempId

response = {
"isNew": True,
"oldRequestId": requestId,
"newRequestId": tempId,
"duration": time.time() - timeStart,
"containeId": containeId,
"createTime": createTime
}

response["isNew"] = True if requestId == tempId else False
return AppClass(environ, start_response, json.dumps(response))

輸出數據:

---------- 串行测试 ----------
总触发次数: 200
冷启动次数: 1
热启动次数: 199
最大耗时量: 1.8273499011993408
最小耗时量: 1.1592700481414795
平均耗时量: 1.221251163482666
---------- 并行测试 ----------
总触发次数: 200
冷启动次数: 163
热启动次数: 37
最大耗时量: 3.184391975402832
最小耗时量: 1.1983528137207031
平均耗时量: 2.3849029302597047

Serverless: 2020年函數計算的冷啟動怎麼樣了 5

華為雲

測試代碼:

# -*- coding: utf8 -*-
import json
import time
import uuid
requestId = None
containeId = str(uuid.uuid1())
createTime = time.time()

def handler(event, context):
time.sleep(1)
tempId = str(uuid.uuid1())
timeStart = time.time()
global requestId
if not requestId:
requestId = tempId

response = {
"isNew": True,
"oldRequestId": requestId,
"newRequestId": tempId,
"duration": time.time() - timeStart,
"containeId": containeId,
"createTime": createTime
}

response["isNew"] = True if requestId == tempId else False
return json.dumps({
'statusCode': 200,
'isBase64Encoded': False,
'headers': {
"Content-type": "text/html; charset=utf-8"
},
'body': json.dumps(response),
})

輸出數據:

---------- 串行测试 ----------
总触发次数: 200
冷启动次数: 1
热启动次数: 199
最大耗时量: 2.4535348415374756
最小耗时量: 1.202908992767334
平均耗时量: 1.4574852859973908
---------- 并行测试 ----------
总触发次数: 200
冷启动次数: 72
热启动次数: 128
最大耗时量: 3.8169281482696533
最小耗时量: 1.232532024383545
平均耗时量: 2.3244904506206514

Serverless: 2020年函數計算的冷啟動怎麼樣了 6

國外雲廠商

AWS

測試代碼:

# -*- coding: utf8 -*-
import json
import time
import uuid

requestId = None
containeId = str(uuid.uuid1())
createTime = time.time()

def lambda_handler(event, context):
time.sleep(1)
tempId = str(uuid.uuid1())
timeStart = time.time()
global requestId
if not requestId:
requestId = tempId

response = {
"isNew": True,
"oldRequestId": requestId,
"newRequestId": tempId,
"duration": time.time() - timeStart,
"containeId": containeId,
"createTime": createTime
}

response["isNew"] = True if requestId == tempId else False
return {
'statusCode': 200,
'body': json.dumps(response)
}

輸出數據:

---------- 串行测试 ----------
总触发次数: 200
冷启动次数: 1
热启动次数: 199
最大耗时量: 6.628237009048462
最小耗时量: 1.917238712310791
平均耗时量: 2.1634005284309388
---------- 并行测试 ----------
总触发次数: 200
冷启动次数: 176
热启动次数: 24
最大耗时量: 6.071150779724121
最小耗时量: 1.9705779552459717
平均耗时量: 2.370948977470398

Serverless: 2020年函數計算的冷啟動怎麼樣了 7

Google Cloud

測試代碼:

# -*- coding: utf8 -*-
import json
import time
import uuid

requestId = None
containeId = str(uuid.uuid1())
createTime = time.time()

def main_handler(event):
time.sleep(1)
tempId = str(uuid.uuid1())
timeStart = time.time()
global requestId
if not requestId:
requestId = tempId

response = {
"isNew": True,
"oldRequestId": requestId,
"newRequestId": tempId,
"duration": time.time() - timeStart,
"containeId": containeId,
"createTime": createTime
}

response["isNew"] = True if requestId == tempId else False
return json.dumps(response)

輸出數據:

---------- 串行测试 ----------
总触发次数: 200
冷启动次数: 1
热启动次数: 199
最大耗时量: 4.707853078842163
最小耗时量: 1.226269006729126
平均耗时量: 1.3416448163986205
---------- 并行测试 ----------
总触发次数: 200
冷启动次数: 198
热启动次数: 2
最大耗时量: 7.694962024688721
最小耗时量: 1.296091079711914
平均耗时量: 5.523866602182388

Serverless: 2020年函數計算的冷啟動怎麼樣了 8

測試結果

通過對上面的數據進行基本分析,可以作圖:

Serverless: 2020年函數計算的冷啟動怎麼樣了 9

通過這個表格可以看到,由於我是在國內測試的aws和google cloud,而且還是國外區域,所以網絡因素對其影響蠻大的。拋棄掉這個因素進行分析可以看到,在串行的時候,騰訊雲的冷啟動率比較大,容器復用率相對比較低,串行環境下,國內廠商最小的時間消耗基本一致,平均的時間消耗也基本一致。國外廠商中,由於受到網絡環境的影響,導致數據可供參考的價值不大,但是可以確定的是,無論是AWS還是GoogleCloud的串行測試過程中,容器復用率還是蠻高的。

其實相對對來說,在串行環境下,除了騰訊雲的容器復用率比較低之外,整體來看雲廠商之間區別不大。重點還是在並行測試結果上。

並行測試中,可以看到,全部廠商中,只有騰訊雲出現了失敗次數,失敗報錯是:HTTP Error 504: Gateway Time-out,我為了確定這個不是我的問題,我進行了多次運行,結果騰訊雲每次都會有十幾個失敗的請求,可以基本判斷是API網關/雲函數的不穩定,造成了504的錯誤。除此之外,冷啟動造成的最大耗時,騰訊雲高居榜首,是阿里雲和華為雲的4倍之多,就算包括了測試環境訪問國外網絡因素,騰訊雲的冷啟動最大耗時也是AWS和GoogleCloud的2倍之多,這個結果並不理想。在最小耗時部分,基本上是和串行一致,原因很簡單,因為我的程序中是休眠1S,而冷啟動很多都高於1S,這就導致可能出現了復用情況,通過冷啟動次數也可以確定這個推斷。由於騰訊雲的冷啟動最大耗時實在太大了,導致其平均耗時也比較高。

總結

函數冷啟動問題確實是在項目中常見的一個現象,我做了一個微信公眾號,後台綁定了兩個函數,冷啟動可怕到每次遇到冷啟動,公眾號的後台服務都會被微信判定為”故障,無法提供服務”,在實際項目中,冷啟動不能說是地球毀滅,但是也應該是一場大災難。

我始終認為,一個項目對開發者再友好,提供再多的功能/能力都是建立起來的高樓,如果這個高樓的地基不穩定,那麼這高樓注定坍塌。與其更多的用戶側體驗優化,真不如用心做一下核心能力,否則冷啟動14S,真的可以嚇退開發者。

最後以我的公眾號做結:

Serverless: 2020年函數計算的冷啟動怎麼樣了 10