微服务学习 - Buildpacks 工作原理

概述

Buildpacks 是云原生的应用镜像构建工具,它主要的作用是将应用源代码打包成符合OCI标准的镜像,供给各种云环境使用。

核心组件

Buildpacks 可以分为几个部分,参考 Componets 。

Lifecycle

Lifecycle 是 Buildpacks 最重要的组件,它将由应用源代码到镜像的构建步骤抽象出来,完成了对整个过程的编排,并最终产出应用镜像。

它所编排的步骤为(按顺序):

  • Detect ,判断是否可以使用该 buildpack ,规范标准参考 Phase #1: Detection 。
  • Analyze ,与 Restore 配合,为 Build 阶段提供文件、 layers 的缓存。需要在上一个 Build 阶段定义需要缓存的 layer,规范标准参考 Phase #2: Analysis 。
  • Restore ,与 Analyze 配合,为 Build 阶段提供文件、 layers 的缓存。需要在上一个 Build 阶段定义需要缓存的 layer,规范标准参考 Phase #2: Analysis 。
  • Build ,将源代码构建为容器中的可运行物,规范标准参考 Phase #3: Build 。
  • Export ,输出OCI镜像,规范标准参考 Phase #4: Export 。
  • Create , 一键执行上述5个步骤(按顺序)。
  • Launch ,负责启动镜像中的应用,规范标准参考 Launch 。
  • Rebase ,用于替换应用的 run 镜像。

Stack

Stack 的功能是提供基础运行环境,它包含两种镜像:

build ,为 Lifecycle 的 Build 阶段提供运行环境

run ,为业务应用提供运行环境

Buildpack

Buildpack 可以理解为任务单一的构建单元,每一个 buildpack 都具备以下三个元素:

  • buildpack.toml ,描述了 buildpack 的元数据,内容定义格式参考 buildpacktoml
  • bin/detect ,Lifecycle 中 Detect 阶段的实现
  • bin/build ,Lifecycle 中 Build 阶段的实现

它的工作原理可以简单理解为:bin/detect 将会判断是否可以进入该 buildpack ,如果可以进入该 buildpack ,那么 Lifecycle 就会指导流程进入 Build 阶段同时触发 bin/build 执行。

每个 buildpack 完成后都会产出一份清单,用于表明自己产出了什么内容(可为空),且如果要完成后续的 buildpack 过程,还需要什么内容(可为空)。参考 Build Plan 。

同时在 Build 阶段,基于复用构建逻辑的原则,buildpack 的作者可以设置不通过的 layer (layer 内保存了所关联的构建过程的结果,如文件、运行时等内容),并设置 build 、launch 、cache 三种属性(Layer Content Metadata)。Lifecycle 将根据这三个参数的组合来决定是否复用一个 layer ,规则参考 Layer Types 。

Builder

Builder 描述了一个完整的构建编排逻辑。它本质上是将 Stack 及若干个 Buildpack 组合在一起,形成一个业务逻辑明确的构建工具。

create-builder diagram

图片来自 Buildpacks.io 官网

Buildpacks 在 OpenFunction 中的作用

在 OpenFunction 中,Buildpacks 主要用于 build 阶段,配合 Tekton 完成函数从源代码到镜像的制作。

制作过程由 buildpacks.yaml 描述,分为三个步骤:

  • prepare ,准备环境变量
  • create ,使用 /cnb/lifecycle/creator(即 Lifecycle 中的 Create 阶段的实现)进行源代码的构建,规范标准参考 creator 。
  • results ,输出镜像的摘要

OpenFunction 还需要基于 Buildpacks 的原理制作适用于 serverless(FaaS)场景的 builder 。

这个 builder 需要具备以下特性:

  • 封装函数源代码,使之能被多种常见的事件源触发(function framework)
  • 快速构建镜像
  • 适用于在线或离线构建场景

常见的开源 builder 产品:

  • gcr.io/buildpacks/builder:v1
  • paketobuildpacks/builder(包含 base 、 full 、 tiny 三个类型)

基于 gcp/buildpacks 自定义 builder

Google 提供了一套适用于buildpacks的工具, GoogleCloudPlatform/buildpacks ,用于识别业务代码语言,然后使用对应的语言构建工具打包业务代码。

参考 本地构建gcp/buildpacks/builder镜像 使用 GoogleCloudPlatform/buildpacks 制作原生的 gcp/builder 。

相比 paketobuildpacks/builder ,gcp/buildpacks 提供了更丰富的构建时上下文以及现成的 function framework 模板,基于 gcp/buildpacks 自定义更契合 OpenFunction 的 builder 在实现上将会更容易一些。

当前 OpenFunction 的 builder 项目在这里:https://github.com/OpenFunction/builder

stack

以 python3.9 的函数构建为例,需要先准备一个部署有 python3.9 runtime 的 stack ,stack 本质为一个基础运行环境,在制作过程中加入 LABEL io.buildpacks.stack.id 即可以指定 stack 的标识。

可以参考 Create a stack 制作一个简单的 stack,stack 的规范标准请参考 Stacks

原生 gcp/buildpacks 将下载语言 runtime 的步骤放置在 buildpack 中,即在探测到语言类型后再下载安装对应的语言 runtime

本例中选择将语言 runtime 安装在 stack 中,以减少函数构建过程中的时间(python、node、golang 语言函数的构建时间在原有的80~90s基础上缩短20s左右)

❓需要探讨该部分机制的合理性

以下为基于 gcp/buildpacks 自定义的 python3.9 stack :

完整的 stack 定义请参考 py39/stack

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
FROM gcr.io/gcp-runtimes/ubuntu_18_0_4

ARG cnb_uid=1000
ARG cnb_gid=1000
ARG stack_id="google"

# Required by python/runtime: libexpat1, libffi6, libmpdecc2.
# Required by dotnet/runtime: libicu60
# Required by go/runtime: tzdata (Go may panic without /usr/share/zoneinfo)
RUN apt-get update && apt-get install -y --no-install-recommends \
libexpat1 \
libffi6 \
libmpdec2 \
libicu60 \
libc++1-9 \
tzdata \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

LABEL io.buildpacks.stack.id=${stack_id}

RUN groupadd cnb --gid ${cnb_gid} && \
useradd --uid ${cnb_uid} --gid ${cnb_gid} -m -s /bin/bash cnb

RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
build-essential \
libffi-dev \
zlib1g-dev \
libssl-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/* && \
cd /opt && wget https://www.python.org/ftp/python/3.9.5/Python-3.9.5.tgz && tar xzf Python-3.9.5.tgz && cd Python-3.9.5 && \
sed -i '214,217s/^#//' Modules/Setup && /bin/bash -c "./configure && make -j8 && make -j8 install" && \
ln -sf /usr/local/bin/python3.9 /usr/bin/python3 && python3 -m pip install --upgrade pip setuptools wheel && \
cd /opt && rm -rf Python-3.9.5

ENV CNB_USER_ID=${cnb_uid}
ENV CNB_GROUP_ID=${cnb_gid}
ENV CNB_STACK_ID=${stack_id}

buildpack

参考其中标识为 google.python.functions-framework 的 buildpack.toml:

api 表示 buildpack 遵循的格式标准

[buildpack] 中定义了一些元数据,用于和其他资源关联

[[stacks]] 用于描述 buildpack 可以运行在哪些 stack 上

可以参考 Create a buildpack 制作一个简单的 buildpack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
api = "0.2"

[buildpack]
id = "google.python.functions-framework"
version = "0.9.4"
name = "Python - Functions Framework"

[[stacks]]
id = "google"

[[stacks]]
id = "google.python37"

[[stacks]]
id = "google.python38"

[[stacks]]
id = "google.python39"

gcp 提供了 bazel 工具将相关代码目录打包成 tgz 文件,对于 buildpack 来说,就是封装了 /bin/detect 和 /bin/build 两个功能。google.python.functions-framework 这个 buildpack 的打包配置参考 BUILD.bazel 。

基于这个机制,只需要在 main.go 中完成 detectFn 和 buildFn 两个函数的内容即可。

builder

builder 的工作内容描述:

build diagram

图片来自 Buildpacks.io 官网

builder 的定义文件 builder.toml 内容如下:

[[buildpacks]] 表示引入的 buildpack ,此处 gcp 使用 bazel 将相关代码目录打包成了 tgz 文件

[[order]]|[[order.group]] 表示 buildpack 的执行顺序

[stack] 表示引用的 stack 内容

[lifecycle] 表示使用的 lifecycle 模块的版本

可以参考 Create a builder 制作一个简单的 builder

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
[[buildpacks]]
id = "google.config.entrypoint"
uri = "entrypoint.tgz"

[[buildpacks]]
id = "google.python.pip"
uri = "python/pip.tgz"

[[buildpacks]]
id = "google.python.functions-framework"
uri = "python/functions_framework.tgz"

[[buildpacks]]
id = "google.python.missing-entrypoint"
uri = "python/missing_entrypoint.tgz"

[[buildpacks]]
id = "google.utils.label"
uri = "label.tgz"

[[order]]
[[order.group]]
id = "google.python.functions-framework"

[[order.group]]
id = "google.python.pip"
optional = true

[[order.group]]
id = "google.config.entrypoint"
optional = true

[[order.group]]
id = "google.utils.label"

[stack]
id = "google"
build-image = "openfunctiondev/buildpacks-py37-build:v1"
run-image = "openfunctiondev/buildpacks-py37-run:v1"

[lifecycle]
version = "0.11.1"

运行日志

以下为一个 builder 运行时的日志信息:

显示|隐藏输出内容
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
===> DETECTING
3 of 4 buildpacks participating
google.python.functions-framework 0.9.4
google.python.pip 0.9.2
google.utils.label 0.0.1
===> ANALYZING
Restoring metadata for "google.python.pip:pip" from app image
===> RESTORING
Removing "google.python.pip:pip", not in cache
===> BUILDING
=== Python - Functions Framework (google.python.functions-framework@0.9.4) ===
--------------------------------------------------------------------------------
Running "python3 -m compileall -f -q ."
Done "python3 -m compileall -f -q ." (63.312851ms)
Handling functions with dependency on functions-framework.
=== Python - pip (google.python.pip@0.9.2) ===
Installing application dependencies.
--------------------------------------------------------------------------------
Running "python3 -m pip install --requirement requirements.txt --upgrade --upgrade-strategy only-if-needed --no-warn-script-location --no-warn-conflicts --force-reinstall --no-compile --user (PIP_CACHE_DIR=/layers/google.python.pip/pipcache PIP_DISABLE_PIP_VERSION_CHECK=1)"
Collecting functions-framework==1.4.3
Downloading functions_framework-1.4.3-py3-none-any.whl (21 kB)
Collecting watchdog>=0.10.0
Downloading watchdog-2.1.1-py3-none-manylinux2014_x86_64.whl (74 kB)
Collecting click<8.0,>=7.0
Downloading click-7.1.2-py2.py3-none-any.whl (82 kB)
Collecting gunicorn<21.0,>=19.2.0
Downloading gunicorn-20.1.0-py3-none-any.whl (79 kB)
Collecting flask<2.0,>=1.0
Downloading Flask-1.1.3-py2.py3-none-any.whl (94 kB)
Collecting Werkzeug<2.0,>=0.15
Downloading Werkzeug-1.0.1-py2.py3-none-any.whl (298 kB)
Collecting Jinja2<3.0,>=2.10.1
Downloading Jinja2-2.11.3-py2.py3-none-any.whl (125 kB)
Collecting itsdangerous<2.0,>=0.24
Downloading itsdangerous-1.1.0-py2.py3-none-any.whl (16 kB)
Collecting setuptools>=3.0
Downloading setuptools-56.2.0-py3-none-any.whl (785 kB)
Collecting MarkupSafe>=0.23
Downloading MarkupSafe-2.0.0-cp37-cp37m-manylinux2010_x86_64.whl (31 kB)
Installing collected packages: MarkupSafe, Werkzeug, setuptools, Jinja2, itsdangerous, click, watchdog, gunicorn, flask, functions-framework
Successfully installed Jinja2-2.11.3 MarkupSafe-2.0.0 Werkzeug-1.0.1 click-7.1.2 flask-1.1.3 functions-framework-1.4.3 gunicorn-20.1.0 itsdangerous-1.1.0 setuptools-56.2.0 watchdog-2.1.1
Done "python3 -m pip install --requirement requirements.txt --upgr..." (1m5.686905222s)
--------------------------------------------------------------------------------
Running "python3 -m compileall --invalidation-mode unchecked-hash -qq /layers/google.python.pip/pip"
Done "python3 -m compileall --invalidation-mode unchecked-hash -qq..." (788.702099ms)
Checking for incompatible dependencies.
--------------------------------------------------------------------------------
Running "python3 -m pip check"
No broken requirements found.
Done "python3 -m pip check" (535.823132ms)
=== Utils - Label Image (google.utils.label@0.0.1) ===
===> EXPORTING
Reusing layers from image 'index.docker.io/zephyrfish/python-hello-func@sha256:e5550f9bb775872b95b23bd1030d0bdc99d24ed8b94383341638bea6a84acb19'
Reusing layer 'google.python.functions-framework:functions-framework'
Adding layer 'google.python.pip:pip'
Adding 1/1 app layer(s)
Reusing layer 'launcher'
Reusing layer 'config'
Reusing layer 'process-types'
Adding label 'io.buildpacks.lifecycle.metadata'
Adding label 'io.buildpacks.build.metadata'
Adding label 'io.buildpacks.project.metadata'
Setting default process type 'web'
Saving zephyrfish/python-hello-func:latest...
*** Images (sha256:f34b0428549a6f61ceaf751520f55fc3b2b7dbe9fd795f25318a83d487bb6c33):
zephyrfish/python-hello-func:latest
Adding cache layer 'google.python.pip:pip'
Adding cache layer 'google.python.pip:pipcache'

function framework

FaaS 的目的在于降低用户使用 serverless 框架的成本,为了让用户专注于业务函数本身,就需要有一个 framework 将用户纯粹的业务函数转换为符合 serverless 框架标准的 main 函数。

例如以下是一个输入函数,这个函数显然无法直接运行在 Knative 等 serverless 框架下(可以参考 Knative 的 Hello World Sample ):

1
2
3
4
5
6
7
8
9
10
package myfunction

import (
"fmt"
"net/http"
)

func HelloWorld(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello, world")
}

经过 google.go.functions-framework 转换后的 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
package main

import (
"log"
"os"
"net/http"

userfunction "example.com/myfunction"

"github.com/GoogleCloudPlatform/functions-framework-go/funcframework"
)

func register(fn interface{}) error {
if fnHTTP, ok := fn.(func (http.ResponseWriter, *http.Request)); ok {
funcframework.RegisterHTTPFunction("/", fnHTTP)
} else {
funcframework.RegisterEventFunction("/", fn)
}
return nil
}


func main() {
if err := register(userfunction.Handler); err != nil {
log.Fatalf("Function failed to register: %v\n", err)
}

// Don't invoke the function for reserved URLs.
http.HandleFunc("/robots.txt", http.NotFound)
http.HandleFunc("/favicon.ico", http.NotFound)

port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
if err := funcframework.Start(port); err != nil {
log.Fatalf("Function failed to start: %v\n", err)
}
}

这样一来,函数(被嵌入框架后)就可以在 serverless 场景下被调用了。