OpenFaaS中的watchdog负责OpenFaaS中的启动和监视功能。通过嵌入二进制的watchdog,任何二进制文件都可以成为函数。
在函数镜像中,watchdog的作用是使用Golang编写的嵌入式HTTP服务器的“初始化进程”,它可以支持并发请求,超时和运行状况检查。下面会提到的较新的监视程序(of-watchdog),他的http响应模式非常适合热函数,流式使用案例或需要在请求之间维护昂贵的资源(例如数据库连接,ML模型或其他数据)时使用。
经典watchdog
经典的watchdog已用于所有正式的OpenFaaS模板,经典watchdog现在变得越来越流行。并且默认模板存储库(github.com/openfaas/te…)和社区模板存储库(github.com/openfaas/st…)中都存在适配了通用编程语言的watchdog模板。
下面是经典watchdog的技术概念图:
watchdog就两个功能:
- 调用函数
- 小型httpserver,用来把请求变成标准输入,再把标准输出写回response
watchdog在外部环境和函数功能之间提供了不受管理的通用接口。它的工作是封送API网关上接受的HTTP请求并调用您选择的应用程序。watchdog是一个小型的Golang网络服务器-工作原理在上图可以很清楚的看出来。
无论是使用openfaas-cli构建函数还是使用Dockerfile来构建函数,每个函数都需要嵌入此二进制文件并将其用作其ENTRYPOINT或CMD,实际上这是容器的初始化过程。forking了我们写的进程后,watchdog将通过stdin传递HTTP请求,并通过stdout读取HTTP响应。这意味着我们编写的函数不需要了解有关Web或HTTP的任何信息。
这是一个典型的watchdog嵌入函数的Dockerfile
FROM openfaas/classic-watchdog:0.18.1 as watchdog
FROM python:2.7-alpine
# Allows you to add additional packages via build-arg
ARG ADDITIONAL_PACKAGE
COPY --from=watchdog /fwatchdog /usr/bin/fwatchdog
RUN chmod +x /usr/bin/fwatchdog
RUN apk --no-cache add ca-certificates ${ADDITIONAL_PACKAGE}
# Add non root user
RUN addgroup -S app && adduser app -S -G app
WORKDIR /home/app/
COPY index.py .
COPY requirements.txt .
RUN chown -R app /home/app && \
mkdir -p /home/app/python && chown -R app /home/app
USER app
ENV PATH=$PATH:/home/app/.local/bin:/home/app/python/bin/
ENV PYTHONPATH=$PYTHONPATH:/home/app/python
RUN pip install -r requirements.txt --target=/home/app/python
RUN mkdir -p function
RUN touch ./function/__init__.py
WORKDIR /home/app/function/
COPY function/requirements.txt .
RUN pip install -r requirements.txt --target=/home/app/python
WORKDIR /home/app/
USER root
COPY function function
# Allow any user-id for OpenShift users.
RUN chown -R app:app ./ && \
chmod -R 777 /home/app/python
USER app
ENV fprocess="python index.py"
EXPOSE 8080
HEALTHCHECK --interval=3s CMD [ -e /tmp/.lock ] || exit 1
CMD ["fwatchdog"]
从COPY --from=watchdog /fwatchdog /usr/bin/fwatchdog可以看出,watchdog作为二进制嵌入到了python的基础镜像中。
经典watchdog的源码比较少,如下图所示:
入口在main.go中,其中的main方法主要做了一下三件事:
- 读取环境变量
- 构造一个httpServer,用来接收外部请求(gateway,openfaas-cli等)
- 构造一个httpServer,作为暴露给prometheus的端点
在目标函数pod中可以看到启动了fwatchdog进程
而且该进程暴露了8080和8081两个端口。这两个端口分别对应着函数入口和promethues metric采集入口。
httpServer端点-函数
该端点使用端口8080暴露。InstrumentHandler是httpServer用来接收外部请求的handleFunc,他使用了prometheus的client-go封装了原始的handleFunc,用来提供发出请求时记录metric的功能。
http.HandleFunc("/", metrics.InstrumentHandler(makeRequestHandler(&config), httpMetrics))
封装的promethues metric的参数如下:
httpMetrics := metrics.NewHttp()
func NewHttp() Http {
return Http{
RequestsTotal: promauto.NewCounterVec(prometheus.CounterOpts{
Subsystem: "http",
Name: "requests_total",
Help: "total HTTP requests processed",
}, []string{"code", "method"}),
RequestDurationHistogram: promauto.NewHistogramVec(prometheus.HistogramOpts{
Subsystem: "http",
Name: "request_duration_seconds",
Help: "Seconds spent serving HTTP requests.",
Buckets: prometheus.DefBuckets,
}, []string{"code", "method"}),
}
}
该方法中的NewXXXVec会将metric注册到promethues的注册中心(Registerer),该类型的方法可以注册多个收集器,如果在第一次注册的时候出现错误那就会直接panic。可以看到NewHttp()注册了计数器和直方图,这会在函数调用度量的时候派上用场。
InstrumentHandler会像构造链一样对http.HandlerFunc进行了两次封装。一次是计数统计,一次是调用耗时统计。
//每次加一
counter.With(labels(code, method, r.Method, 0)).Inc()
...
//计算时间差
obs.With(labels(code, method, r.Method, 0)).Observe(time.Since(now).Seconds())
在接受到openfaas-cli或者gateway发送来的请求后,被makeRequestHandler(&config)代理转发,最后执行的逻辑都在方法pipeRequest(config *WatchdogConfig, w http.ResponseWriter, r *http.Request, method string)中
这里执行下面的语句,如下图
//这里的config.faasProcesss是从环境变量中获取的
parts := strings.Split(config.faasProcess, " ")
把faasProcess作为参数,
targetCmd := exec.Command(parts[0], parts[1:]...)
然后执行命令作为标准输入,
writer, _ := targetCmd.StdinPipe()
获取执行命令的标准输出
out, err = targetCmd.Output()
最后进行系列http相关的参数构造,之后通过w.Write(out)将执行的stdout作为http的response输出,完成一次函数调用。
httpServer端点-metric
该端点使用端口8081暴露。如果做过针对promethues SDK开发的同学对这个一定很熟悉,这个端点是用promethues中默认的handler来注册到promethues,这个handler已经使用InstrumentMetricHandler函数和prometheus.DefaultRegisterer进行检测,并且这个方法已经覆盖了promethues metric收集的大部分情况。
metricsMux.Handle("/metrics", promhttp.Handler())
func Handler() http.Handler {
return InstrumentMetricHandler(
prometheus.DefaultRegisterer, HandlerFor(prometheus.DefaultGatherer, HandlerOpts{}),
)
}
使用127.0.0.1:8081/metrics访问可以看到所有暴露的指标,如下图所示:
of-watchdog
of-watchdog是上述经典watchdog的补充。它于2017年10月启动,为STDIO提供了一种替代方案,用于watchdog和函数之间的通信。 相比起of-watchdog,经典的watchdog更像是cgi程序。改进后的watchdog更加贴合httpserver或者是fastcgi程序。
官方文档上介绍这种新型的watchdog,由于新增提供了http微服务和STDIO的反向代理,从而可以重用内存并非常快速地处理请求。而且它并非旨在取代经典Watchdog,而是为需要这些功能的用户提供了另一个选择。其中默认的分叉模型具有诸如流程隔离,可移植性和简单性等优点。无需任何附加代码,可以使任何过程都可以成为一个函数。of-watchdog和" HTTP"模式在所有请求之间维护一个单一进程,这种设计对于程序来说是一种优化。
新型watchdog主要有以下三个诉求:
- 通过使用HTTP来保持目标函数进程的活跃,以此达到降低延迟/持续缓存/持久连接的需求
- 启用来自函数的大型响应流,这种流一般超出容器的RAM或磁盘容量
- 每个“模式”的抽象更清晰
新型watchdog组件适合在生产中使用,并且是openfaas-incubator GitHub组织的一部分。github.com/openfaas-in…
以下是三种watchdog模式的比较。左上方-Classic Watchdog,右上方:of-watchdog中的afterburn(已弃用),左下方的HTTP模式。
在查看了of-watchdog的各种语言的模板,也能发现of-watchdog要求函数体写在一个固定的httpServer模板中。比如node template的对比,node只支持经典watchdog,而node12支持of-watchdog。
node template中的index.js
getStdin().then(val => {
const cb = (err, res) => {
if (err) {
return console.error(err);
}
if (!res) {
return;
}
if(Array.isArray(res) || isObject(res)) {
console.log(JSON.stringify(res));
} else {
process.stdout.write(res);
}
} // cb ...
const result = handler(val, cb);
if (result instanceof Promise) {
result
.then(data => cb(undefined, data))
.catch(err => cb(err, undefined))
;
}
}).catch(e => {
console.error(e.stack);
});
node12中的index.js
app.post('/*', middleware);
app.get('/*', middleware);
app.patch('/*', middleware);
app.put('/*', middleware);
app.delete('/*', middleware);
const port = process.env.http_port || 3000;
app.listen(port, () => {
console.log(`OpenFaaS Node.js listening on port: ${port}`)
});
of-watchdog中的各种模式
监视程序有几种可用的模式,这些模式可以更改其与微服务或功能代码的交互方式。
main.go中会根据不同的参数配置构造不同的handler
func buildRequestHandler(watchdogConfig config.WatchdogConfig) http.Handler {
...
switch watchdogConfig.OperationalMode {
case config.ModeStreaming:
requestHandler = makeForkRequestHandler(watchdogConfig)
break
case config.ModeSerializing:
requestHandler = makeSerializingForkRequestHandler(watchdogConfig)
break
case config.ModeAfterBurn:
requestHandler = makeAfterBurnRequestHandler(watchdogConfig)
break
case config.ModeHTTP:
requestHandler = makeHTTPRequestHandler(watchdogConfig)
break
case config.ModeStatic:
requestHandler = makeStaticRequestHandler(watchdogConfig)
...
}
...
return requestHandler
}
它们各自封装了不同的代理逻辑,做了更高层次的抽象,而不是像经典watchdog那样所有函数都通过http.HandlerFunc来做简单的封装。
比如http模式就封装了HTTPFunctionRunner结构体,它创建并维护一个负责处理所有调用的进程。它包含了输入io,输出io和httpclient等成员字段(属性)。
HTTPFunctionRunner主要实现了两个方法
- start:启动watchdog时执行,构造了代理httpClient,启动forking进程用来处理请求
- run:收到请求时执行,接受Request和ResponseWriter参数,使用start方法中构造好的client访问函数模块中的httpServer(url:127.0.0.1:8081)
下面是watchdog组件的各种模式简介。
HTTP (mode=http)
这种模式是以后的缺省模式。他提供的各种语言的模板将提供更加丰富的功能。比如为golang提供的模板,可提供对HTTP请求和响应的额外控制。由于进程保持活跃,它们都将比传统的watchdog处理更高的吞吐量。如果需求更高的吞吐量,可以使用这种类型的模板来构建函数。
watchdog启动时会分叉一个进程,然后我们将传入watchdog的所有请求转发到容器内的HTTP端口。
优点:
- 提供高并发性和吞吐量的最快选择
- 与forking模型相比,更有效的并发和RAM使用率 (这里说的forking模型指的是下面提到的,作为默认类型的streammodel,因为它的原理是forking request)
- 数据库连接可以在容器的生命周期内保持不变
- 可以将文件或模型作为一次性的初始化任务提取并存储在/ tmp /中,然后用于所有请求。
- 不需要像Afterburn这样的新的/自定义客户端库,而是使用了长时间运行的守护进程,例如Express.js(用于Node)或Flask(用于Python)
缺点:
- 客户端和函数之间又有一个HTTP接续点(传统的watchdog没有httpclient)
- 以这种方式使用时,诸如express / flask / sinatra之类的守护进程可能无法预测,因此许多守护进程都需要进行额外的配置
- 与forking模型相比可能会占用更多内存
这种类型的watchdog是如何做到比经典watchdog更高的吞吐量的呢?
因为在请求到达前,也就是HTTPFunctionRunner的start方法中,就已经构造好了关键字段http.Client,在start方法中通过makeProxyClient构造http.Transport。这个client的用法相当于连接池,有全局复用的作用。
func (f *HTTPFunctionRunner) Start() error {
...
f.Client = makeProxyClient(f.ExecTimeout)
...
}
func makeProxyClient(dialTimeout time.Duration) *http.Client {
proxyClient := http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
//用于创建http连接
DialContext: (&net.Dialer{
Timeout: dialTimeout,
KeepAlive: 10 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
//用于支持长连接
DisableKeepAlives: false,
IdleConnTimeout: 500 * time.Millisecond,
ExpectContinueTimeout: 1500 * time.Millisecond,
},
//禁止重定向,会在重试循环中使用到
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
return &proxyClient
}
而在Run方法中,就可以直接使用HTTPFunctionRunner中的Client来发送请求,
func (f *HTTPFunctionRunner) Run(req FunctionRequest, contentLength int64, r *http.Request, w http.ResponseWriter) error {
...
res, err := f.Client.Do(request.WithContext(reqCtx))
...
}
而且在Run方法中,特意封装了超时返回的逻辑,
if f.ExecTimeout.Nanoseconds() > 0 {
//如果设置了超时时间就传入控制超时ctx
reqCtx, cancel = context.WithTimeout(r.Context(), f.ExecTimeout)
} else {
reqCtx = r.Context()
cancel = func()
}
}
res, err := f.Client.Do(request.WithContext(reqCtx))
client中的是url地址是在构建函数镜像时就指定了的固定url,是功能函数中暴露出来的httpserver的地址,比如java8,这里的upstream的值就为**http://127.0.0.1:8082**,这里和经典watchdog不同的是,经典watchdog是直接在这里获取exec.Cmd(process,args)的标准输出,而of-watchdog为了提升性能,已经提前做执行了exec.Cmd(process,args)的逻辑,然后通过http协议暴露出来(127.0.0.1:8082)。
public static void main(String[] args) throws Exception {
int port = 8082;
IHandler handler = new com.openfaas.function.Handler();
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
InvokeHandler invokeHandler = new InvokeHandler(handler);
server.createContext("/", invokeHandler);
server.setExecutor(null); // creates a default executor
server.start();
}
Serializing fork (mode=serializing)
此模式旨在复制原始watchdog的行为以实现向后兼容。这种模式的每个请求都会fork一个进程。所以是多线程的。改装CGI应用程序处理程序(例如Flask,传统cgi每个请求都会启动一个进程)的理想选择。
将整个请求从HTTP请求读取到内存中。此时,如果需要,我们可以序列化或修改相应。然后将其写入stdin管道。 比如如下的操作:
- 将标准输出管道读入内存,然后在必要时进行序列化或修改,然后再写回HTTP响应。
- 可以提前设置静态的Content-type。
- 即使执行该功能(未实现),也可以设置HTTP标头。
- 支持执行超时的场景
Streaming fork (mode=streaming) - default.
每个请求forking一个进程,并且可以处理大于内存容量的请求主体-即512mb VM可以处理多个GB的视频。 这种模型是来源于经典watchdog,是他的更高层次抽象,也是of-watchdog架构的默认模型。这种模型将cmd.Command逻辑放到了ForkFunctionRunner的Run方法中,其中逻辑更贴近经典watchdog。
函数开始执行后,由于直接将输入/输出连接到流,因此无法发送HTTP标头。除非forking进程本身出现问题,否则响应代码始终为200。必须在客户端上获取运行中的错误。本是是多线程的。
- 输入在执行过程中被打印到标准输出后,立即发送回客户端。
- 可以提前设置静态的Content-type。
- 支持执行超时的场景
Static (mode=static)
此模式启动HTTP文件服务器,以提供在static_path指定的目录中找到的静态内容。
Afterburn (mode=afterburn)
不推荐。所有请求使用同一个进程。
使用of-watchdog提升性能
可以从下图查找支持of-watchdog的语言
| Name | Language | Version | Linux base | Watchdog | Link |
|---|---|---|---|---|---|
| node12 | NodeJS | 12.13.0 | Alpine Linux | of-watchdog | NodeJS template |
| node | NodeJS | 12.13.0 | Alpine Linux | classic | NodeJS template |
| python3 | Python | 3 | Alpine Linux | classic | Python 3 template |
| python3-debian | Python | 3 | Debian Linux | classic | Python 3 Debian template |
| python | Python | 2.7 | Alpine Linux | classic | Python 2.7 template |
| go | Go | 1.11 | Alpine Linux | classic | Go template |
| csharp | C# | N/A | Debian GNU/Linux 9 | classic | C# template |
| java11-vert-x | Java and Vert.x | 11 | Debian GNU/Linux | of-watchdog | Java LTS template |
| java11 | Java | 11 | Debian GNU/Linux | of-watchdog | Java LTS template |
| java8 | Java | 8 | OpenJDK Alpine Linux | of-watchdog | Java EOL template |
| ruby | Ruby | 2.5.1 | Alpine Linux 3.7 | classic | Ruby template |
| php7 | PHP | 7.2 | Alpine Linux | classic | PHP 7 template |
| dockerfile | Dockerfile | N/A | Alpine Linux | classic | Dockerfile template |
使用java8来构建函数镜像,如下图:
Dockerfile中直接指定了upstream等参数
...
ENV upstream_url="http://127.0.0.1:8082"
ENV mode="http"
ENV CLASSPATH="/home/app/entrypoint-1.0/lib/*"
//参数保证在jdk8-9中能够正确识别容器内存
ENV fprocess="java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap com.openfaas.entrypoint.App"
EXPOSE 8080
...
阅读更多关于watchdog的信息 。github.com/openfaas-in…
watchdog的http模式使用自定义模板。github.com/openfaas-in…