这一讲我将带你了解如何自定义函数运行时。

我们知道 Serverless 应用的函数代码是在 FaaS 中运行的,到目前为止,你也只能选择 FaaS 平台支持的编程语言开发应用,而 FaaS 平台支持的编程语言不但有限,还只支持极少数的特定版本,比如函数计算只支持 Node.js 12、Node.js 8、Java、Python 等 。这样一来,当你想用 FaaS 平台不支持的编程语言(比如 TypeScrip、Golang、Ruby )或各种编程语言的小版本(比如最新的 Node.js)时,该怎么办呢?这就需要使用自定义运行时了。

主流的 FaaS 平台都支持自定义运行时,实现原理也都大致相同。为了让你弄清楚自定义运行时的原理,并学会开发一个自定义运行时,这一讲我将分为原理和实现两部分。

自定义运行时的原理: 首先我会带你了解一个通用的 FaaS 自定义运行时原理,这样你使用任何 FaaS 平台都可以触类旁通了。

自定义运行时的实现: 我会带你分别实现一个 TypeScript 运行时,和 Golang 的运行时,从易到难让你彻底掌握自定义运行时的实现。

话不多说,我们进行今天的学习。

自定义运行时的原理

运行时(Runtime)是程序运行时所依赖的环境(环境包括任何库、框架或平台)。FaaS 中的运行时,就是你创建函数时指定的运行环境,比如函数计算的 Node.js 运行时,就包括 Node.js 运行环境以及一些内置的模块,如 ali-oss、tablestore,此外还有 Java 运行时、Python 运行时等。那自定义运行时就是你可以在 FaaS 自定义一个运行环境,如 TypeScript,然后你就可以使用 TypeScript 来编写代码并部署到 FaaS 平台上运行。

在了解自定义运行时的原理前,咱们先来回顾 FaaS 的运行原理(我在“04 | 运行原理:Serverless 应用是怎么运行的?”讲中提到过)。在 FaaS 中,运行时被预先定义,比如在创建函数时可以指定 runtime:nodejs12 ,接下来,用户通过触发器驱动函数执行后,FaaS 就会以 Node.js 12 作为运行时来创建函数实例,函数代码也就在 Node.js 12 这个运行环境中执行。

FaaS 运行原理

那怎么才能让函数在自己定义的运行环境中执行呢? 这就涉及“06 | 依赖管理:Serverless 应用怎么安装依赖?”的内容了。在这一讲中我讲了:安装依赖的本质就是要把函数运行所需要的依赖都打包上传到 FaaS 中,这些依赖既可以是代码依赖包,还可以是系统依赖库。那你能不能把函数的运行时也打包上传到 FaaS 中,让 FaaS 利用你上传的运行时来执行你的代码呢?

当然可以,并且 FaaS 平台的自定义运行时也是这样实现的。

比如你可以用 TypeScript 编写代码,然后将代码和 TypeScript 运行时都上传到 FaaS 中,然后通过特定的配置,让 FaaS 通过自定义的 TypeScript 运行时来运行你的代码。比如通过 runtime:custom 配置告诉 FaaS 你使用的时自定义运行时,然后用 bootstrap: ts-node index.ts 配置来告诉 FaaS 函数启动时,使用 ts-node 来运行 index.ts。

这样一来,我们就解决了使用自定义运行时运行自定义编程语言的代码问题。**但还存在一个问题:**FaaS 平台在运行函数时会有很多参数(比如事件信息、函数上下文等),这些参数怎么传递给自定义运行时呢?这本质上是远程数据通信问题,最简单的就是 HTTP 协议来实现:在自定义运行时中实现一个 HTTP 服务,FaaS 平台通过 HTTP 请求把数据传递给自定义运行时。

讲到这儿,一个自定义运行时的原理图就可以总结出来了:

自定义运行时原理

简单来讲,自定义运行时就是一个使用自定义编程语言实现的 HTTP 服务。然后你需要为你的 HTTP 服务指定一个启动命令,通用的做法是将启动命令保存在名为 bootstrap 的文件中。bootstrap 文件示例:

1
2
#!/bin/bash
./node_modules/ts-node/dist/bin.js server.ts

FaaS 平台在创建函数实例时,会执行 bootstrap 文件启动 HTTP 服务,然后将所有请求及参数都转发到你的 HTTP 服务中,由 HTTP 服务处理所有请求。讲完自定义运行时的原理之后,我们接着来实现一个自定义运行时。

自定义运行时的实现

我会从易到难,先讲解如何实现一个 TypeScript 运行时,然后再介绍如何实现 Golang 运行时,这是两个很典型的例子,并且你掌握了如何自定义 Golang 运行时之后,就可以轻松自定义其他编程语言的运行时了。为了方便你进行实践,我选择了用得比较多的函数计算进行演示,同时我也为你提供了示例程序,你可以直接下载使用。

实现一个 TypeScript 运行时

TypeScript 为 JS 代码增加了类型系统,可以大大提升代码的可读性和可维护性。然而现在大多数 FaaS 平台都不直接支持 TypeScript,要想使用 TypeScript 编写 Serverless 应用,通常需要把代码编译为 JavaScript 再运行。显然这没有直接使用部署并执行 TypeScript 代码高效。我们如果想要直接运行 TypeScript 代码,可以通过 ts-node 来实现。所以你可以基于 ts-node 实现一个 TypeScript 运行时,这样就可以直接使用 TypeScript 编写 Serverless 应用了。

那怎么实现呢?

首先在本地创建一个 TypeScript 项目,然后安装必要的依赖,为了将依赖都上传到 FaaS,我们需要将 ts-node 等相关依赖(ts-node、typescript 和 @types/node)都安装在项目的 node_modules 中,如下所示:

1
2
3
$ npm i -S ts-node
$ npm i -S typescript
$ npm i -D @types/node

前面我们已经学习了自定义运行时需要实现一个 HTTP 服务来接收 FaaS 平台的请求,所以接下来我们就使用 TypeScript 编写一个 HTTP 服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import * as http from 'http';
/**
 * 你可以在这里实现具体的业务逻辑
 */
function sayHello(name: string): string {
  return `Hello, ${name}`;
}
// 创建一个 HTTP 服务
const server = http.createServer(function (req: http.IncomingMessage, res: http.ServerResponse): void {
  // 获取 RequestId
  const requestId = req.headers["x-fc-request-id"];
  console.log(`FC Invoke Start RequestId: ${requestId}`);
  // 拼接请求参数
  let rawData = "";
  req.on('data', function (chunk) {
    rawData += chunk;
  });
  req.on('end', function () {

    // 处理业务逻辑,比如这里是输出欢迎语
    const body = sayHello(rawData);
    // 设置 HTTP 响应
    res.writeHead(200);
    res.end(body);
    console.log(`FC Invoke End RequestId: ${requestId}`);
  });
});
server.timeout = 0;
server.keepAliveTimeout = 0;
// 启动 HTTP 服务并监听 9000 端口
server.listen(9000, '0.0.0.0', function () {
  console.log('FunctionCompute typescript runtime initialized.');
});

这段代码启动了一个 HTTP 服务,监听 0.0.0.0:9000 端口(这也是函数计算要求的)。然后我们可以先在本地测试,通过安装在项目中的 ts-node  命令来运行上述代码:

1
2
# 启动 HTTP 服务
$ ./node_modules/ts-node/dist/bin.js server.ts

然后在另一个终端中使用 curl 命令进行测试:

1
2
$ curl 0.0.0.0:9000 -X POST -d "Serverless" -H "x-fc-request-id:abcde"
Hello, Serverless

HTTP 服务测试正常后,我们的自定义运行时就完成了。你可以在接收 HTTP 请求后处理业务逻辑,然后将处理结果再以 HTTP 响应返回给 FaaS 平台。

当然了,在将自定义运行时部署到 FaaS 之前还需要创建一个名为 bootstrap 的文件,在文件中添加启动命令,这样 FaaS 才知道如何启动你的自定义运行时,如下所示:

1
2
#!/bin/bash
./node_modules/ts-node/dist/bin.js server.ts

接下来我们还需要添加函数计算的 template.yaml 配置,定义函数信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  custom-typescript-demo:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld'
    typescript-demo:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Runtime: custom
        MemorySize: 512
        Handler: index.handler
        CodeUri: './'

你要注意一下,在这份 YAML 配置中,Runtime 的值必须为 custom,Handler 属性在这里没有实际意义但是必须填写。

接下来就可以使用 fun deploy 将自定义运行时部署到函数计算了。部署后可以使用 fun invoke 进行测试:

1
2
3
4
5
6
7
8
$ fun deploy -y
...
Waiting for service custom-typescript-demo to be deployed...
    Waiting for function typescript-demo to be deployed...
        Waiting for packaging function typescript-demo code...
        The function typescript-demo has been packaged. A total of 363 files were compressed and the final size was 10.4 MB
        function typescript-demo deploy success
service custom-typescript-demo deploy success

$ fun invoke -e “Serverless” … FC Invoke Result: Hello Serverless

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

这时函数计算就是使用自定义的 TypeScript 运行环境直接运行我们的 TypeScript 代码。

TypeScript 的运行时比较简单,因为 ts-node 可以直接安装在 node\_modules 中,那么其他无法直接安装在依赖目录中的编程语言,比如 Golang 或最新版 Node.js 的自定义运行时应该怎么实现呢?

实现一个 Golang 的运行时

如果要沿用 TypeScript 这种自定义启动命令的方案,就需要将 Golang 和代码打包,但 Golang 是直接安装在操作系统上的,依赖系统环境,好像无从下手。

但你思考一下,将运行环境和代码打包,这种思想是不是和容器技术很像? 容器技术就是将应用和运行所依赖环境打包为镜像,这样应用可以轻松迁移、部署。那能不能把 Golang 运行环境构建为 Docker 镜像,然后让 FaaS 平台使用你的 Docker 镜像去执行代码呢?这样只要 FaaS 平台能支持自定义容器,就能实现任意编程语言的运行时了。答案是肯定的,很多 FaaS 平台(比如 Lambda 和函数计算)也都提供了自定义容器的能力。

使用容器自定义运行时,你需要先构建一个容器镜像,然后通过函数的配置告诉 FaaS 平台使用你的容器镜像。在函数执行时,FaaS 平台就会拉取容器镜像并启动容器执行代码。与前面 TypeScript 运行时一样,在自定义容器镜像中你也需要实现一个 HTTP 服务,接收 FaaS 平台的所有请求。

自定义运行时使用流程

那我们就一起来实现一下吧!

还是以函数计算为例,首先你需要准备一个镜像仓库,用来存放你的镜像,函数计算目前只支持容器镜像服务中的镜像(大部分 FaaS 也都只支持自家的镜像仓库),所以你需要构建自定义运行时镜像然后上传到容器镜像服务中。你可以提前在容器服务中创建一个命名空间和镜像仓库,创建完毕后记住你的仓库地址,格式为registry.<地域>.aliyuncs.com/<命名空间>/<仓库名> ,例如 registry.cn-hangzhou.aliyuncs.com/serverless-image/nodejs15 。

接下来进入开发步骤。

首先我们使用 Golang 实现一个 HTTP 服务,代码如下:

package main import ( “fmt” “net/http” “runtime” ) func HelloHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, “Hello Serverless! This is Golang runtime, version: %s”, runtime.Version()) } func main () { http.HandleFunc("/", HelloHandler) http.ListenAndServe(":8080", nil) }

1
2
3
4

在这个 HTTP 服务中我们定义了 / 这个路由,逻辑就是返回当前 Golang 的版本。当基于容器实现自定义运行时,函数计算会将 HTTP 触发器的请求转发到  / 路由,将事件触发器的请求转发到 /invoke 路由。

然后我们也可以先在本地测试,为了简单,可以直接通过 go run main.go 的命令启动 HTTP 服务,然后使用 curl 命令测试:

启动 HTTP 服务

$ go run main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# 新开一个终端,通过 curl 命令测试
$ curl localhost:8080
Hello Serverless! This is Golang runtime, version: go1.13.5

```

(这里返回的 golang 版本是 1.3.15,这是因为我的电脑上安装的是该版本)。

接下来我们需要编写一个 Dockerfile,用来构建包含 Golang 运行时及代码的镜像:

```
# Dockerfile
FROM golang:1.15.6-alpine3.12
WORKDIR /go/src/app
# 将代码复制到工作目录
COPY . .
# 编译
RUN go build main.go
# 暴露 8080 端口
EXPOSE 8080
# 启动应用
ENTRYPOINT [ "./main" ]

```

在这个 Dockerfile 中,我们使用了 golang:1.15.6-alpine3.12 作为基础镜像,其中 alpine 是最小体积的 golang 运行环境。然后我们使用 go build 来编译代码,最后启动应用。

接下来就可以构建并上传镜像了,如果是第一次使用容器镜像服务,则需要先使用 docker login 登录。

```
# 指定镜像名称,例如 registry.cn-hangzhou.aliyuncs.com/serverless-image/golang:v0.1
$ export IMAGE_NAME="你的镜像仓库:版本"
$ docker build -t $IMAGE_NAME .
$ docker push $IMAGE_NAME

```

镜像上传后,就可以创建一个 template.yaml 来定义函数配置了:

```
ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  serverless:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Policies:
        - AliyunContainerRegistryReadOnlyAccess
      InternetAccess: true
    golang-runtime:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Description: 'Golang Runtime'
        Runtime: custom-container
        Timeout: 60
        CAPort: 8080
        Handler: not-used
        MemorySize: 1024
        CodeUri: ./
        CustomContainerConfig:
          Image: 'registry.cn-hangzhou.aliyuncs.com/serverless-image/golang:v0.1'

```

这份 YAML 配置中,Runtime 值为custom-container ,表示该函数是自定义容器,然后通过 CustomContainerConfig 配置容器镜像。示例中我直接使用的 registry.cn-hangzhou.aliyuncs.com 这个 endpoint,但通常为了更快拉取镜像,一般会使用 VPC 地址,如registry-vpc.cn-beijing.aliyuncs.com/serverless-image/golang:v0.1

接下来就可以通过 fun deploy 进行部署,fun invoke  进行测试:

```
$ fun deploy
$ fun invoke
Hello Serverless! This is Golang runtime, version: go1.15.6

```

可以看到,执行结果中 Golang 版本是 1.15.6,说明自定义运行时正常工作了。

总结

这一讲我首先为你讲解了自定义运行时的基本原理,然后以 TypeScript 运行时和 Golang 运行时为例,为你详细介绍了如何创建一个自定义运行时。关于这一讲的内容,我想要强调以下几点:

FaaS 平台提供了有限的编程语言及版本的支持,使用自定义运行时,你可以自定义编程语言进行开发;

自定义运行时的原理是在函数中实现一个 HTTP 服务,FaaS 平台将触发器事件转发到你的 HTTP 服务;

你可以通过将运行时上传到 FaaS,在 bootstrap 中定义启动命令来实现自定义运行时,比如 TypeScript

你也可以通过自定义容器镜像来实现任意编程语言的自定义运行时。

自定义运行时是 Serverless 应用开中非常重要的一个功能, 它可以让你突破 FaaS 平台运行环境的限制,可以让你使用 FaaS 平台不支持的编程语言进行开发。你应该也能发现,基于容器实现自定义运行时你可以很方便地安装依赖,因为依赖都打包到了镜像中。除此之外,基于自定义运行时,你还可以平滑地将原有系统或传统应用平滑迁移到 Serverless 架构。

最后我留给你的作业就是:根据我所讲的内容,举一反三地实现一个最新版 Node.js 运行时。我们下一讲见。

本讲的代码地址:https://github.com/nodejh/serverless-class/tree/master/07

\--- ### 精选评论 ##### \*\*涛: > 你好,有个疑问:当我们指定运行时为自定义的容器,我们在容器里运行了一个http的server用于接收参数,而我们的handler也是在容器里执行,那么serverless平台如何将我们的server和handler衔接起来的呢? ######     讲师回复: >     运行时是自定义容器时,就不用 handler 了,换句话说,handler 就没有意义了。自定义容器本质上是实现一个 HTTP Server,并构建一个容器镜像。部署函数时,部署的就是容器镜像 。函数启动时,函数就会去拉取容器镜像,然后启动容器,这个过程与普通函数的 handler 就没有关系了。容器启动后,Serverless 平台就会将所有请求都转发到容器的 HTTP Server,这样 Serverless 平台就和我们自定义的 HTTP Server 衔接起来了。 ##### \*\*俊: > RAM 账号想使用 custome-container 进行应用部署都需要阿里云什么权限的? ######     讲师回复: >     对于子账号,这里有两个地方需要权限。一是部署函数,子账号需要管理函数计算的权限,即 AliyunFCFullAccess ,这样你的子账号才能访问函数计算的创建服务(CreateService)、创建函数(CreateFunction)、更新函数(UpdateFunction)等所有 API。 二是函数使用 custome-container ,这里需要为函数所在的服务设置容器镜像服务的只读权限,即 AliyunContainerRegistryReadOnlyAccess 。因为我们使用 custome-container 时,镜像是上传到容器镜像服务的,函数运行时需要从容器镜像服务中拉取镜像并启动容器,所以需要 AliyunContainerRegistryReadOnlyAccess 权限,这样函数才能访问容器镜像的所有读相关的 API,包括拉取镜像等。由于函数计算不支持对单个函数授权,所以你需要为服务授权。 ##### \*\*3917: > 自定义容器运行时,还能继续用FAAS平台提供的函数或SDK吗?以及,fun deploy 这一步还有啥作用? ######     讲师回复: >     自定义容器运行时,依旧可以使用 FaaS 平台提到的 SDK,fun deploy 的作用就是部署函数,背后就是调用的 @alicloud/fc2 这个 SDK。只是自定义容器就不能使用 FaaS 平台内置的运行环境了,比如不能使用内置的 ali-oss、ali-mns 等模块。