咱們近一兩年來天天在各類公衆號、前端大會上聽見這個詞。一聽到什麼serverless,什麼微服務,總感受本身像是一個外星科技的觀光客,外星人的東西再好,咱們單位沒有這種基礎設施我該怎麼用呢?我一個小小的切圖仔,難道還要我造一堆serverless的設施出來?html
既然地球沒有,那咱們只能去外星偷學一波技能,正所謂「師夷長技」,沒準哪一天地球招人的時候也須要有serverless開發經驗的工程師呢。前端
晦澀的外星語言我就不在這裏多作贅述了,相信你們對什麼BaaS, FaaS這種詞都看了不下數十遍了,用地球人的話來講就是:有了serverless,你們在寫應用時不再用去關心什麼服務器、後端運維了,咱們只須要專一在寫業務邏輯代碼就好了。下面再重述一下幾條老生常談的優點node
聽到這可能有些人就會想了,serverless這麼厲害,是否是後端和運維同事都得下崗了呢?這個問題能夠說是見仁見智的,我能夠表達一個我的的想法:在大規模使用serverless架構的前提下,對於有serverless基礎設施的公司來講,serverless能將後端從業務中解放出來,更加明確地劃分不一樣工程師的職責;而對於依賴於他人serverless服務的公司來講,JS全棧工程師就已經足以勝任全部業務開發的職責了。git
在真正體驗以前,先來對比一下現有的幾家Serverless服務商github
100萬
次請求以及400000GB-秒
的計算時間$0.00001667 / GB-秒
Azure Functions - 微軟於2016年推出了他們的serverless解決方案Azure Functions。web
100萬
次請求以及400000GB-秒
的計算時間$0.000016 / GB-秒
Google Cloud Functions - 谷歌於2017年推出的解決方案,早期落後於亞馬遜與微軟,可是在近年來修復了很多問題,有迎頭遇上的趨勢。數據庫
200萬
次請求以及400000GB-秒
的計算時間$0.0000004 / GB-秒
(額外徵收內存
與CPU
的費用)100萬
次請求以及400000GB-秒
的計算時間$0.000017 / GB-秒
100萬
次請求以及400000GB-秒
的計算時間$0.00001617 / GB-秒
(按當前匯率 1 : 6.87計算)從價格
的角度來看,較爲貴的一家是谷歌,儘管提供了200萬
次的免費請求額度,谷歌對於內存
與CPU
的額外收費會顯著提升使用他家服務的開支。另外四家的價格都是較爲接近的,其中以微軟最低,IBM最高,亞馬遜和阿里雲處於中游。npm
從實用
的角度來看,亞馬遜和微軟的服務仍然以完善的設施(觸發器種類多、支持語言多...) 和豐富的社區支持在多數評測中佔據了上風,而谷歌與剛嶄露頭角的IBM、阿里雲依然是處於跟跑的狀態。這五家中Lambda能夠說是體驗serverless的最佳選擇了。編程
咱們選擇serverless框架來快速建立、部署Lambda服務。json
這裏的serverless指的是一個在GitHub上超過3萬星的一個cli工具。經過serverless cli,咱們能夠快速生成Lambda服務模版,標準化、工程化服務的開發以及一鍵部署服務至多套的環境與節點,極大地縮短了服務開發至上線的時間。
若是你是一個更加信賴純淨的AWS設施的人,願意跟隨着原汁原味的AWS Lambda開發文檔來開發的話,那也是極好的。只是可能個人服務今天就上線了,你的要等到後天。
接下來的兩個小時,咱們要在Lambda上部署一套常見的用戶服務,包含如下四個接口
/api/user/signup
- 建立一個新用戶並錄入數據庫/api/user/login
- 登入並返回JSON Web Token來讓用戶訪問私有接口/api/public
- 公共接口,無須登入的用戶也可訪問/api/private
- 私有接口,只有登入後的用戶才能訪問npm install -g serverless
設置AWS Credentials
咱們選擇的語言是JS,數據庫是DynamoDb,從serverless的示例庫中很快能夠找到這樣的模版aws-node-rest-api-with-dynamodb
複製模版至本地做爲起步工程
serverless install -u https://github.com/serverless/examples/tree/master/aws-node-rest-api-with-dynamodb
複製代碼
這個工程包含了一個Todo列表的CRUD操做服務。核心文件有:
serverless.yml
定義了該服務所提供的Lambda函數
、觸發函數的觸發器
以及運行該函數所須要的其餘AWS資源
。
package.json
定義了該服務所依賴的其餘庫。
todos
目錄下包含了全部的函數文件。咱們能夠看到函數都是較爲直白的,每個文件都是相似如下的結構:
const AWS = require('aws-sdk'); // 引入AWS SDK
const dynamoDb = new AWS.DynamoDB.DocumentClient(); // 創建dynamoDb實例
// 經過 module.exports 來導出該函數
module.exports.create = (event, context, callback) => {
const data = JSON.parse(event.body); // 解析event來得到請求數據
/* 業務邏輯 */
callback(); // 用callback來返回響應數據
}
複製代碼
當咱們運行npm install
與serverless deploy
將該起步工程部署到雲端後,就能夠經過Api地址(例:xxxxxx.execute-api.us-east-1.amazonaws.com/dev/todos)來運行和訪問這些函數。
根據這個工程原有的函數 ,建立相似的函數文件並不是難事,咱們在工程中建立如下4個文件:
但僅僅建立函數文件是不夠的,咱們須要同時在serverless.yml
中爲這幾個函數添加定義。以signup
函數爲例,在functions
中添加如下內容:
signup:
handler: user/signup.signup #定義了函數文件的路徑
events:
- http: #定義了函數觸發器種類爲http (AWS API Gateway)
path: api/user/signup #定義了請求路徑
method: post #定義了請求method種類
cors: true #開啓跨域
複製代碼
這樣咱們就完整地定義了4個函數。接下來咱們來看這四個函數具體的實現方法。
GET
返回一條無須登錄便可訪問的信息
public
函數是4個函數中最爲簡易的一個,由於該函數是徹底公開的,咱們不須要對該函數作任何校驗。以下,簡單地返回一條信息即可:
// user/public.js
module.exports.public = (event, context, callback) => {
const response = {
statusCode: 200,
body: JSON.stringify({
message: '任何人均可以閱讀此條信息。'
})
}
return callback(null, response);
};
複製代碼
注:
callback
第一個參數傳入的爲錯誤,第二個參數傳入的爲響應數據。
public
函數# 部署單個函數
serverless deploy function -f public
複製代碼
或# 部署全部函數
serverless deploy
複製代碼
serverless deploy
後的log中或在AWS API Gateway控制檯
- 階段(stage)
中找到)curl -X GET https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/public
複製代碼
{
"message": "任何人均可以閱讀此條信息。"
}
複製代碼
POST
建立一個新用戶並錄入數據庫,返回成功或失敗信息
signup
函數的運行須要DynamoDB
這一資源,因此第一步咱們須要在serverless.yml
文件中對resources
進行以下修改
# serverless.yml
resources:
Resources:
UserDynamoDbTable:
Type: 'AWS::DynamoDB::Table' #資源種類爲DynamoDB表
DeletionPolicy: Retain #當刪除CloudFormation Stack(serverless remove)時保留該表
Properties:
AttributeDefinitions: #定義表的屬性
-
AttributeName: username #屬性名
AttributeType: S #屬性類型爲字符串
KeySchema: #描述表的主鍵
-
AttributeName: username #鍵對應的屬性名
KeyType: HASH #鍵類型爲哈希
ProvisionedThroughput: #表的預置吞吐量
ReadCapacityUnits: 1 #讀取量爲1單元
WriteCapacityUnits: 1 #寫入量爲1單元
#用serverless變量來定義表名,表名爲環境變量中的定義的DYNAMODB_TABLE
TableName: ${self:provider.environment.DYNAMODB_TABLE}
複製代碼
resources
一欄中填寫的內容是使用yaml
語法寫的AWS CloudFormation的模版。
DynamoDB表在CloudFormation中更爲詳細定義文檔請參考連接。
signup
是一個方法爲POST
的接口,所以須要從觸發事件的body
中獲取請求數據。
// user/signup.js
module.exports.signup = (event, context, callback) => {
// 獲取請求數據並解析JSON字符串
const data = JSON.parse(event.body);
const { username, password } = data;
/* ... 校驗 username 與 passowrd */
}
複製代碼
獲取完了請求數據後,咱們須要構造出新用戶的數據,並把數據錄入DynamoDB
// user/signup.js
// 引入nodejs加密庫
const crypto = require('crypto');
// 建立dynamoDB實例
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports.signup = (event, context, callback) => {
// ...獲取並校驗 username 與 password
// 生成新用戶的數據
// 生成salt來確保哈希後密碼的惟一性
const salt = crypto.randomBytes(16).toString('hex');
// 用sha512哈希函數加密,生成僅可單向驗證的哈希密碼
const hashedPassword = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512').toString('hex');
const timestamp = new Date().getTime(); // 生成當前時間戳
const params = {
TableName: process.env.DYNAMODB_TABLE, // 從環境變量中獲取DynamoDB表名
Item: {
username, // 用戶名
salt, // 保存salt用於登錄時單向校驗密碼
password: hashedPassword, // 哈希密碼
createdAt: timestamp, // 生成時間
updatedAt: timestamp // 更新時間
}
}
// 錄入至dynamoDb
dynamoDb.put(params, (error) => {
// 返回失敗信息
if (error) {
// log錯誤信息,可在AWS CloudWatch服務中查看
console.error(error);
callback(null, {
statusCode: 500,
body: JSON.stringify({
message: '建立用戶失敗!'
})
});
} else {
// 返回成功信息
callback(null, {
statusCode: 200,
body: JSON.stringify({
message: '建立用戶成功!'
})
});
}
}
複製代碼
DynamoDB在Nodejs中更詳細的CRUD操做文檔請參考連接
signup
函數# 部署單個函數
serverless deploy function -f signup
複製代碼
或# 部署全部函數
serverless deploy
複製代碼
cURL
工具執行如下命令來發送請求(替換成你的API地址,API地址可在運行serverless deploy
後的log中或在AWS API Gateway控制檯
- 階段(stage)
中找到)curl -X POST https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/user/signup --data '{ "username": "new_user", "password": "12345678" }'
複製代碼
{
"message": "success"
}
複製代碼
GET
校驗用戶名密碼並返回JSON Web Token來讓登錄用戶訪問私有接口
在用戶調用了Login接口並經過驗證後,咱們須要爲用戶返回一個JSON Web Token
,以供用戶來調用須要權限的服務。設置JSON Web Token須要如下幾步操做:
npm install jsonwebtoken --save
安裝jsonwebtoken庫並添加至項目依賴secret.json
文件來存放密鑰,這裏咱們採用對稱加密的方式來定義一個私有密鑰。// secret.json
{
"secret": "私有密鑰"
}
複製代碼
serverless.yml
的provider
下做以下變動# serverless.yml
provider:
environment:
# 使用serverless變量語法將文件中的密鑰賦值給環境變量PRIVATE_KEY
PRIVATE_KEY: ${file(./secret.json):secret}
複製代碼
login
是一個方法爲GET
的接口,所以須要從觸發事件的queryStringParameters
中獲取請求數據。
// user/login.js
module.exports.login = (event, context, callback) => {
// 獲取請求數據
const { username, password } = event.queryStringParameters;
/* ... 校驗 username 與 passowrd */
}
複製代碼
// user/login.js
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports.login = (event, context, callback) => {
// ...獲取並校驗 username 與 password
// 驗證帳號密碼並返回JSON Web Token
// 構造DynamoDB請求數據,根據主鍵username獲取數據
const params = {
TableName: process.env.DYNAMODB_TABLE,
Key: {
username
}
};
// 從DynamoDB中獲取數據
dynamoDb.get(params, (error, data) => {
if (error) {
// log錯誤信息,可在AWS CloudWatch服務中查看
console.error(error);
// 返回錯誤信息
callback(null, {
statusCode: 500,
body: JSON.stringify({
message: '登錄失敗!'
})
});
return;
}
// 從回調參數中獲取DynamoDB返回的用戶數據
const user = data.Item;
if (
// 確認username存在
user &&
// 確認哈希密碼匹配
user.password === crypto.pbkdf2Sync(password, user.salt, 10000, 512, 'sha512').toString('hex')
) {
// 返回登錄成功信息
const response = {
statusCode: 200,
body: JSON.stringify({
username, // 返回username
// 返回JSON Web Token
token: jwt.sign(
{
username // 嵌入username數據
},
process.env.PRIVATE_KEY // 使用私有密鑰簽發token
)
})
};
callback(null, response);
} else {
// 返回錯誤信息
callback(null, {
statusCode: 401,
body: JSON.stringify({
message: '用戶名或密碼錯誤!'
})
});
}
});
};
複製代碼
login
函數# 部署單個函數
serverless deploy function -f login
複製代碼
或# 部署全部函數
serverless deploy
複製代碼
serverless deploy
後的log中或在AWS API Gateway控制檯
- 階段(stage)
中找到)curl -X GET 'https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/user/login?username=new_user&password=12345678'
複製代碼
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5ld191c2VyIiwiaWF0IjoxNTYxODI1NTgyfQ.Iv0ulooGayulxf_MkkpBO1xEw1gilThT62ysuz-rQE0",
"username": "new_user"
}
複製代碼
校驗請求中所包含的JSON Web Token是否有效
在編寫private
函數以前,咱們須要提供另外一個函數auth
來校驗用戶提交請求中的JSON Web Token是否與咱們所簽發的一致。
user/auth.js
文件serverless.yml
的functions
中添加如下內容:auth:
handler: user/auth.auth
# auth是一個僅會在服務內被調用的函數,所以沒有任何觸發器
複製代碼
爲了可以讓AWS API Gateway
觸發器正確地識別函數有無權限執行,咱們必須在auth
函數中返回一個含IAM ( AWS 服務與權限管控系統) 權限策略信息的響應數據,來使得有權限的函數能夠經過AWS API Gateway
成功觸發。在user/auth.js
內定義一個以下的方法:
// user/auth.js
const generatePolicy = (principalId, effect, resource) => {
const authResponse = {};
authResponse.principalId = principalId; // 用於標記用戶身份信息
if (effect && resource) {
const policyDocument = {};
policyDocument.Version = '2012-10-17'; // 定義版本信息
policyDocument.Statement = [];
const statementOne = {};
statementOne.Action = 'execute-api:Invoke'; // 定義操做類型,這裏爲API調用操做
statementOne.Effect = effect; // 可用值爲ALLOW或DENY,用於指定該策略所產生的結果是容許仍是拒絕
statementOne.Resource = resource; // 傳入ARN(AWS資源名)來指定操做所須要的資源
policyDocument.Statement[0] = statementOne;
authResponse.policyDocument = policyDocument; // 將定義完成的策略加入響應數據
}
return authResponse;
};
複製代碼
關於IAM策略更爲詳細的配置文檔請查看連接
咱們在解析JSON Web Token時默認請求遵循OAuth2.0中的Bearer Token格式。
// user/auth.js
const jwt = require('jsonwebtoken');
/* ...定義generatePolicy方法 */
module.exports.auth = (event, context, callback) => {
// 獲取請求頭中的Authorization
const { authorizationToken } = event;
if (authorizationToken) {
// 解析Authorization
const split = event.authorizationToken.split(' ');
if (split[0] === 'Bearer' && split.length === 2) {
try {
const token = split[1];
// 使用私有密鑰校驗JSON Web Token
const decoded = jwt.verify(token, process.env.PRIVATE_KEY);
// 使用generatePolicy生成包含容許API調用的IAM權限策略的響應數據
const response = generatePolicy(decoded.username, 'Allow', event.methodArn);
return callback(null, response);
} catch (error) {
// JSON Web Token 校驗失敗,返回錯誤
return callback('Unauthorized');
}
} else {
// Authorization 格式校驗失敗,返回錯誤
return callback('Unauthorized');
}
} else {
// 請求頭未含Authorzation,返回錯誤
return callback('Unauthorized');
}
};
複製代碼
GET
返回一條須要登錄纔可訪問的信息
private
函數的實現與以前的public
函數十分相似,惟一的區別就是咱們須要在函數的http (AWS API Gateway)
觸發器中加入剛剛定義的auth
做爲權限校驗函數。
在serverless.yml
中對先前定義的private
函數做以下變動:
# serverless.yml
functions:
private:
handler: user/private.private
events:
- http:
path: api/private
method: get
authorizer: auth #設置authorizer爲auth函數
cors: true
複製代碼
// user/private.js
module.exports.private = (event, context, callback) => {
// 從觸發事件中獲取請求的用戶信息
const username = event.requestContext.authorizer.principalId;
// 返回消息
const response = {
statusCode: 200,
body: JSON.stringify({
message: `你好,${username}!只有登錄後的用戶才能夠閱讀此條信息。`
})
}
return callback(null, response);
};
複製代碼
private
函數# 部署單個函數
serverless deploy function -f private
複製代碼
或# 部署全部函數
serverless deploy
複製代碼
serverless deploy
後的log中或在AWS API Gateway控制檯
- 階段(stage)
中找到)curl -X GET -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5ld191c2VyIiwiaWF0IjoxNTYxODI1NTgyfQ.Iv0ulooGayulxf_MkkpBO1xEw1gilThT62ysuz-rQE0" https://xxxxxx.execute-api.us-west-2.amazonaws.com/dev/api/private
複製代碼
{
"message": "你好,new_user!只有登錄後的用戶才能夠閱讀此條信息。"
}
複製代碼
serverless install -u https://github.com/yuanfux/aws-lambda-user
cd aws-lambda-user
npm install
serverless deploy
咱們看到了AWS Lambda的實用、便捷與低價,但咱們可否看到隱藏在其背後的一些問題。
代碼維護
函數式的開發模式註定了代碼之間的複用與共享會成爲一個難題,也註定了代碼量會隨着服務的增多而膨脹。serverless會使得函數數量與代碼量呈線性增加的關係,以下圖
當服務達到必定數量後,維護一個無限膨脹的代碼庫所須要的額外人力與開支也是不可小視的。
冷啓動
冷啓動也是一個老生常談的話題了,用簡單的話來講就是當你的函數一段時間未被運行後,系統就會回收運行你函數的容器資源。這樣帶來負面影響就是,當下一次調用這個函數時,就須要從新配置一個容器來運行你的函數,結果就是函數的調用會被延遲。來看一下函數調用時間間隔與冷啓動機率的關係:
那麼具體的延遲時間是多少呢?延遲時間受許多因素的影響,好比你選擇的編程語言、函數的內存配置、函數的文件大小等等。可是一個較爲廣泛的建議就是 Lambda 不適合用做對延遲極其敏感的服務( < 50ms)。
本地開發
運行Lambda函數依賴於許多外部的庫和應用(aws-sdk, API Gateway、DynamoDB...),所以想要在一個徹底本地的環境運行這樣的函數是十分困難的。若是咱們每次修改函數後都須要部署並依賴於 AWS CloudWatch 中輸出的運行日誌來調試與開發 Lambda 函數,那想必效率是極低的。幸虧serverless cli
提供了必定的插件來支持基本的本地開發(serverless-offline、serverless-dynamodb-local...)。但不用serverless cli
開發Lambda的用戶可能就須要研讀AWS-SAM文檔並走過一段更爲漫長的配置過程了。
遷移
經過開發AWS Lambda你可能已經發現了,咱們所寫的代碼與AWS這個雲服務商是具備強關聯性的。儘管有serverless cli
這種通用的serverless應用框架來幫助咱們抹平不一樣服務商之間的代碼差別,想從一個服務商遷移至另外一個服務商依然是一件繁重的體力勞動,甚至包含着大量的代碼重構。用serverless就像抽菸,可能一開始你享受到了煙的美妙,以爲抽幾根無所謂,想戒時定然能戒。但隨着你越陷越深,你會發現戒菸是一個至關痛苦的過程。
計價方式
AWS Lambda收費的最小單位是100ms,也就是意味着你的函數哪怕只執行了1ms也會看成100ms來計費。這種計費方式甚至會致使使用高內存的函數甚至比低內存的要便宜!咱們來看下AWS Lambda的計費表:
舉一個較爲極端的例子:假設咱們設置了一個內存爲448MB的函數,它運行時間爲101ms,那麼每次執行咱們都須要支付0.000000729 x 2 = $0.000001458。而若是咱們將這個函數的內存提升到512MB,使它的運行時間下降100ms之內,那麼每次執行咱們只須要支付$0.000000834。僅僅是一個設置,咱們就下降了整整(1458 - 834) / 1458 ≈ 42.8% 的成本!
找到性價比最高的內存設置意味着額外的工做量,很難想象AWS在這個問題上竟然沒有爲客戶提供一個合理的解決方案。
捆綁消費
在使用AWS Lambda的時幾乎全部的周邊服務(API Gateway、 CloudWatch、DynamoDB...)都是須要額外收費的。其中一個很明顯的特徵就是捆綁消費,你可能很難想象 CloudWatch 是在使用 Lambda 時被強制使用的一個服務;而 API Gateway 也是在搭建http服務時幾乎沒法逃過一個收費站,其$3.5/百萬次請求的高額價格甚至遠遠高於使用 Lambda 的價格。
成長型吸血鬼
小明剛剛遷移了他每月花費$5搭建在某VPS商的我的博客到AWS Lambda上,他發現全部的服務都沒有超過AWS的免費線,Lambda爲小明每月省下了$5。對於像小明這樣的小流量、小內存服務來講,Lambda的的確確會省下一筆至關可觀的成本;但對於佔用大流量、大內存的服務來講,Lambda的按調用量收費反而會在無形之間累加出一筆高額費用。使用Lambda就像在住酒店,而租服務器則像租房。酒店設施齊全且便捷,偶爾住幾天可能比租房還便宜,但天天每夜住咱也住不起。
雖然Lambda有以上值得權衡的問題,但它所帶來對於開發效率的提升是前所未有的,它所帶來對於服務開發及運維層面的成本削減也是肉眼可見的。大家可能不知道學了serverless的前端是什麼概念,咱們通常只會用兩個字來形容這種人:全能!我常常說一句話,今天學serverless,明天就能開公司。今天看了這篇文章若是你還不能在Lambda上寫serverless服務,我當場就把這個電腦屏幕吃掉。