OpenResty 不完全指南

OpenResty 简介

OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台。我们知道开发 Nginx 的模块需要用 C 语言,同时还要熟悉它的源码,成本和门槛比较高。国人章亦春把 LuaJIT VM 嵌入到了 Nginx 中,使得可以直接通过 Lua 脚本在 Nginx 上进行编程,同时还提供了大量的类库(如:lua-resty-mysql lua-resty-redis 等),直接把一个 Nginx 这个 Web Server 扩展成了一个 Web 框架,借助于 Nginx 的高性能,能够快速地构造出一个足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。

Nginx 采用的是 master-worker 模型,一个 master 进程管理多个 worker 进程,worker 真正负责对客户端的请求处理,master 仅负责一些全局初始化,以及对 worker 进行管理。在 OpenResty 中,每个 worker 中有一个 Lua VM,当一个请求被分配到 worker 时,worker 中的 Lua VM 里创建一个 coroutine(协程) 来负责处理。协程之间的数据隔离,每个协程具有独立的全局变量 _G

ngx_lua works.png

OpenResty 处理请求流程

由于 Nginx 把一个请求分成了很多阶段,第三方模块就可以根据自己的行为,挂载到不同阶段处理达到目的。OpenResty 也应用了同样的特性。不同的阶段,有不同的处理行为,这是 OpenResty 的一大特色。OpenResty 处理一个请求的流程参考下图(从 Request start 开始):

image

指令 使用范围 解释
int_by_lua* init_worker_by_lua* http 初始化全局配置/预加载Lua模块
set_by_lua* server,server if,location,location if 设置nginx变量,此处是阻塞的,Lua代码要做到非常快
rewrite_by_lua* http,server,location,location if rewrite阶段处理,可以实现复杂的转发/重定向逻辑
access_by_lua* http,server,location,location if 请求访问阶段处理,用于访问控制
content_by_lua* location, location if 内容处理器,接收请求处理并输出响应
header_filter_by_lua* http,server,location,location if 设置 heade 和 cookie
body_filter_by_lua* http,server,location,location if 对响应数据进行过滤,比如截断、替换
log_by_lua http,server,location,location if log阶段处理,比如记录访问量/统计平均响应时间

更多详情请参考官方文档

配置 OpenResty

OpenResty 的 Lua 代码是提现在 nginx.conf 的配置文件之中的,可以与配置文件写在一起,也可以把 Lua 脚本放在一个文件中进行加载:

内联在 nginx.conf 中:

server {
    ...
    location /lua_content {
         # MIME type determined by default_type:
         default_type 'text/plain';

         content_by_lua_block {
             ngx.say('Hello,world!')
         }
    }
    ....
}    

通过加载 lua 脚本的方式:

server {
    ...
    location = /mixed {
         rewrite_by_lua_file /path/to/rewrite.lua;
         access_by_lua_file /path/to/access.lua;
         content_by_lua_file /path/to/content.lua;
     }
    ....
} 

OpenResty 变量的共享范围

全局变量

在 OpenResty 中,只有在 init_by_lua*init_worker_by_lua* 阶段才能定义真正的全局变量。因为在其他阶段,OpenResty 会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在上述两个阶段可以定义全局变量,也尽量避免这么做。全局变量能解决的问题,用模块变量也能解决,而且会更清晰,干净。

模块变量

这里将定义在 Lua 模块中的变量称为模块变量。Lua VM 会将 require 进来的模块换成到 package.loaded table 里,模块里的变量都会被缓存起来,在同一个 Lua VM下,模块中的变量在每个请求中是共享的,这样就可以避免使用全局变量来实现共享了,看下面一个例子:

nginx.conf

worker_processes  1;

...
location {
    ...
    lua_code_cache on;
    default_type "text/html";
    content_by_lua_file 'lua/test_module_1.lua'
}

lua/test_module_1.lua

local module1 = require("module1")

module1.hello()

lua/module1.lua

local count = 0
local function hello() 
    count = count + 1
    ngx.say("count: ", count)
end

local _M  = {
    hello = hello
}   

return _M

当通过浏览器访问时,可以看到 count 输出是一个递增的,这也说明了在 lua/module1.lua 的模块变量在每个请求中时共享的:

count: 1
count: 2
.....

另外,如果 worker_processes 的数量大于 1 时呢,得到的结果可能就不一样了。因为每个 worker 中都有一个 Lua VM 了,模块变量仅在同一个 VM 下,所有的请求共享。如果要在多个 Worker 进程间共享请考虑使用 ngx.shared.DICT 或如 Redis 存储了。

本地变量

跟全局变量,模块变量相对,我们这里姑且把 *_by_lua* 里定义的变量称为本地变量。本地变量仅在当前阶段有效,如果需要跨阶段使用,需要借助 ngx.ctx 或者附加到模块变量里。

这里我们使用了 ngx.ctx 表在三个不同的阶段来传递使用变量 foo

location /test {
     rewrite_by_lua_block {
         ngx.ctx.foo = 76
     }
     access_by_lua_block {
         ngx.ctx.foo = ngx.ctx.foo + 3
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.foo)
     }
 }

额外注意,每个请求,包括子请求,都有一份自己的 ngx.ctx 表。例如:

 location /sub {
     content_by_lua_block {
         ngx.say("sub pre: ", ngx.ctx.blah)
         ngx.ctx.blah = 32
         ngx.say("sub post: ", ngx.ctx.blah)
     }
 }

 location /main {
     content_by_lua_block {
         ngx.ctx.blah = 73
         ngx.say("main pre: ", ngx.ctx.blah)
         local res = ngx.location.capture("/sub")
         ngx.print(res.body)
         ngx.say("main post: ", ngx.ctx.blah)
     }
 }

访问 GET /main 输出:

main pre: 73
sub pre: nil  # 子请求中并没有获取到父请求的变量 $pre
sub post: 32
main post: 73

性能开关 lua_code_cache

开启或关闭在 *_by_lua_file(如:set_by_lua_file, content_by_lua_file) 指令中以及 Lua 模块中 Lua 代码的缓存。

若关闭,ngx_lua 会为每个请求创建一个独立的 Lua VM,所有 *_by_lua_file 指令中的代码将不会被缓存到内存中,并且所有的 Lua 模块每次都会从头重新加载。在开发模式下,这给我们带来了不需要 reload nginx 就能调试的便利性,但是在生成环境下,强烈建议开启。 若关闭,即使是一个简单的 Hello World 都会慢上一个数量级(每次 IO 读取和编译消耗很大)。

但是,那些直接写在 nginx.conf 配置文件中的 *_by_lua_block 指令下的代码不会在你编辑下实时更新,只有发送 HUP 信号给 Nginx 才能能够重新。

小案例

通过 OpenResty + Redis 实现动态路由

Nginx 经常用来作为反向代理服务器。通常情况下,我们将后端的服务配置在 Nginx 的 upstream 中,当后端服务有变更时就去修改 upstream 中的配置再通过 reload 的方式使其生效。这个操作如果在后端服务经常发生变更的情况下,操作起来就会显得有些繁琐了。现在利用 Lua + Redis 的方式将 upstream 中的配置放在 Redis 中,以实现动态配置的效果。

架构图

image

原理:

在求请求访问阶段处理(access_by_lua*)通过指定的规则(这个规则根据自己的需求去设计)从 Redis 中去获取相对应的后端服务地址去替换 Nginx 配置中的 proxy_pass 的地址。

流程:

  1. 在 Nginx 配置中创建后端服务地址的变量 $backend_server
    server {
        listen 80;
        server_name app1.example.com;

        location / {
            ...
            set $backend_server '';
        }
    }

同时在 Redis 中存入后端服务的地址。

set app1 10.10.10.10:8080
  1. 使用 ngx_redis2 模块来实现一个读取 Redis 的接口。
    # GET /get?key=some_key
    location = /get {
        internal;                        # 保护这个接口只运行内部调用
        set_unescape_uri $key $arg_key;  # this requires ngx_set_misc
        redis2_query get $key;
        redis2_pass foo.com:6379;        # redis_server and port
    }
  1. 在求请求访问阶段处理利用 ngx.location.capture 模块请求去上个阶段定义的 Redis 接口,并将结果替换 $backend_server
    location / {
        ...
        access_by_lua_block {
            local rds_key = "app1"
            # 从 redis 中获取 key 为 app1 对应的 server_ip
            local res = ngx.location.capture('/get', { args = {key = rds_key}})
            # 解析 redis 结果
            local parser = require("redis.parser")
            local server, typ = parser.parse_reply(res.body)
            if typ ~= parser.BULK_REPLY or not server then
                ngx.log(ngx.ERR, "bad redis response: ", res.body)
                ngx.exit(500)
            end

            ngx.var.backend_server = server
        }
    }
  1. Nginx 转发阶段将请求转发至后端服务。
    location / {
        ...
        access_by_lua_block {...};
        proxy_pass http://$backend_server;
    }

最后,推荐两个基于 OpenResty 的比较实用的两个开源项目:

参考

1+

一个创业公司的容器化之路(三) – 为什么要用容器

创业公司应该使用容器的4个原因

前两篇文章(一个创业公司的容器化之路(一) - 容器化之前一个创业公司的容器化之路(二) - 容器化)我们介绍了杏仁架构的发展历程。我们再回顾一下第一篇开头提出的三个初创公司的技术挑战,

  1. 如何快速、低成本的搭建系统,同时确保安全稳定?
  2. 如何快速的构建和发布应用,满足业务需求?
  3. 如何提高团队开发效率,确保开发质量?

杏仁通过容器化来应对这几个挑战,也取得了一些成就,但也说不上完美的解决。毕竟我们规模还小,很多方案也只是在我们这个规模刚好够用。不过在这个过程里,我们还是深刻体会到了容器化的价值。正如我介绍容器的时候说的,容器会和集装箱一样,成为一个标准化的基础设施,对上层应用产品革命性的影响。

很多大厂其实一、两年前就开始做容器方面的尝试。但现在,我们认为其实初创公司也应该考虑应用容器了,原因有以下几点。

1. 容器生态已经成熟,容器服务和容器云可以大幅降低容器使用成本。

去年容器编排还不能说很成熟,例如 Kubernetes 还存在不少缺陷。但经过一年时间狂飙猛进,Kubernetes 今年已经很成熟了,有不少企业已经在生产环境使用了 Kubernetes。部署和运维也比之前简单多了。

另一方面,各大公有云也都推出了容器服务,还有不少独立的容器云公司。如果你是用公有云的话,推荐直接使用相应的容器服务,可以快速的搭建系统,大幅降低运维成本,提高效率。

并且这会带来一个额外的好处,因为这些容器服务都是基于 Kubernetes 的,而容器本身就是一个标准化的东西,使用容器服务反而可以降低对公有云服务的依赖。在公有云的容器服务之间迁移,或者后期打算自建然后迁移,都不是很难的事情,

2. 容器使创业公司可以轻松的应用行业最佳实践,创建优质应用。

几年前有人提出了一个十二要素应用的最佳实践,我认为是一个很好的标准。

下面列表对这十二条实践进行了分析,我分成了三种情况,分别用三颗星、两颗星和一颗星表示。

  • 三颗星:没有容器实践起来很困难,容器环境下可以很容易实现。
  • 两颗星:没有容器也可以实践,但在容器环境下可以做得更好或更轻松。
  • 一颗星:没有容器也可以实践,容器环境里也能支持。
最佳实践 支持程度 说明
基准代码:一份基准代码,多份部署。 ★★ 不仅一份代码多份部署,容器能做到一份镜像,多分部署。
依赖:显式声明依赖关系。 ★★★ 容器通过 Dockerfile 管理内部依赖,实现自包含;容器编排管理服务依赖。
配置:在环境中存储配置。 ★★ 容器通过环境变量传递配置;容器编排能提供标准的配置工具。
后端服务:把后端服务当作附加资源。
构建,发布,运行:严格分离构建和运行。 ★★ 通过 docker build 构建,通过 docker run 运行,严格分离。
进程:以一个或多个无状态进程运行应用。 容器对无状态应该可以完美支持,有状态应用现在也能很好支持。
端口绑定:通过端口绑定提供服务。 ★★ 容器编排可以处理服务依赖和绑定,应用不再需要关心。
并发:通过进程模型进行扩展。 ★★★ 容器编排可以很轻松的扩容。
易处理:快速启动和优雅终止可最大化健壮性。 ★★★ 容器编排可以支持快速启动、优雅终止,具有自愈能力。
开发环境与线上环境等价:尽可能的保持开发,预发布,线上环境相同。 ★★★ 容器的可移植性和自包含,能做到随处运行,但应用的环境依然保持一致。
日志:把日志当作事件流。 ★★ 容器和容器编排可以很好的支持日志。
管理进程:后台管理任务当作一次性进程运行。 容器编排提供了多种方式可以运行一次性任务。

所以可以看到,在容器环境下,可以很轻松的实践这十二要素,从而使得应用更容易开发、维护和部署。

3. 容器使创业公司一开始就可以以极低的成本应用微服务架构。

这张图很多人应该看到过,是 Martin Fowler 提出的关于微服务和单体应用的生产率趋势的比较。

productivity.png-102.3kB

这张图要表达的意思是,如果团队使用微服务,开始可能需要花费更多的精力来搭建基础设施和架构,但是生产率可以一直维持在比较高的水平。而在创业公司,我们一般会为了速度,采用 Quick & Dirty 的方案,也就是创建一个单体应用。一开始这样会很快,但问题是,单体应用会越来越庞杂、越来越难以维护,此时就会严重影响团队生产率。

当生产率下降到一定程度时,团队会开始考虑服务化(包括我们杏仁也是这么做的)。但在服务化的过程中,会消耗更多的人力和资源,严重影响生产率。服务化完成之后,生产率会有一定提升,但也很难一下子达到微服务的状态,需要不断的重构并拆分服务。所以在互联网,其实大部分稍微有点规模的公司的生产率曲线是这样的:

productivity2.png-51.9kB

但是在容器环境下,一开始就使用微服务的话,其实生产率并不会比单体应用低多少。

productivity3.png-58.6kB

这是因为容器已经提供了微服务所需要的很多基础设施,包括服务注册和发现、服务依赖和生命周期管理、负载平衡等,而且健壮性和资源利用率也会更高。如果是直接使用容器云,则运维的成本也很低,只需要关注微服务的业务架构就可以了。

所以在容器环境下,我们没有理由再去创建一个单体应用,一切都是微服务,只要业务架构设计合理,研发团队可以更专注于业务开发,生产率可以一直保持在较高水平。

4. 容器使创业公司降低运维成本,提升运维效率,轻松实践 Devops。

容器环境下,很多以前的日常操作都自动化或至少半自动化了,比如比如部署、发布新应用、扩容等等,都可以很快速的响应。容器编排的自愈能力,即使除了问题,也可减少人工干预。

所以容器环境下的运维是幸福的,不用在苦逼的响应各种需求,可以吃着火锅唱着歌把事儿做了。大大加强了运维的幸福感,终于运维同学也可以开开心心的去谈恋爱了。

而开发也可以对自己的应用有更多的掌控。除了能快速频繁的部署,也可以从日志、监控中得到数据和反馈,出现问题也可以通过工具一定程度上去进行调试。

容器即未来

所以,正如上一篇说的,基于容器,会逐步建议一套完整应用架构体系,而这套体系会带来革命性的改变。所以,容器即未来,而未来已来到眼前。

容器容器容器

0

一个创业公司的容器化之路(二) – 容器化

容器是什么?

上一篇文章,我们简单说明了杏仁容器化之前的架构发展。今天我们就来谈谈容器。

2013 年 Docker 横空出世,到 2015 年已经渐渐进入大家的视野。容器当然不一定是 Docker,而且容器现在也是有标准的。但一说容器大家肯定会想到 Docker。所以我们这里说的容器,主要就是指 Docker。

容器到底是什么呢?顾名思义,容器就是用来装东西的。在这里它用来装的就是应用程序。容器的特点简单说就那么四点:

  1. 容器是自包含的,它打包了应用程序及其所有依赖,可以直接运行。以前应用程序的依赖管理一直是个大问题,即使像 RPM 、Maven、Ansible 等都能解决一部分问题,但并没有一个所有应用程序通用的标准机制,直到容器出现。
  2. 容器是可移植的,可以在几乎任何地方以相同的方式运行。这就可以确保应用在开发环境、测试环境、生产环境等都有完全一样的运行环境。
  3. 容器是互相隔离的,同一主机上运行的多个容器,不会互相影响。即一个容器中运行的应用程序,是访问不到其他容器的资源的(进程、网络、文件、用户等),除非配置为共享的资源。
  4. 容器是轻量级的,体现在容器的秒级启动,并且占用资源很少。

容器能做的很多事情,虚拟机也能做,那它们有什么区别呢?下面这张图是 Docker 官网的截图,很好的说明了两者的区别。

docker_intro.png-73.7kB

但最根本的差别,其实就是最后一点:轻量。很多人可能觉得这只是一个简单的差别,但其实不是。因为就是这一点使得容器可以成为一种 标准化的应用发布方式

上个世纪 5、60 年代集装箱刚出现,看上去也只是简单的差别,也没有什么技术含量。但集装箱提供了一个标准化的物流方式,全球的海陆空运输、码头装卸等围绕集装箱形成了整个一个高效的物流体系。最终改变了世界贸易,促成了全球化。

所以容器这个标准化的应用发布方式,最终会影响上层的整个应用架构。最终围绕容器,会建立一套完整应用架构体系,带来革命性的改变。现在其实已经可以看到一点端倪了,Kubernetes 基本已经成为标准,不久前 Google 还发布了 Istio 这个 Service Mesh 工具,进一步把微服务的基础架构也抽象了出来。

容器编排能做什么?

光有能装应用的容器还不够,如果还是人工管理那么多容器,那也发挥不出容器的优势。所以我们需要一个容器编排系统。容器编排能提供以下功能:

  • 应用调度:应用部署、无缝升级、弹性扩展、自愈等。
  • 资源管理:内存、CPU、存储空间、网络等。
  • 服务管理:命名空间、负载均衡、健康检查等。
  • 以及很多其他功能,如日志、监控、认证、授权等。

容器编排领域最主要的三个系统是 Docker Swarm、Kubernetes 以及 Marathon/Mesos。

Swarm 是 Docker 官方的方案,优点就是简单,缺点是太简单了。

然后是 Google 的 Kubernetes,也叫 K8s。Kubernetes 最近一年大放光彩,几乎统治了容器编排领域,就连 Docker 官方不久前也宣布了支持 Kubernetes 。它的优势就是有大厂支持,而且 Google 是把它作为战略来布局的,你可以把它想象成当年的 Android;它的社区也非常火爆。

技术上,Kubernetes 是一个集成的方案,设计非常优秀,可以说是分布式系统的设计典范,Google 在这方面毕竟有很深入的经验。缺点就是有点复杂,而且在今年之前它还是存在不少问题,包括性能问题、大版本的兼容性、部署复杂等,当然现在已经基本解决了。

Mesos 在 Docker 之前就有了,本身做的是分布式系统的资源管理,Mesos 很灵活,上面可以支持各种系统,包括 Spark 等。Marathon 则是基于 Mesos 实现了编排的功能。

我们是去年年中考虑容器化的,我们最后选择的方案是 Marathon/Mesos。原因一方面是之前 Jenkins 容器化已经用到了 Marathon/Mesos,有些经验。另一方面是该方案便于和当前的架构整合。Kubernetes 太过复杂,迁移的话,对架构改动太大。

杏仁的容器化

我们容器化之后的架构是这样的:

容器化.png-34.3kB

所有的应用都以容器的方式运行在 Mesos Slave 上,Mesos Master 统一管理 Mesos Slave 服务器。Marathon 通过 Mesos 调度容器,进行发布、升级、扩容等。Calico 是 Docker 的网络解决方案,实现了一个容器一个 IP 以及容器之间的互联。而右上角部分,我们基本保留了我们之前的微服务架构,只是用于服务发现和注册的 Consul Agent 替换成了 Registrator。

同时我们的 CI/CD 也相应的做了调整。

CI.png-68.4kB

Jenkins 自身现在也是基于容器的,会在 Mesos Slave 的容器里进行编译和打包。应用会被打包成的 Docker 镜像,上传到我们的镜像仓库 Harbor 里。部署时,Jenkins 调用 Marathon 的接口进行部署,Marathon 则从 Mesos 申请资源。部署时 Mesos 会从 Harbor 下载相应的应用镜像并根据配置运行。

有了这套系统,我们创建应用、扩展应用就很简单了。创建应用时首先通过 Dockerfile 和 Jenkins 创建镜像,然后在 Marathon 界面上,只需要准备一个 Json 配置(也可以通过 Form 配置),指定资源、实例数、镜像、网络、健康检查、环境变量等,就可以很快上线一个新应用。

marathon_new.png-136kB

有时候我们要准备一个秒杀活动或者推送几百万用户,需要增加应用实例,也只要在 Marathon 界面调整一个数字就可以了。

屏幕快照 2017-10-19 下午12.02.49.png-20.9kB

除了容器编排和 CI/CD,还有两个很基础的东西,一个是基于 ELK 的统一日志平台、另一个是基于 Open-Falcon、StatsD、Graphite、Grafana 的 监控告警平台。大致结构如下,具体实现以后有机会再专门写文章介绍,这里就不详述了。

日志、监控和告警.png-102.2kB

最终,我们的整个平台的组成是这样的:

容器平台

未来之路

到这里为止,这些就是杏仁目前的基础平台的架构。通过这套系统,我们提升了资源利用率,刚刚迁移到容器化环境的时候,我们只用了原来大约 6、70% 的云服务器。并且我们大大加强了我们的自动化运维的能力,完善了服务监控。

但是这套系统也存在不少问题。

  • 新增服务器节点还是需要一些手工操作。
  • 容器、环境等的配置都是分散在各处,缺乏有效的管理。
  • 对有状态应用支持不好。
  • 系统存在一些冗余,例如有 Zookeeper、有 Etcd 还有 Consul。
  • 不支持自动扩容。
  • 部分基础设施没有容器化。

未来我们会继续进化,也许等时机成熟了,也不排除会迁移到公有云的容器服务,或者自建 Kubernetes 集群。

参考资料

1+

一个创业公司的容器化之路(一) – 容器化之前

创业公司的技术挑战

托尔斯泰说:“幸福的家庭都是相同的,不幸的家庭各有各的不幸。”互联网创业公司也一样。大部分互联网创业公司,都会碰到以下几个技术挑战。

  1. 如何快速、低成本的搭建系统,同时确保安全稳定?
  2. 如何快速的构建和发布应用,满足业务需求?
  3. 如何提高团队开发效率,确保开发质量?

这个列表肯定不完整,但这三个应该是创业公司技术团队都会面临的共通的问题。当然杏仁不能说完全解决了这几个问题,但还是取得了一些进展。我们接下来简单介绍下,我们杏仁是怎么应对这些挑战的,以及容器又可以带来什么?

该系列文章会分为三篇。第一篇介绍容器化之前,杏仁技术架构的发展历史。第二篇介绍容器以及杏仁的容器化方案。第三篇最后总结为什么我们认为创业公司应该用容器,以及为什么容器可以帮助我们应对这三个挑战。

杏仁早期

在 2012 年以前,大部分互联网公司包括创业公司,都是直接购买服务器,租用 IDC 机房的机架部署的。应用是直接运行在物理机上的,要扩展必须购买新服务器。IDC 经常出各种故障,如果碰到 IDC 迁移的话,就更痛苦,必须半夜搬机器,天亮前上线。总之对创业公司的成本、服务稳定性、工作效率都是有很大的消耗。

不过杏仁医生很幸运,正好赶上了公有云的成熟,所以一开始就是基于公有云搭建的。杏仁医生最早期的架构如下:

杏仁医生早期.png-18kB

这个架构非常简单,其中负载平衡、数据库都是基于腾讯云的。然后腾讯云也提供了一些基础的监控、告警和安全服务。然后就是两个应用,一个移动后端 API,一个运营平台,都是基于 Play 的 Scala 应用。

很多人可能会好奇为什么选择 Scala/Play 进行开发,毕竟 Scala 在国内应该用的不多。这里一方面因为杏仁医生是继承了看处方的架构,当初看处方就是基于 Scala/Play 开发的, 团队对这一套方案比较熟悉。我们需要快速的构建杏仁医生,自然就会选择最熟悉的语言和框架。而且对于中小规模的应用,Scala/Play 的开发效率的确非常高。Scala 本身的表达能力非常强,是一门很有意思的语言。很多好学的工程师,也会对新的语言也会比较感兴趣。

应用拆分和CI/CD

经过一年多快速演进,整个应用越来越庞杂。所以我们对应用进行了拆分,并且随着业务扩张,应用也越来越多,例如 HIS、CRM 等。所以我们的架构变成了这个样子。

杏仁医生早期2.png-29.7kB

这时 Scala 最大的问题开始体现出来了,那就是编译速度的问题。那时候我们的应用部署方式也很原始,必须登陆服务器运行一个 Shell 脚本,它会拉取代码,然后编译、打包、运行,整个过程需要耗费5~10分钟。而我们 API 应用的节点后来增加到 5、6 台,即使两台同时发布,也需要 20 分钟才能全部发布完成。如果发布后出了问题,那就吐血了,因为回滚也是一样的流程,又需要 20 分钟。

有一次我们做了一个送 Apple Watch 的活动,半夜 12 点开始。我们出了个很低级的 BUG,活动一开始就蹭蹭蹭的一分钟送好几个 Apple Watch。我们创业公司也没多少钱,每一个都是白花花的银子,心痛啊。修复很简单,但发布或者回滚都得先编译,太慢了,于是我们一狠心把服务器给停了,几分钟后才部署上了新的代码。

这是我们觉得必须要有一个自动化的发布系统了。其实在几年以前,发布都是需要运维执行的,研发提交给运维,运维手动部署。那自然发布不可能很频繁,并且对开发和运维都是很大的负担。但渐渐的敏捷和 Devops 的文化成为主流,持续集成和发布(CI/CD)成为一项基础设施。

我们第一版 CI/CD 很简单,是基于 Jenkins 的,通过脚本进行编译、打包,然后拷贝到服务器上发布。因为只要打包一次即可,缓解了部署慢的问题。但还是存在几个问题。

  1. 首先,没有应用仓库。打包是一次性的,部署的时候会备份当前应用目录,用于回滚,所以只能回滚到上一个版本。
  2. 其次,健康检比较简单,只能检测应用是否启动。我们遇到过应用启动了,测活也没问题,但服务还是有严重问题、基本不可用的情况。
  3. 其次,不支持灰度发布,出问题只能回滚。

期间我们有一次较大的故障,也是因为这几个因素,花了很长时间才恢复。痛定思痛,于是我们又开发了 Frigate 发布系统,它的架构大致如下图。

Frigate.png-28kB

  1. Frigate 有一个应用仓库,即 App Repository。应用仓库会保存发布的应用版本,回滚的时候可以指定版本。
  2. Watcher 组建实现了比较强大的应用检测功能。除了一般的 HTTP 检测,还可以从日志、监控里获取数据,可以根据异常数、错误率等进行进一步的健康检测。
  3. Frigate 支持分组和分阶段发布。例如现发布2台机器,然后健康检查,或者中间可以有一些人工检查,然后再发布剩余的机器。

后来回头看,Frigate 虽然没有使用容器,但其实是实现了容器编排的很多功能。Frigate 发布的截图如下,这是基于 Jenkins Pipeline 的。

frigate_screen.png-112.7kB

微服务化

系统多了,依赖复杂、数据没有隔离、逻辑重复,接下来一个必然的方向就是微服务。关于微服务,我们公众号有两篇文章(乐高式微服务化改造(上)乐高式微服务化改造(下))对此有比较详细的分析说明,这里就简单介绍一下。

我们的服务注册和发现是基于 Consul 的,负载平衡是通过 Nginx 实现的。下图是整个服务注册和发现的过程:

服务化结构.png-33.3kB

有几点是值得一提的。

首先,我们的微服务是基于 HTTP 和 Json 的,没有采用二进制的协议如 Protobuf、Thrift 等。其实 HTTP 和二进制协议的性能差别,并没有很多人想的那么大,一般也就2、3倍的差距(没有亲测)。对大部分企业,这个差别根本就不是瓶颈,特别是现在还有 HTTP2。如果真的有需要,还可以在 HTTP2 上跑二进制协议,通过框架在服务端和客户端加一层就可以实现。

其次,我们的微服务对应用是无侵入的。我们没有采用常见的 Dubbo、SpringCloud 框架。一方面我们服务调用方有 Java 应用也有 Scala 应用,要接入还是要花点功夫。另一方面,我们认为微服务框架发展的未来方向是非侵入性的独立的微服务基础设施层。其实这和容器编排的理念是一致的,并且最近提出的 Service Mesh 概念,就是进一步的延伸,我们认为这才是微服务的未来。

最后,我们每个微服务都会生成一个 SDK,便于调用方调用。SDK 集成了熔断、异步、分布式追逐(开发中)等功能。

搭建了微服务基础框架后,我们开发了好几个微服务,有业务的例如订单、预约等,有基础设施的例如推送、短信等。当然其实有些并不算“微”。

但是我们发现,整个体系依然存在不少问题

  1. 基于云服务,成本低了,效率高了。但运维还是面向资源的,并且资源利用率不高。
  2. 有了持续集成和部署的能力。但新增节点、新建服务等,依然需要大量人工运维,并且扩展并不方便。
  3. 实践微服务,改进了应用架构。但依赖管理、监控等尚未完善,稳定性仍然不够。

下一篇,我们将介绍杏仁是怎么使用容器和容器编排来优化这些问题的。

1+