使用 Serverless 實現日誌報警

HangJiang發表於2019-03-25

最近嘗試將應用的頁面 JS 錯誤報警功能通過 Serverless 來實現。本文主要介紹一下具體實現過程,以及遇到的一些問題。

報警功能的需求也很簡單,就是定時(如每隔 1 分鐘)去讀取 ARMS 的錯誤日誌,如果有錯誤日誌,則通過釘釘訊息傳送錯誤詳情進行報警。

在這之前,我通過定時任務實現了該功能。從成本上來說,這種方案就需要單獨申請一臺伺服器資源;而且定時任務只在對應的時間才執行,這件意味著,伺服器有很長的時間都是空閒的,這就造成了資源的浪費。而使用 Serverless,就不需要再申請伺服器,函式只需要在需要的時候執行,這就大大節省了成本。

總的來說,我覺得函式計算的優勢就是:

  • 對於開發者,只需要關係業務邏輯的實現,不需要關心程式碼所執行的環境、硬體資源、以及運維
  • 節省成本

通過 Serverless 實現前端日誌報警,依賴的雲服務是阿里雲函式計算,依賴的其他工具還有:

  • 函式計算的命令列工具 fun,用於本地除錯、部署函式
  • 函式計算的可互動式工具 fcli,用於本地測試
  • 阿里雲 JS SDK aliyun-sdk-js,用於讀取 SLS 日誌,ARMS 的日誌是儲存在 SLS 中的
  • 程式語言使用 Node.js

安裝和配置 fun

初次使用需要先安裝 fun

$ npm install @alicloud/fun -g
複製程式碼

安裝完成之後,需要通過 fun config 配置一下賬號資訊 Aliyun Account ID Aliyun Access Key ID Aliyun Secret Access Key 以及預設的地域。地域這裡有個需要注意的是,如果需要使用 SLS 記錄函式日誌,則需要 SLS 和函式服務在同一個地域。這裡稍後會詳細介紹。

$ fun config
? Aliyun Account ID ******
? Aliyun Access Key ID ******
? Aliyun Secret Access Key ******
? Default region name cn-shanghai
? The timeout in seconds for each SDK client invoking 60
? The maximum number of retries for each SDK client 6
複製程式碼

Aliyun Account ID Aliyun Access Key ID Aliyun Secret Access Key 可以在阿里雲的控制檯中查詢和設定。

Aliyun Account ID

![Aliyun Account ID]

accountid

Aliyun Access Key ID Aliyun Secret Access Key

accesskey

函式初始化

先通過 fun 建立一個 Node.js 的 demo,之後可以在這個 demo 的基礎上進行開發。

$ fun init -n alarm helloworld-nodejs8
Start rendering template...
+ /Users/jh/inbox/func/code/alarm
+ /Users/jh/inbox/func/code/alarm/index.js
+ /Users/jh/inbox/func/code/alarm/template.yml
finish rendering template.
複製程式碼

執行成功後,分別建立了兩個檔案 index.jstemplate.yml

其中 template.yml 是函式的規範文件,在裡面定義了函式需要的資源、觸發函式的事件等等。

template.yml

接下來簡單看看生成的預設的 template.yml 配置檔案。

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  alarm:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld'
    alarm:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs8
        CodeUri: 'index.js'
複製程式碼

首先定義了規範文件的版本 ROSTemplateFormatVersionTransform,這兩個都不用修改。

Resources 裡面定義了一個名為 alarm 的函式服務(Type: Aliyun::Serverless::Service 表示該屬性為函式服務),並且該服務裡面定義了名為 alarm 的函式(Type: 'Aliyun::Serverless::Function'表示該屬性為函式)。

函式服務裡面可以包含多個函式,就相當於是一個函式組。後面我們會提到的函式日誌,是配置到函式服務上的。函式服務裡面的所有函式,都用同一個日誌。

可以根據實際情況修改函式服務名和函式名。下面就將函式服務名稱改為 yunzhi,函式名依舊保留為 alarm

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi: # 函式服務的名稱
    Type: 'Aliyun::Serverless::Service' # 表示 yunzhi 是一個函式服務
    Properties:
      Description: 'helloworld' # 函式服務的描述
    alarm: # 函式的名稱
      Type: 'Aliyun::Serverless::Function' # 表示 alarm 是一個函式
      Properties:
        Handler: index.handler # 函式的呼叫入口
        Runtime: nodejs8 # 函式的執行環境
        CodeUri: 'index.js' # 程式碼的目錄
複製程式碼

alarm 函式裡面的 Properties 定義了函式的呼叫入口、執行環境等,如上面的註釋所示。

關於 template.yml 的配置詳見 Serverless Application Model

index.js

index.js 檔案就是函式的呼叫入口了。index.handler 就表示,函式的呼叫的是 index.[extension] 檔案中的 handler 函式。

module.exports.handler = function(event, context, callback) { 
  console.log('hello world');
  callback(null, 'hello world'); 
};
複製程式碼

初始化之後的程式碼就上面這幾行,很簡單。主要是理解上面的幾個引數。

  • event 呼叫函式時傳入的引數
  • context 函式執行時的一些資訊
  • callback 函式執行之後的回撥
      1. 必須要要呼叫 callback 函式,才會被認為函式執行結束。如果沒有呼叫,則函式會一直執行到超時
      1. callback 呼叫之後,函式就結束了
      1. callback 的第一個引數是 error 物件,這和 JS 回撥程式設計的思想一致

關於 eventcontext,詳見 Nodejs 函式入口

實現報警功能的主要邏輯,就寫在 index.js 裡面。具體的實現,就不細說,下面用虛擬碼來描述:

alarm/alarm.js

// alarm/alarm.js 
// 實現報警功能
module.exports = function() {
	return new Promise((resolve, reject) => {
		// 查詢 SLS 日誌
		// - 如果沒有錯誤日誌,則 resolve
		// - 如果有錯誤日誌,則傳送釘釘訊息
		// 		- 如果釘釘訊息傳送失敗,則 reject
		// 		- 如果釘釘訊息傳送成功,則 resolve
		resolve();
	})
}
複製程式碼

alarm/index.js

// alarm/index.js 
// 呼叫報警函式
const alarm = require('./alarm');

module.exports.handler = function(event, context, callback) { 
  alarm()
  	.then(() => {
			callback(null, 'success');   
  	})
  	.catch(error => {
			callback(error); 
		})
};
複製程式碼

CodeUri

如果函式裡面引入了自定義的其他模組,比如在 index.js 裡面引入了 alarm.js const alarm = require('./alarm');,則需要修改預設的 codeUri 為當前程式碼目錄 ./。否則預設的 codeUri 只定義了 index.js,部署的時候只會部署 index.js

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi: # 函式服務的名稱
    Type: 'Aliyun::Serverless::Service' # 表示 yunzhi 是一個函式服務
    Properties:
      Description: 'helloworld' # 函式服務的描述
    alarm: # 函式的名稱
      Type: 'Aliyun::Serverless::Function' # 表示 alarm 是一個函式
      Properties:
        Handler: index.handler # 函式的呼叫入口
        Runtime: nodejs8 # 函式的執行環境
        CodeUri: './' # 程式碼的目錄
複製程式碼

如果沒有修改 CodeUri,則會有類似下面的報錯

$ fun local invoke alarm
FC Invoke End RequestId: 16e3099e-6a40-43cb-99a0-f0c75f3422c6
{
  "errorMessage": "Cannot find module './alarm'",
  "errorType": "Error",
  "stackTrace": [
    "Error: Cannot find module './alarm'",
    "at Module._resolveFilename (module.js:536:15)",
    "at Module._load (module.js:466:25)",
    "at Module.require (module.js:579:17)",
    "at require (internal/module.js:11:18)",
    "at (/code/index.js:9:15)",
    "at Module._compile (module.js:635:30)",
    "at Module._extensions..js (module.js:646:10)",
    "at Module.load (module.js:554:32)",
    "at tryModuleLoad (module.js:497:12)",
    "at Module._load (module.js:489:3)"
  ]
}
複製程式碼

fun local invoke alarm 是本地除錯的命令,接下來會講到。

本地除錯

在開發過程中,肯定需要本地除錯。fun 提供了 fun local 支援本地除錯。

fun local 的命令格式為 fun local invoke [options] <[service/]function>,其中 optionsservice 都可以忽略。比如除錯上面的報警功能的命令就是 fun local invoke alarm

需要注意的是,本地除錯需要先安裝 docker。

$ brew cask install docker
複製程式碼

安裝成功後啟動 docker。

如果 docker 沒有啟動,執行 fun local 可能會有如下報錯

$ fun local invoke alarm
Reading event data from stdin, which can be ended with Enter then Ctrl+D
(you can also pass it from file with -e)
connect ENOENT /var/run/docker.sock
複製程式碼

正常的輸出如下

$ fun local invoke alarm
Reading event data from stdin, which can be ended with Enter then Ctrl+D
(you can also pass it from file with -e)
skip pulling image aliyunfc/runtime-nodejs8:1.5.0...
FC Invoke Start RequestId: 9360768c-5c52-4bf5-978b-774edfce9e40
load code for handler:index.handler
FC Invoke End RequestId: 9360768c-5c52-4bf5-978b-774edfce9e40
success

RequestId: 9360768c-5c52-4bf5-978b-774edfce9e40          Billed Duration: 79 ms          Memory Size: 1998 MB    Max Memory Used: 54 MB
複製程式碼

第一次除錯的話,會安裝 runtime 的映象,可能需要點時間。預設的 Docker 映象下載會很慢,可以使用國內的加速站點加速下載。

出現 Reading event data from stdin, which can be ended with Enter then Ctrl+D 的提示時,如果不需要輸入,可以按 ctrl+D 跳過。

函式部署

開發完成之後,就需要將函式部署到阿里雲的函式計算上面了。部署可以通過 fun deploy 命令。

前面已經在安裝 fun 之後,通過 fun config 命令配置了阿里雲的賬號和地域資訊,fun deploy 會將函式自動部署到對應的賬號和地域下。

template.yml 中,也配置了函式的服務名和函式名。如果在函式計算中沒有對應的服務或函式,fun deploy 會自動建立;如果已經存在,則會更新。

$ fun deploy
using region: cn-shanghai
using accountId: ***********4698
using accessKeyId: ***********UfpF
using timeout: 60

Waiting for service yunzhi to be deployed...
        Waiting for function alarm to be deployed...
                Waiting for packaging function alarm code...
                package function alarm code done
        function alarm deploy success
service yunzhi deploy success
複製程式碼

部署成功之後,就可以在函式計算的控制檯中看到對應的函式服務和函式了。目前還沒有配置觸發器,可以手動在控制檯中點選“執行”按鈕來執行函式。

fcshow2

觸發器

對於應用到生產環境的函式,肯定不會像上面一樣手動去執行它,而是通過配置觸發器去執行。觸發器就相當於是一個特定的事件,當函式計算接收到該事件的時候,就去呼叫對應的函式。

阿里雲的函式計算支援 HTTP 觸發器(接收到 HTTP 請求之後呼叫函式)、定時觸發器(定時呼叫函式)、OSS 觸發器等等。詳見 觸發器列表

對於報警功能,需要用到的是定時觸發器,因為需要間隔一定的時間就呼叫函式。

觸發器是配置到函式中的,可以通過函式的 Event 屬性去配置

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld'
    alarm: 
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs8 
        CodeUri: './' 
      Events: # 配置 alarm 函式的觸發器
        TimeTrigger: # 觸發器的名稱
          Type: Timer # 表示該觸發器是定時觸發器
          Properties: 
            CronExpression: "0 0/1 * * * *"  # 每 1 分鐘執行一次
            Enable: true # 是否啟用該定時觸發器
複製程式碼

上面的配置,就為 alarm 配置了一個名為 TimeTrigger 的定時觸發器,觸發器每隔 1 分鐘執行一次,也就是每隔 1 分鐘呼叫一次函式。

配置完成之後,再執行 fun deploy 就可以釋出函式及觸發器到函式計算上。

trigger

這裡需要注意的是,阿里雲函式計算服務目前支援的觸發器,最小的間隔時間為 1 分鐘。如果小於 1 分鐘,則無法設定成功。定時觸發器的詳細介紹可參考文件 定時觸發函式

函式日誌

對於 serverless 應用,雖然不用關心運維了,其實我們也並不知道我們的函式執行在哪臺伺服器上。這個時候,函式的日誌就尤為重要了。沒有日誌,我們很難知道程式執行狀態,遇到問題更是無從下手。

所以接下來需要對函式配置日誌。阿里雲的函式計算可以使用阿里雲日誌服務 SLS來儲存日誌。如果要儲存日誌,則需要先開通 日誌服務

不存在日誌庫

如果是第一次使用日誌服務,則肯定不存在日誌庫。可以在 template.yml 像定義函式服務一樣,通過 Resource 來定義日誌資源。

前面也提到,函式日誌是配置到對應的服務上的,具體配置也很簡單,就是通過函式服務的 LogConfig 屬性來配置。

完整的 template.yml 如下

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  log-yunzhi: # 日誌專案名稱為 log-yunzhi
    Type: 'Aliyun::Serverless::Log' # 表示該資源是阿里雲的日誌服務
    Properties:
      Description: 'yunzhi function service log project'
    log-yunzhi-store: # 日誌的 logstore
      Type: 'Aliyun::Serverless::Log::Logstore'
      Properties:
        TTL: 10
        ShardCount: 1
    log-another-logstore: # 日誌的另一個 logstore
      Type: 'Aliyun::Serverless::Log::Logstore'
      Properties:
        TTL: 10
        ShardCount: 1
  yunzhi:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld' 
      LogConfig: # 配置函式的日誌
        Project: 'log-yunzhi' # 儲存函式日誌 SLS 專案: log-yunzhi
        Logstore: 'log-yunzhi-store' # 儲存函式日誌的 SLS logstore: log-yunzhi-store
    alarm:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler 
        Runtime: nodejs8 
        CodeUri: './' 
      Events: 
        TimeTrigger: 
          Type: Timer 
          Properties: 
            CronExpression: "0 0/1 * * * *"  
            Enable: true 

複製程式碼

在上面的配置中,就定義了名為 log-yunzhi 的日誌專案(Project),並且在該 Project 中建立了兩個日誌倉庫(LogStore):log-yunzhi-storelog-yunzhi-store。一個 Project 可以包含多個 LogStore。

注意:日誌專案的名稱必須全域性唯一。 即配置中,og-yunzhi 這個專案名稱是全域性唯一的。

執行 fun deploy 之後,就會自動在函式服務對應的地域建立日誌 Project 及日誌 logstore,同時也會自動為 logstore 加上全文索引,然後自動為函式服務配置日誌倉庫。

之後函式的執行日誌都會儲存在對應的 logstore 裡。

$ fun deploy
using region: cn-shanghai
using accountId: ***********4698
using accessKeyId: ***********UfpF
using timeout: 60

Waiting for log service project log-yunzhi to be deployed...
        Waiting for log service logstore log-yunzhi-store to be deployed...
                retry 1 times
                Waiting for log service logstore log-yunzhi-store default index to be deployed...
                log service logstore log-yunzhi-store default index deploy success
        log serivce logstore log-yunzhi-store deploy success
        Waiting for log service logstore log-another-logstore to be deployed...
                Waiting for log service logstore log-another-logstore default index to be deployed...
                log service logstore log-another-logstore default index deploy success
        log serivce logstore log-another-logstore deploy success
log serivce project log-yunzhi deploy success

Waiting for service yunzhi to be deployed...
        Waiting for function alarm to be deployed...
                Waiting for packaging function alarm code...
                package function alarm code done
                Waiting for Timer trigger TimeTrigger to be deployed...
                function TimeTrigger deploy success
        function alarm deploy success
service yunzhi deploy success
複製程式碼

slslog

如果日誌庫已經存在,且定義了日誌資源,則 fun deploy 會按照 template.yml 中的配置更新日誌庫。

存在日誌庫

如果日誌庫已經存在,即已經在日誌服務中建立了日誌專案 Project 和日誌庫 Logstore ,就可以直接為函式服務新增 LogConfig,不用再定義日誌資源。

注意,日誌庫需要和函式服務在同一個地域 Region。否則不能部署成功。

下面是一個配置函式日誌到已經存在的 Project 和 Logstore 中的例子。

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld' 
      LogConfig: # 配置函式的日誌
        Project: 'log-yunzhi-exist' # 儲存函式日誌到已經存在的 Project: log-yunzhi-exist
        Logstore: 'logstore-exist' # 儲存函式日誌到已經存在的 logstore: logstore-exist
    alarm:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler 
        Runtime: nodejs8 
        CodeUri: './' 
      Events: 
        TimeTrigger: 
          Type: Timer 
          Properties: 
            CronExpression: "0 0/1 * * * *"  
            Enable: true 

複製程式碼

如果日誌庫和函式服務不在同一個地域,函式服務就會找不到日誌庫,fun deploy 也會報錯。如下所示,yunzhi-log-qingdao 是我建立的一個青島地域的日誌 Project。

$ fun deploy
using region: cn-shanghai
using accountId: ***********4698
using accessKeyId: ***********UfpF
using timeout: 60

Waiting for service yunzhi to be deployed...
        retry 1 times
        retry 2 times
        retry 3 times
        retry 4 times
        retry 5 times
        retry 6 times
        retry 7 times
PUT /services/yunzhi failed with 400. requestid: 6af2afb8-cbd9-0d3e-bf16-fe623834b4ee, message: project 'yunzhi-log-qingdao' does not exist.
複製程式碼

其他問題

子賬號 AccessDenied

如果是使用 RAM 子賬號來開發、部署函式計算,則 fun 工具的配置中 Aliyun Access Key ID Aliyun Secret Access Key 是對應子賬戶的資訊,但 Aliyun Account ID 還是主賬號的資訊。RAM 子賬號有一個 UID,這個不是 Account ID。

如果 Aliyun Account ID 寫錯了,則使用 funfcli 的時候,可能會遇到下面的錯誤

Error: {
  "HttpStatus": 403,
  "RequestId": "b8eaff86-e0c1-c7aa-a9e8-2e7893acd545",
  "ErrorCode": "AccessDenied",
  "ErrorMessage": "The service or function doesn't belong to you."
}
複製程式碼

程式碼版本的管理

在實現報警功能的過程中,我依舊使用了 GitLab 來儲存程式碼。每次開發完成之後,將程式碼 push 到 GitLab,然後再將程式碼部署到函式計算上。不過這兩個過程是獨立的,還是不那麼方便。

環境問題

一般我們開發的時候,需要日常、預發、線上多個環境部署、測試。阿里雲函式計算是一個雲產品,沒有環境的區分。但對於報警整個功能,我也沒有去區分環境,只是本地開發的時候,將報警訊息發到一個測試的釘釘群,所以也沒有特別去關注。

經濟成本

使用函式計算的經濟成本,相比於購買雲伺服器部署應用,成本低了非常多。

本文中涉及到函式計算和日誌服務兩個雲產品,都有一定的免費額度。其中函式計算每月前 100 萬次函式呼叫免費,日誌服務每月也有 500M 的免費儲存空間和讀寫流量。所以只用來測試或者實現一些呼叫量很小的功能,基本是免費的。

總結

配置了函式的日誌之後,將函式部署到函式計算上,就算是正式釋出上線了。

現在回過頭來看,整個流程還算比較簡單。但從零開始一步一步到部署上線的過程,還是遇到了很多問題。比如文中的許多注意事項,都是在不斷嘗試中得出的結論。

最近 serverless 這個話題也很火熱,也期待這個技術即將帶來的變革。

more github.com/nodejh/node…

相關文章