Categories
程式開發

传统框架部署到Serverless架构的利与弊


Serverless是一个比较新的概念、架构,让开发者放弃之前的开发习惯、放弃现有的Express、Koa、Flask、Django等框架,无缝转向Serverless架构,显然是不可能的,必须得有一段过渡和适应的时间。在这段时间内,开发者需要思考是否可以将现有的框架部署到Serverless架构上?如果要部署,如何才能顺利上云呢?

Web框架在Serverless上的表现

首先,我们以Flask框架进行一个简单的测试:

测试四种接口:

  • Get请求(可能涉及到通过路径传递参数)
  • Post请求(通过Formdata传递参数)
  • Get请求(通过url参数进行参数传递)
  • Get请求(带有jieba等计算功能)

测试两种情况:

  • 本地表现
  • 通过Flask-Component部署表现

测试两种性能:

  • 传统云服务器上的性能表现
  • 云函数性能表现

测试代码如下:

from flask import Flask, redirect, url_for, request
import jieba
import jieba.analyse

app = Flask(__name__)


@app.route('/hello/')
def success(name):
    return 'hello %s' % name


@app.route('/welcome/post', methods=['POST'])
def welcome_post():
    user = request.form['name']
    return 'POST %s' % user


@app.route('/welcome/get', methods=['GET'])
def welcome_get():
    user = request.args.get('name')
    return 'GET %s' % user


@app.route('/jieba/', methods=['GET'])
def jieba_test():
    str = "Serverless Framework 是业界非常受欢迎的无服务器应用框架,开发者无需关心底层资源即可部署完整可用的 Serverless 应用架构。Serverless Framework 具有资源编排、自动伸缩、事件驱动等能力,覆盖编码、调试、测试、部署等全生命周期,帮助开发者通过联动云资源,迅速构建 Serverless 应用。"
    print(", ".join(jieba.cut(str)))
    print(jieba.analyse.extract_tags(str, topK=20, withWeight=False, allowPOS=()))
    print(jieba.analyse.textrank(str, topK=20, withWeight=False, allowPOS=('ns', 'n', 'vn', 'v')))
    return 'success'


if __name__ == '__main__':
    app.run(debug=True)

这段测试代码是比较有趣的,它包括了最常用的请求方法、传参方法,同时还囊括了简单的接口和稍微复杂的接口。

本地表现

本地运行之后,通过Postman进行三个接口简单测试:

  • Get请求:

传统框架部署到Serverless架构的利与弊 1

  • Post参数传递:

传统框架部署到Serverless架构的利与弊 2

  • Get参数传递:

传统框架部署到Serverless架构的利与弊 3

通过Flask-Component部署表现

接下来,我们将这个代码部署到云函数中:

Flask-Component的部署操作,可以参考Tencent给予的文档,Github地址https://github.com/serverless-components/tencent-flask

Yaml文档内容:

FlaskComponent:
  component: '@gosls/tencent-flask'
  inputs:
    region: ap-beijing
    functionName: Flask_Component
    code: ./flask_component
    functionConf:
      timeout: 10
      memorySize: 128
      environment:
        variables:
          TEST: vale
      vpcConfig:
        subnetId: ''
        vpcId: ''
    apigatewayConf:
      protocols:
        - http
      environment: release

部署完成

传统框架部署到Serverless架构的利与弊 4

接下来测试三个目标接口

  • Get通过路径传参:

传统框架部署到Serverless架构的利与弊 5

  • Post参数传递:

传统框架部署到Serverless架构的利与弊 6

  • Get参数传递:

传统框架部署到Serverless架构的利与弊 7

从上面的测试,我们可以看出,通过Flask-Component部署的云函数同样可以具备常用的几种请求形式和传参形式。一般情况下,用户的Flask项目可以直接通过腾讯云提供的Flask-component快速部署到Serverless架构上,并获得比较良好的运行。

简单的性能测试

接下来我们对性能进行一些简单的测试,首先购买一个云服务器,将这个部分代码部署到云服务器上。

首先,我们购买了1核2G的云服务器

传统框架部署到Serverless架构的利与弊 8

配置环境,使得服务可以正常运行:

传统框架部署到Serverless架构的利与弊 9

传统框架部署到Serverless架构的利与弊 10

通过Post设置简单的Tests:

传统框架部署到Serverless架构的利与弊 11

对接口进行测试:

传统框架部署到Serverless架构的利与弊 12

完成接口测试:

传统框架部署到Serverless架构的利与弊 13
传统框架部署到Serverless架构的利与弊 14

通过接口测试结果进行部分可视化:

传统框架部署到Serverless架构的利与弊 15

统计数据:

传统框架部署到Serverless架构的利与弊 16

通过上面的图表,我们可以看到服务器的整体响应时间都快于云函数的响应时间,同时函数是存在冷启动的,一旦出现冷启动,其响应时间会增长20余倍。

接下来,我们再测试一下稍微复杂的接口
在由于上述测试,仅仅是非常简单的接口,接下来我们来测试一下稍微复杂的接口,使用了jieba分词的接口。

测试结果:

传统框架部署到Serverless架构的利与弊 17

可视化结果:

传统框架部署到Serverless架构的利与弊 18

传统框架部署到Serverless架构的利与弊 19

根据对Jieba接口的测试,我们发现虽然服务器也会有因分词组件进行初始化而产生比较慢的响应时间,但是整体而言,速度依旧是远远低于云函数。

那么问题来了,这是函数本身的性能有问题,还是增加了Flask框架+APIGW响应集成之后才有问题?

接下来,我们做一组新的接口测试,在函数中,直接返回内容,不进行额外处理,看看函数+API网关性能和正常情况下的服务器性能对比

传统框架部署到Serverless架构的利与弊 20

传统框架部署到Serverless架构的利与弊 21

从上图我们可以看出最小和平均耗时的区别不是很大,最大耗时基本上是持平,框架的加载会导致函数冷启动时间长度变得异常可怕。

通过Python代码,我们对Flask框架进行并发测试:

对函数进行3次压测,每次并发300:

===========task end===========
total:301,succ:301,fail:0,except:0
response maxtime: 1.2727971077
response mintime 0.573610067368

===========task end===========
total:301,succ:301,fail:0,except:0
response maxtime: 1.1745698452
response mintime 0.172255039215

===========task end===========
total:301,succ:301,fail:0,except:0
response maxtime: 1.2857568264
response mintime 0.157210826874

对服务器进行3次压测,同样是每次并发300:

===========task end===========
total:301,succ:301,fail:0,except:0
response maxtime: 3.41151213646
response mintime 0.255661010742

===========task end===========
total:301,succ:301,fail:0,except:0
response maxtime: 3.37784004211
response mintime 0.212490081787

===========task end===========
total:301,succ:301,fail:0,except:0
response maxtime: 3.39548277855
response mintime 0.439364910126

这一波压测,我们可以看到了一个奇怪现象:在函数和服务器预热完成之后,连续三次并发301个请求,函数的整体表现反而比服务器的要好。这说明在Serverless架构下,弹性伸缩发挥了重要作用。传统服务器,如果出现了高并发现象,很容易会导致整体服务受到严重影响,例如响应时间变长,无响应,甚至是服务器直接挂掉,但是在Serverless架构下,具备弹性伸缩能力,因此当并发量达到一定的时候,优势就会凸显出来。

传统Web框架上云方法(以Python Web框架为例)

分析已有Component(Flask为例)

首先,我们要知道其他的框架是怎么运行的,例如Flask等。我们先按照腾讯云的Flask-Component的说明部署一下:

传统框架部署到Serverless架构的利与弊 22

部署上线之后,在函数的控制台把部署好的下载下来:

传统框架部署到Serverless架构的利与弊 23

下载解压之后,我们可以看到这样一个目录结构:

传统框架部署到Serverless架构的利与弊 24

蓝色框中的是依赖包,黄色的app.py是我们自己写的代码,而红色框中的是什么?

api_server.py文件内容:

import app  # Replace with your actual application
import severless_wsgi

# If you need to send additional content types as text, add then directly
# to the whitelist:
#
# serverless_wsgi.TEXT_MIME_TYPES.append("application/custom+json")

def handler(event, context):
    return severless_wsgi.handle_request(app.app, event, context)

可以看到,这是将我们创建的app.py文件引入,并且拿到了app对象,将event和context同时传递给severless_wsgi.py中的handle_reques方法中,那么问题来了,这个方法是什么?

传统框架部署到Serverless架构的利与弊 25

再观察一下这段代码:

传统框架部署到Serverless架构的利与弊 26

传统框架部署到Serverless架构的利与弊 27

实际上,这一段代码就是将我们拿到的参数(event和context)进行转换,转换之后统一到environ中,通过werkzeug依赖将其变成request对象,并与app对象一起调用from_app方法。

传统框架部署到Serverless架构的利与弊 28

按照API网关的响应集成的格式,将结果返回。

相信执行到这里,大家可能就有所感悟了,我们再看一下Flask/Django这些框架的实现原理:

传统框架部署到Serverless架构的利与弊 29

根据简版原理图,相信大家都明白正常用的时候要通过web_server,进入到下一个环节,而云函数更多是一个函数,不需要启动web server,所以可以直接调用wsgi_app方法,其中environ就是对event/context等进行处理后的对象,start_response可以认为是一种特殊的数据结构,例如response结构形态等。

因此,如果我们想要自己动手实现这个过程,可以这样做:

import sys

try:
    from urllib import urlencode
except ImportError:
    from urllib.parse import urlencode

from flask import Flask

try:
    from cStringIO import StringIO
except ImportError:
    try:
        from StringIO import StringIO
    except ImportError:
        from io import StringIO

from werkzeug.wrappers import BaseRequest

__version__ = '0.0.4'


def make_environ(event):
    environ = {}
    for hdr_name, hdr_value in event['headers'].items():
        hdr_name = hdr_name.replace('-', '_').upper()
        if hdr_name in ['CONTENT_TYPE', 'CONTENT_LENGTH']:
            environ[hdr_name] = hdr_value
            continue

        http_hdr_name = 'HTTP_%s' % hdr_name
        environ[http_hdr_name] = hdr_value

    apigateway_qs = event['queryStringParameters']
    request_qs = event['queryString']
    qs = apigateway_qs.copy()
    qs.update(request_qs)

    body = ''
    if 'body' in event:
        body = event['body']

    environ['REQUEST_METHOD'] = event['httpMethod']
    environ['PATH_INFO'] = event['path']
    environ['QUERY_STRING'] = urlencode(qs) if qs else ''
    environ['REMOTE_ADDR'] = 80
    environ['HOST'] = event['headers']['host']
    environ['SCRIPT_NAME'] = ''
    environ['SERVER_PORT'] = 80
    environ['SERVER_PROTOCOL'] = 'HTTP/1.1'
    environ['CONTENT_LENGTH'] = str(len(body))
    environ['wsgi.url_scheme'] = ''
    environ['wsgi.input'] = StringIO(body)
    environ['wsgi.version'] = (1, 0)
    environ['wsgi.errors'] = sys.stderr
    environ['wsgi.multithread'] = False
    environ['wsgi.run_once'] = True
    environ['wsgi.multiprocess'] = False

    BaseRequest(environ)

    return environ


class LambdaResponse(object):
    def __init__(self):
        self.status = None
        self.response_headers = None

    def start_response(self, status, response_headers, exc_info=None):
        self.status = int(status[:3])
        self.response_headers = dict(response_headers)


class FlaskLambda(Flask):
    def __call__(self, event, context):
        if 'httpMethod' not in event:
            print('httpMethod not in event')
            return super(FlaskLambda, self).__call__(event, context)

        response = LambdaResponse()

        body = next(self.wsgi_app(
            make_environ(event),
            response.start_response
        ))

        return {
            'statusCode': response.status,
            'headers': response.response_headers,
            'body': body
        }

整个实现过程可以认为是对web server部分进行了一种“截断”或者是“替换”:

传统框架部署到Serverless架构的利与弊 30

以上就是对Flask-Component的基本分析思路,按照这个思路我们是否可以将Django框架也部署在Serverless架构上呢?Flask和Django有什么区别呢?(特指运行启动过程)

拓展思路:实现Django-component

我们是否可以直接使用Flask的转换逻辑,将flask的app替换成django的app?
把:

from flask import Flask
app = Flask(__name__)

替换成:

import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mydjango.settings')
application = get_wsgi_application()

替换完成,我们来测试一下:

传统框架部署到Serverless架构的利与弊 31

建立好Django项目,直接增加index.py:

# -*- coding: utf-8 -*-

import os
import sys
import base64
from werkzeug.datastructures import Headers, MultiDict
from werkzeug.wrappers import Response
from werkzeug.urls import url_encode, url_unquote
from werkzeug.http import HTTP_STATUS_CODES
from werkzeug._compat import BytesIO, string_types, to_bytes, wsgi_encoding_dance
import mydjango.wsgi

TEXT_MIME_TYPES = [
    "application/json",
    "application/javascript",
    "application/xml",
    "application/vnd.api+json",
    "image/svg+xml",
]


def all_casings(input_string):
    if not input_string:
        yield ""
    else:
        first = input_string[:1]
        if first.lower() == first.upper():
            for sub_casing in all_casings(input_string[1:]):
                yield first + sub_casing
        else:
            for sub_casing in all_casings(input_string[1:]):
                yield first.lower() + sub_casing
                yield first.upper() + sub_casing


def split_headers(headers):
    """
    If there are multiple occurrences of headers, create case-mutated variations
    in order to pass them through APIGW. This is a hack that's currently
    needed. See: https://github.com/logandk/serverless-wsgi/issues/11
    Source: https://github.com/Miserlou/Zappa/blob/master/zappa/middleware.py
    """
    new_headers = {}

    for key in headers.keys():
        values = headers.get_all(key)
        if len(values) > 1:
            for value, casing in zip(values, all_casings(key)):
                new_headers[casing] = value
        elif len(values) == 1:
            new_headers[key] = values[0]

    return new_headers


def group_headers(headers):
    new_headers = {}

    for key in headers.keys():
        new_headers[key] = headers.get_all(key)

    return new_headers


def encode_query_string(event):
    multi = event.get(u"multiValueQueryStringParameters")
    if multi:
        return url_encode(MultiDict((i, j) for i in multi for j in multi[i]))
    else:
        return url_encode(event.get(u"queryString") or {})


def handle_request(application, event, context):

    if u"multiValueHeaders" in event:
        headers = Headers(event["multiValueHeaders"])
    else:
        headers = Headers(event["headers"])

    strip_stage_path = os.environ.get("STRIP_STAGE_PATH", "").lower().strip() in [
        "yes",
        "y",
        "true",
        "t",
        "1",
    ]
    if u"apigw.tencentcs.com" in headers.get(u"Host", u"") and not strip_stage_path:
        script_name = "/{}".format(event["requestContext"].get(u"stage", ""))
    else:
        script_name = ""

    path_info = event["path"]
    base_path = os.environ.get("API_GATEWAY_BASE_PATH")
    if base_path:
        script_name = "/" + base_path

        if path_info.startswith(script_name):
            path_info = path_info[len(script_name) :] or "/"

    if u"body" in event:
        body = event[u"body"] or ""
    else:
        body = ""

    if event.get("isBase64Encoded", False):
        body = base64.b64decode(body)
    if isinstance(body, string_types):
        body = to_bytes(body, charset="utf-8")

    environ = {
        "CONTENT_LENGTH": str(len(body)),
        "CONTENT_TYPE": headers.get(u"Content-Type", ""),
        "PATH_INFO": url_unquote(path_info),
        "QUERY_STRING": encode_query_string(event),
        "REMOTE_ADDR": event["requestContext"]
        .get(u"identity", {})
        .get(u"sourceIp", ""),
        "REMOTE_USER": event["requestContext"]
        .get(u"authorizer", {})
        .get(u"principalId", ""),
        "REQUEST_METHOD": event["httpMethod"],
        "SCRIPT_NAME": script_name,
        "SERVER_NAME": headers.get(u"Host", "lambda"),
        "SERVER_PORT": headers.get(u"X-Forwarded-Port", "80"),
        "SERVER_PROTOCOL": "HTTP/1.1",
        "wsgi.errors": sys.stderr,
        "wsgi.input": BytesIO(body),
        "wsgi.multiprocess": False,
        "wsgi.multithread": False,
        "wsgi.run_once": False,
        "wsgi.url_scheme": headers.get(u"X-Forwarded-Proto", "http"),
        "wsgi.version": (1, 0),
        "serverless.authorizer": event["requestContext"].get(u"authorizer"),
        "serverless.event": event,
        "serverless.context": context,
        # TODO: Deprecate the following entries, as they do not comply with the WSGI
        # spec. For custom variables, the spec says:
        #
        #   Finally, the environ dictionary may also contain server-defined variables.
        #   These variables should be named using only lower-case letters, numbers, dots,
        #   and underscores, and should be prefixed with a name that is unique to the
        #   defining server or gateway.
        "API_GATEWAY_AUTHORIZER": event["requestContext"].get(u"authorizer"),
        "event": event,
        "context": context,
    }

    for key, value in environ.items():
        if isinstance(value, string_types):
            environ[key] = wsgi_encoding_dance(value)

    for key, value in headers.items():
        key = "HTTP_" + key.upper().replace("-", "_")
        if key not in ("HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH"):
            environ[key] = value

    response = Response.from_app(application, environ)

    returndict = {u"statusCode": response.status_code}

    if u"multiValueHeaders" in event:
        returndict["multiValueHeaders"] = group_headers(response.headers)
    else:
        returndict["headers"] = split_headers(response.headers)

    if event.get("requestContext").get("elb"):
        # If the request comes from ALB we need to add a status description
        returndict["statusDescription"] = u"%d %s" % (
            response.status_code,
            HTTP_STATUS_CODES[response.status_code],
        )

    if response.data:
        mimetype = response.mimetype or "text/plain"
        if (
            mimetype.startswith("text/") or mimetype in TEXT_MIME_TYPES
        ) and not response.headers.get("Content-Encoding", ""):
            returndict["body"] = response.get_data(as_text=True)
            returndict["isBase64Encoded"] = False
        else:
            returndict["body"] = base64.b64encode(response.data).decode("utf-8")
            returndict["isBase64Encoded"] = True

    return returndict



def main_handler(event, context):
    return handle_request(mydjango.wsgi.application, event, context)

将其部署到函数上,看一下效果:
函数信息:

from django.shortcuts import render
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

# Create your views here.
@csrf_exempt
def hello(request):
    if request.method == "POST":
        return HttpResponse("Hello world ! " + request.POST.get("name"))
    if request.method == "GET":
        return HttpResponse("Hello world ! " + request.GET.get("name"))

部署完成,绑定apigw触发器,并在postman中进行测试:
get:

传统框架部署到Serverless架构的利与弊 32

post:

传统框架部署到Serverless架构的利与弊 33

通过对运行原理的基本剖析和对django的改造,我们已经通过增加一个文件和相关依赖的方法,成功将Django搬上了Serverless。

接下来,我们看一下,如何将代码写成一个Component:
首先Clone下来Flask-Component的代码:

传统框架部署到Serverless架构的利与弊 34

按照Django的部分模式进行修改:

传统框架部署到Serverless架构的利与弊 35

第一部分,是我们可能会依赖的一个依赖包,以及刚才放入的index.py文件。在用户调用这个Component的时候,我们会把这两个文件放入用户的代码中,一并上传。

第二部分是Serverless.js,下面是一个基本格式:

const { Component } = require('@serverless/core')
class TencentDjango extends Component {
  async default(inputs = {}) {
  }
  async remove(inputs = {}) {
  }
}
module.exports = TencentDjango

用户在执行sls时会默认调用default方法,在执行sls remove时会调用remove方法,所以可以认为default的内容是部署,而remove的内容是移除。

主要流程的部署也很简单,首先将文件进行复制和处理,然后直接调用云函数的组件,通过函数中的include参数将这些文件额外加入,再通过调用apigw的组件来进网关的管理。用户写的yaml中inpust的内容会在inputs中获取,我们要做的就是对应的传给不同的组件:

传统框架部署到Serverless架构的利与弊 36

除了将这两部分对应部署,region等一些信息也要进行对应处理。调用底层组件方法也很简单:

const tencentCloudFunction = await this.load('@serverless/tencent-scf'
const tencentCloudFunctionOutputs = await tencentCloudFunction(inputs)

处理好之后,只需要修改一下package.json和readme就可以了。

至此,我们完成了一个Django Component的开发,发布到NPM后,在使用时只需要引入这个Component即可:

DjangoTest:
  component: '@serverless/tencent-django'
  inputs:
    region: ap-guangzhou
    functionName: DjangoFunctionTest
    djangoProjectName: mydjango
    code: ./
    functionConf:
      timeout: 10
      memorySize: 256
      environment:
        variables:
          TEST: vale
      vpcConfig:
        subnetId: ''
        vpcId: ''
    apigatewayConf:
      protocols:
        - http
      environment: release

总结:

Flask可以通过很简单的方法部署在Serverless架构上,用户基本可以按照原生Flask开发习惯来开发Flask项目,尤其是使用Flask开发接口服务的项目。

整体框架迁移上Serverless架构有几个需要额外注意的点:

  1. 如果接口比较多,需要按照资源消耗比较大的那个接口来设置内存大小。如果是按照文章中的例子,非jieba接口使用时的最小内存(64M),而jieba接口却需要256M的内存。因为项目是一体的,只能设置一个内存,所以为了保证项目可用性,应该整体设置为256M的内存,这样一来在另外三个接口访问比较多的前提下,资源消耗可能会相对增加比较大,有条件的话,可考虑将资源消耗比较大的接口额外提取出来;

  2. 云函数+API网关的组合对静态资源以及文件上传等的支持并不是十分友好,尤其是云函数+API网关的双重收费,所以建议将Flask中的静态资源统一放在对象存储中,同时将文件上传逻辑修改成优先上传到对象存储中。

框架越大、框架内的资源越多,函数冷启动的时间就会越大。在上文的测试过程中,非框架下,最高耗时是平均耗时的3倍,而在加载Flask框架和Jieba的前提下,最高耗时是平均的10+倍!如果能保证函数都是热启动还好,一旦出现冷启动,就会有一定的影响。

由于用户发起请求是客户端到API网关再到函数,然后从函数回到API网关,再回到客户端。相对于直接访问服务器获得结果,这个过程明显链路更长,所以在实际测试过程中,用户量较少的时候,表现不是很好,几次测试基本上都是1核2G的服务器优于函数。但当并发量上来的之后,函数的表现实现了大超车。因此,我们可以得出一个有趣的结论:对于极小规模请求,函数是按量付费,在性能上有一定的劣势,但在价格上有一定的优势;当流量逐渐变大之后,函数在性能上的优势也逐渐凸显。

除了对传统Web框架部署到Serverless架构的利弊分析之外,通过对Flask框架进行分析,我们可以总结出Web框架搬上Serverless架构的原理思路,虽然说Serverless团队很活跃,各个云厂商也很活跃,但在实际生产中,必然会用到很多定制化组件,Serverless Component本身就是组件化的产品,如果深度使用就可以定制化开发Component满足自身需求,这时候就要知道一些原理和技巧。

我相信,Serverless架构虽然目前还存在一些问题,但一定会随着时间发展得越发成熟!