所谓分久必合合久必分,分治可以解决all in one的问题,但是更多的问题因为隔离而产生,为了解决这些问题又会有相应的工具产生。作为已经不算火热的微服务概念,落地解决方案也渐渐成熟和成型,为了说明containerpilot的适用场景,首先简单说明白几个基本概念。
微服务
不管是前端还是后端服务,项目开始的时候追求短平快,所有的代码会放在一个代码库中,基于同一个框架和语言开发,顶多根据文件或者文件夹做一下模块化。前端和后端服务做一下分离(或者也没有做),放在一个SLB后面,作为无状态的应用服务器也能实现基本的水平扩容。然而,随着业务的快速发展,这样的简单结构会逐渐变成障碍。微服务的实践其实应该由来已久,只是最近几年被炒的火热,而微服务的核心思想还是设计模式中经典的思想:单一责任。基于这个思想,微服务的其他便利才有了落脚点。
更多内容可以参考这里: 微服务架构的优势与不足
服务的注册与发现
既然我们的目标是把一个服务打散,也就是从单进程要放到多进程中去,就需要涉及到进程间通信。经典的操作系统理论理论里面提到的IPC方法有: 管道,有名管道(无父子关系约束),信号量(计数锁),消息队列,信号,共享内存,套接字。这些方法大多是基于单机器中进程通信的方式。我们利用微服务在部署的时候另外一个原则: 消除单点,所以肯定是需要能够跨机器进程的方式通信了,那套接字就是重要的选择,实际应用中因为开发更多在应用层实现,所以Restful和RPC的方式是常用的手段。这里就存在一个问题,服务因为彼此隔离,彼此通信的时候怎么知道彼此的存在,同时在需要调用依赖的服务时,如何能够调用到合适的机器(这里指的是机器的IP和端口号),这就是服务的注册与发现问题了。同时服务的注册和发现也存在两种模式: 客户端模式和服务端模式。
可以看到这里的差别主要在于是否将注册中心暴露出去,我们后面的讨论基于客户端模式。
可能你直接看上面的架构,感觉不到这个注册中心的好处,让我们再放几张图,看看我们原来是怎么做的
当然实际场景有可能比这还要复杂,可能中间用的还有F5或者haproxy,这里只是个示意。刚开始简单实现就档个nginx,对upstream有个基本的healthcheck,通过反向代理就请求到后端服务就可以了。当访问量上升,单个nginx扛不住就再在前端档个LVS,通过四层协议转发IP包,第二层对nginx分组讲负载进一步分流。但是这样LVS变成了单点,而且配置变得更加复杂。最后又不得不把这个单点消除,由客户端智能判断合适的分组,请求后端的服务。这里nginx在所有情况下既担当了负载均衡的角色,又担当了服务发现的角色(如果服务分组的时候根据访问量来分,会把访问量差不多的放在一个分组,通过nginx重写路由映射到多个后端服务)。这里很明显配置都是需要人工干预的,任何一台后端实际服务的机器IP变化都需要运维手动维护,繁琐而且容易出错。这样大家就可以感受到上面注册中心的好处了把,所有的服务列表可以集中在一个地方管理。
服务注册中心
所有服务都集中存在一个地方管理,现在的主流方案主要是这三个
- consul: 提供了一站式的解决方案,健康检查,DNS解析一应俱全,甚至提供了个简单的UI工具
- etcd: 仅提供基本的kv分布式存储
- zookeeper: 一些老的系统还在使用,配置管理都相对麻烦
因为只用过 consul
,下面都是以它为依据展开,但是作为服务注册中心的概念上是相似的.作为注册中心基本的功能就是kv的存储,服务的配置信息还有应用的配置信息都可以放在这里,consul
提供了 agent
运行的模式,也就是 client
和 server
模式,看下图
server
模式的 agent
互相通信构成一个集群,主要负责参与选出集群的主节点和实现信息同步, client
模式的 agent
只是同步集群的数据,或者在自己接受到数据变更请求的时候将变化广播出去.server
模式的 agent
组成的集群可以放在多个数据中心来保证高可用,通过 gossip
协议实现分布式的数据一致性.这里从简化概念考虑,你可以把 consul
的整个集群当成一个"哈希表",服务都是注册到这个"哈希表"中,然后其他的服务通过查找这个"哈希表"来获取实际调用的下游服务.实际情况并不是这么简单,因为不是本文的重点,这里略过了,有兴趣的同学可以自己看下官方文档.
Tip: 基于应用的配置信息提供了consul-template实现动态的配置更新,结合服务注册在一起使用可以实现自动的依赖服务配置信息更新.本来这样是很酸爽,只是这样有个缺点,也是在实践中发现的,就是配置的变更管理如果用consul来管就没有办法track变更的历史记录了,在加上SRE限制了 consul UI
的访问权限,很多时候上线因为开发不能直接通过UI修改,这个便利性就大打折扣了.经常会出现有新的配置没有即使同步到生产环境的情况,人工管理的成本很高,所以在系统中尝试使用后就退化成普通的文件配置(也可以track变更,因为有git的提交记录).
手工注册和查询的痛苦
因为上面提到的 consul
基本的功能是提供了服务信息的存储,通过它的resulful API可以很容易的将一个服务注册到 consul
里面,同时 consul
也会自动对他执行健康检查,在服务挂掉的时候从可用服务列表中可以过滤掉(一般一个服务会对应一个 SLB
和后面一堆的实际应用服务器)有问题的机器,保证服务的高可用.同时我们也需要在对服务进程接受到特殊信号量,比如 SIGKILL
的时候将服务从 consul
中移除,这就也需要调用API实现.虽然可以通过将注册和解注册的行为通过公共库的方式在一个地方维护,但毕竟不是那么优雅.
对应的,因为是使用查询注册中心的方式来做服务发现(其实就是客户端的服务发现 smart client),需要客户端通过调用 consul
的 restful API 来查询,虽然可以通过在每个服务实例上启动一个在 client
模式下的 agent
缓解集群的并发访问压力,但是还是需要客户端主动查询的,有没有更好的方式呢?
独立出注册和查询为独立服务
终于主角登场了,containerpilot是大名鼎鼎的 joyent
公司(最早开发nodejs的公司)开源出来的一款针对容器内服务做自动注册和发现的好工具.之前我们提到,原始的服务注册和发现依赖于程序通过restful API的调用实现,这样就需要在代码中实现调用的逻辑.现在用了 containerpilot
再加上一些封装之后,就可以实现程序对于服务注册中心的无感知.具体情况是这样的,所有 containerpilot
相关的配置放在一个固定的目录下面,例如 /etc/containerpilot
,在容器启动的时候会执行脚本从依赖关系文件(例如 serviceDependencies.json
)中读取出下游服务列表,删除socker文件(例如 /var/run/containerpilot.socket
),执行 containerpilot
的可执行文件.containerpilot
读取配置文件 config.json5
,通过模板语法,在不同的条件下根据环境变量激活不同的job.这里面最重要的部分是这几个:
- 启动
consul
的agent或者直接连接consul
的cluster,将当前服务注册进去,第一次生成依赖服务的地址services.json
- 在
containerpilot
可以跟consul的agent或者cluster通信之后,获取依赖服务的列表,通过consul-template
将服务的信息渲染到预定义好的模板文件中 - watch在consul中依赖的服务,当检测到更新的时候给服务发送
SIGHUP
的信号提示服务重新读取依赖服务的最新地址services.json
- 通过调用
ping
命令对服务进行健康检查,在服务不健康甚至是容器异常退出的时候会将consul
中服务的状态标记为不可用(不会解注册)
一个服务跟 containerpilot
集成的结构是这样的
使用 containerpilot
概要
首先明确两个概念:
- 被托管服务,即运行在本容器内的主要进程,对外提供某种功能。
- 依赖服务,即被托管服务所依赖的服务,通常运行在其它容器内。
containerpilot 基于 consul 实现了如下两个功能:
- 将被托管服务注册到 consul 内,并维持健康检查心跳,在容器停止时自动解注册该服务。
- 获取依赖服务的实例地址。
这两个功能即服务的注册和发现,可同时使用,也可只使用其中任意一个。
使用服务注册功能时,或者使用服务发现功能且需要接收依赖服务实例更新信号时,被托管服务需由 containerpilot 管理,即最终服务管理结构如下:
- runit
- containerpilot
- your service
只使用服务发现功能且不需要接收依赖服务实例更新信号时,被托管服务可直接由 runit 管理,即最终服务管理结构如下:
- runit
- containerpilot
- your service
环境变量
使用 containerpilot 请按需设置以下环境变量:
CONSUL_ADDR
:容器内可使用该值调用 consul API ,默认值为127.0.0.1:8500
。使用默认值时,会在容器内部起一个 consul client ,并根据CONSUL_JOIN_ADDR
和CONSUL_ADVERTISE
加入已有集群。CONSUL_JOIN_ADDR
:已有 consul 集群中任意一个节点的 IP 地址,consul client 加入集群时使用。CONSUL_ADVERTISE
:consul client 加入集群时使用哪个 IP 通信,需用 k8s pod IP 或 container host IP。SERVICE_NAME
:注册到 consul 中被托管服务名称,ID 会自动设置为${SERVICE_NAME}-${CONTAINER_HOSTNAME}
。可以从外部传入或内部写死。此外,如果存在环境变量CONSUL_PREFIX
,其值将会被拼到服务名称和 ID 前面。默认值为main
。SERVICE_PORT
:注册到 consul 中被托管服务端口。不传则不注册服务到 consul 。SERVICE_COMMAND
:启动被托管服务的命令和参数,不传递则不启动。SERVICE_INTERFACE
:注册到 consul 中被托管服务地址,可选网卡名如eth0
或静态地址static:192.168.1.100
,默认值为eth0
。SERVICE_SIGNAL_CHANGED
:当依赖服务实例发生变化时,是否给被托管服务发送SIGHUP
,传值代表发送。
获取依赖服务的实例地址
被托管服务如果依赖于其他服务,需在项目中添加 serviceDependencies.json
文件,构建 docker 镜像时把这个文件复制到 WORKDIR 下,示例如下:
{
"service1": ["service2", "service3"],
"service2": ["service4"]
}
表示 service1
依赖于 service2
和 service3
,而 service2
依赖于 service4
,即 key 为被托管的服务名,value 为该服务的依赖列表。
对于不使用服务注册,只需要服务发现的情况,可以使用 SERVICE_NAME
的默认值 main
作为 key,示例如下:
{
"main": ["service2", "service3"],
}
containerpilot 会根据 SERVICE_NAME
选取需要监控的依赖服务,比如设置 SERVICE_NAME
为 service1
,则将监控 service2
和 service3
,当依赖服务实例发生变化时,会更新 /etc/containerpilot/services.json
,生成的内容示例如下:
{
"service2": [
"1.1.1.1:80",
"2.2.2.2:80"
],
"service3": [
"3.3.3.3:80",
"4.4.4.4:80"
]
}
其中 key 为依赖的服务名,value 为该服务的实例地址数组。
被托管服务要获取该文件的更新可通过如下方式:
- golang 、java 这种 long running 的,需要托管于 containerpilot ,然后设置环境变量
SERVICES_SIGNAL_CHANGED
,然后代码中处理SIGHUP
信号,收到信号时重新读取该文件。 - php 这种不好处理信号的,可以每次处理请求时都重新读取该文件。
启动 containerpilot
containerpilot 应作为 runit service 启动,示例 run
脚本如下:
#!/bin/bash
# 假设外部已传入 CONSUL_JOIN_ADDR 环境变量
# 假设运行在 k8s 上,则 CONSUL_ADVERTISE 使用默认值即可
export SERVICE_NAME="test"
export SERVICE_PORT=80
export SERVICE_COMMAND="myservice args"
# containerpilot 默认使用健康检查命令 ./ping ,即需要把 ping 文件放在 WORKDIR 下,并在启动 containerpilot 前切换到 WORKDIR
# WORKDIR 默认为 /
cd /
# 启动 containerpilot
exec /etc/containerpilot/containerpilot.sh
参考
- https://github.com/joyent/containerpilot
- https://www.joyent.com/blog/applications-on-autopilot
- http://www.jianshu.com/p/c144a577f3d1