Actor 模型及 Akka 简介

前提

随着业务的发展,现代分布式系统对于垂直扩展、水平扩展、容错性的要求越来越高。常见的一些编程模式已经不能很好的解决这些问题。

解决并发问题核心是并发线程中的数据通讯问题,一般有两种策略:

  1. 共享数据
  2. 消息传递

共享数据

基于 JVM 内存模型的设计,需要通过加锁等同步机制保证共享数据的一致性。但其实使用锁对于高并发系统并不是一个很好的解决方案:

  1. 运行低效,代价昂贵,非常限制并发。
  2. 调用线程会被阻塞,以致于它不能去做其他有意义的任务。
  3. 很难实现,比较容易出现死锁等各种问题。

消息传递

与共享数据方式相比,消息传递机制的最大优点就是不会产生竞争。实现消息传递的两种常见形式:

  1. 基于 Channel 的消息传递
  2. 基于 Actor 模型的消息传递

常见的 RabbitMQ 等消息队列,都可以认为是基于 Channel 的消息传递模式,而本文主要会介绍 Actor 模型相关内容。

Actor 模型

Actor 的基础就是消息传递,一个 Actor 可以认为是一个基本的计算单元,它能接收消息并基于其执行运算,它也可以发送消息给其他 Actor。Actors 之间相互隔离,它们之间并不共享内存。

Actor 本身封装了状态和行为,在进行并发编程时,Actor 只需要关注消息和它本身。而消息是一个不可变对象,所以 Actor 不需要去关注锁和内存原子性等一系列多线程常见的问题。

所以 Actor 是由状态(State)、行为(Behavior)和邮箱(MailBox,可以认为是一个消息队列)三部分组成:

  1. 状态:Actor 中的状态指 Actor 对象的变量信息,状态由 Actor 自己管理,避免了并发环境下的锁和内存原子性等问题。
  2. 行为:Actor 中的计算逻辑,通过 Actor 接收到的消息来改变 Actor 的状态。
  3. 邮箱:邮箱是 Actor 和 Actor 之间的通信桥梁,邮箱内部通过 FIFO(先入先出)消息队列来存储发送方 Actor 消息,接受方 Actor 从邮箱队列中获取消息。

模型概念

Actor

可以看出按消息的流向,可以将 Actor 分为发送方和接收方,一个 Actor 既可以是发送方也可以是接受方。

另外我们可以了解到 Actor 是串行处理消息的,另外 Actor 中消息不可变。

Actor 模型特点

  1. 对并发模型进行了更高的抽象。
  2. 使用了异步、非阻塞、高性能的事件驱动编程模型。
  3. 轻量级事件处理(1 GB 内存可容纳百万级别 Actor)。

简单了解了 Actor 模型,我们来看一个基于其实现的框架。

Akka Actor

Akka 是一个构建在 JVM 上,基于 Actor 模型的的并发框架,为构建伸缩性强,有弹性的响应式并发应用提高更好的平台。

ActorSystem

ActorSystem 可以看做是 Actor 的系统工厂或管理者。主要有以下功能:

  • 管理调度服务
  • 配置相关参数
  • 日志功能

Actor 层次结构

Akka 官网展示的 Actor 层次结构示意图

Akka 有在系统中初始化三个 Actor:

  1. / 所谓的根监护人。这是系统中所有 Actor 的父亲,当系统被终止时,它也是最后一个被停止的。
  2. /user 这是所有用户创建的 Actor 的父亲。不要被 user 这个名字所迷惑,他与最终用户没有关系,也和用户处理无关。你使用 Akka 库所创建的所有 Actor 的路径都将以/user/开头
  3. /system系统监护人

我们可以使用 system.actorOf() 来创建一个在 /user 路径下的 Actor。尽管它只是在用户创建的层次的最高级 Actor,但是我们把它称作顶级 Actor。

Akka 里的 Actor 总是属于其父母。可以通过调用 context.actorOf() 创建一个 Actor。这种方式向现有的 Actor 树内加入了一个新的 Actor,这个 Actor 的创建者就成为了这个 Actor 的父 Actor。

Actor 生命周期

Akka Actor 生命周期示意图
生命周期

Actor 在被创建后存在,并且在用户请求关闭时消失。当 Actor 被关闭后,其所有的子Actor 都将被依次地关闭.

AKKA 为 Actor 生命周期的每个阶段都提供了钩子(hook)方法,我们可以通过重写这些方法来管理 Actor 的生命周期。

Actor 被定义为 trait,可以认为就是一个接口,其中一个典型的方法对是 preStart()postStop(),顾名思义,两个方法分别在启动和停止时被调用。

ActorRef

在使用 system.actorOf() 创建 Actor 时,其实返回的是一个 ActorRef 对象。

ActorRef 可以看做是 Actor 的引用,是一个 Actor 的不可变,可序列化的句柄(handle),它可能不在本地或同一个 ActorSystem 中,它是实现网络空间位置透明性的关键设计。

ActorRef 最重要功能是支持向它所代表的 Actor 发送消息:

ref ! message

Dispatcher 和 MailBox

ActorRef 将消息处理能力委派给 Dispatcher,实际上,当我们创建 ActorSystem 和 ActorRef 时,Dispatcher 和 MailBox 就已经被创建了。

Dispatcher 从 ActorRef 中获取消息并传递给 MailBox,Dispatcher 封装了一个线程池,之后在线程池中执行 MailBox。

因为 MailBox 实现了 Runnable 接口,所以可以通过 Java 的线程池调用。

流程

通过了解上面的一些概念,我们可以 Akka Actor 的处理流程归纳如下:

  1. 创建 ActorSystem
  2. 通过 ActorSystem 创建 ActorRef,并将消息发送到 ActorRef
  3. ActorRef 将消息传递到 Dispatcher中
  4. Dispatcher 依次的将消息发送到 Actor 邮箱中
  5. Dispatcher 将邮箱推送至一个线程中
  6. 邮箱取出一条消息并委派给 Actor 的 receive 方法

简略流程图如下:
Akka Actor 流程图

EventBus

接下来我们看一个 Actor 的应用:EventBus。在异步处理场景下,运用最为广泛的消息处理模式即是 Pub-Sub 模式。基于 Pub-Sub 模式,还可以根据不同的场景衍生出特殊的模式,例如针对一个 Publisher 和多个 Subscriber,演化为 Broadcast 模式和 Message Router 模式。

EventBus 则通过引入总线来彻底解除 Publisher 与 Subscriber 之间的耦合,类似设计模式中的 Mediator 模式。总线就是 Mediator,用以协调 Publisher 与 Subscriber 之间的关系。对于 Publisher 而言,只需要把消息发布给 EventBus 即可;对于 Subscriber 而言,只需要在 EventBus 注册需要处理的事件并实现处理流程即可。

在没有使用 EventBus 的时候,Publisher 必须显式的调用 Subscriber 的方法。例如订单支付成功后,必须在订单处理模块调用积分模块处理积分,调用服务号模块进行通知。而且这样的显示调用会越来越多,每次都要去修改订单模块加一个调用。这样订单处理模块和那些模块就都紧密耦合在一起了。我们看看 EventBus 怎么解决这个问题。

EventBus 定义

要使用 Akka EventBus, 首先要实现一个 EventBus 接口。

trait EventBus {
  type Event
  type Classifier
  type Subscriber

  //#event-bus-api
  def subscribe(subscriber: Subscriber, to: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber, from: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber): Unit

  def publish(event: Event): Unit
  //#event-bus-api
}

如上所示:

  1. Event 就是需要发布到总线上的事件
  2. Classifier 分类器用于对订阅者进行绑定和筛选
  3. Subscriber 注册到总线上的订阅者。

所幸的是,我们不需要要从头实现 EventBus 接口,Akka 提供了一个 LookupClassification 帮助我们实现 Pub-Sub 模式,我们要做的最主要就是实现 publish 方法。

class XrEventBus extends EventBus with LookupClassification {
  type Event = XrEvent
  type Classifier = XrEventType
  type Subscriber = ActorRef

  override protected def publish(event: Event, subscriber: Subscriber): Unit = {
    subscriber ! event
  }
  // 其他方法...
}

可以看到:

  1. Event 的类型是我们自己定义的 XrEvent。
  2. 分类起是基于 XrEventType,也就是事件类型的。我们系统中定义了很多时间类型,例如 XrEventType.ORDER_PAID 是订单支付事件,XrEventType.DOC_REGISTERED 是用户注册事件。
  3. Subscriber 其实就是一个 Actor。
  4. Publisher 只是简单的将 Event 作为一个消息发布给所有 Subscriber。

事件发布和订阅

Subscriber 这边则需要实现对事件的处理。

class ScoreEventHandler extends Actor with Logging {
  override def receive = {
    // 订单支付成功
    case XrEvent(XrEventType.ORDER_PAID, order: OrderResponse) =>
      // 处理订单支付成功事件

    // 处理其他事件
  }
}

然后我们通过调用 EventBus.subscribe 进行事件订阅。

  val eventBus = new XrEventBus

  // 积分事件处理模块
  val scoreEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[ScoreEventHandler], name = "scoreEventHandler"))
  eventBus.subscribe(scoreEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

  // 微信服务号事件处理模块
  val weixinXrEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[WeixinXrMessageActor], name = "weixinXrEventHandler"))
  eventBus.subscribe(weixinXrEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

最后,我们的订单处理模块只需要调用 EventBus.publish 发布订单支付事件就好了。至于那些需要处理该事件的模块,自然会去订阅这个事件。上面 XrEventBus 的实现里可以看到,发布其实就是用 Actor 的消息发送机制,将消息发布给了所有的 Subscriber。

XrEventBus.publish(XrEventType.ORDER_PAID, new OrderResponse(order, product))

至此,我们的订单处理模块和积分处理模块、微信服务号模块就安全解耦了,很漂亮不是吗?

总结

当然 Actor 还有其他很多应用场景。例如并发流式处理,甚至我们系统中的定时任务,也是通过 Actor 实现的。

总之,Actor 为我们提供了更高层次的并发抽象模型,让我们不必关心底层的实现,只需着重实现业务逻辑。对于一些并发的场景,是很值得尝试的一种方案。

0

中文房间之争-浅论到底什么是智能

中文房间问题

1980 年,美国哲学家 John Searle 提出了一个思维实验:中文房间(Chinese Room Argument),它是这样的:

假想在一个密闭的房间中,有一个完全不懂任何中文的美国人。他手上有这两样东西:1)所有的中文字符集(数据);2)如何处理这些中文字符的规则书(程序)。现在,门外有人在纸条上用中文写上一个问题,递进房间当中(输入)。房间里这个完全不懂中文的美国人,按照手头的规则书,把问题的正确回答拼凑出来后递出去(输出)。按照图灵测试的标准,房间里的人被认为具有理解中文的智能,然而,实际上他对中文一无所知。

要注意的是,这本规则书上仅仅只是基于语法(Syntax)的,而不涉及任何语义(Semantics)。也就是说,你可以理解成,这本规则书上,罗列了一切可能的中文问题,并给出了相应的中文回答,房间中的人,只需要按照对应的回答,拼凑出相应的中文字符递出去,但这个过程中,他对问题和答案是什么意思,一无所知。

这个思维实验提出后,长达三十余年的时间里,各方都提供了各种回复与反驳。Searle 最初只是希望借助于这个思维实验,来指出图灵测试在验证智能方面并不是完备的,即我们该如何辨别智能。不过,随着论战的升级,它实际上指向着一个历史更悠久的路线之争:智能是可计算的吗?

其实,所谓路线之争,本来就没有绝对的对错,关键在于你持有什么立场和信念。我们就来看看,中文房间面临的各种诘难和相应的回复。

系统论回复(System Reply)

最常见的回复,也是 Searle 在其论文中首先驳斥的,就是系统论。在这种观点下,房间中的那个人的确不理解中文,然而,如果你把房间当作一个整体系统,则可以说这个系统是理解中文的。这一派观点的人做出了一个类比:想像一下人类大脑中的神经元,单独拎出来一个神经元,它显然不理解中文,然而,人作为一个整体,是可以理解语言的。

Searle 对此的回复是:让我们更极端一点,现在房间中的这个美国人,背下来了所有的中文字符和整本规则书。现在,他离开房间出门和真正的中国人交谈。对面走来了一个人问他「你给王菊投票了吗?」,他根据大脑里内化的规则书回答「没有」。尽管他对「王菊」和「投票」是什么,一无所知。Searle 在 1984 年进一步指出,系统论一方的错误在于,误把这个问题当作整体论和还原论之争的一个分支,然而实际上,他驳斥的是单凭语法本身并不足以构建语义。

系统论的一个变体是「虚拟心智论(Virtual Mind Reply)」。和系统论一样,它同样不认为房间中的人具有智能,但也不认为这个系统整体具有智能。事实上,它完全不关心智能是通过谁或什么(Agent)展现了出来,而只在意在整个过程当中,是否有智能被创造了出来。许多人在这一观点上进行了进一步的演化,继而讨论到:中文房间之中,真正的智能体现在那本规则书上。正反双方对这本规则书是否是可穷尽的,以及意识和本体论的行为主义之间的关系展开了辩驳,不过讨论的内容过于学究,这里就不展开了。

英国物理学家和数学家 Roger Penrose 在 2002 年回应过这一系列相关的观点,把「中文房间」问题扩大化到「中文体育馆(Chinese Gym)」变体。现在,让全中国所有的人,每个人模拟一个大脑中的神经元来处理信息。难道中国这个国家,或者在这个过程当中,存在着任何形式的智能吗?好吧,虽然这个变体的思维实验,核心目的是驳斥智能不需要依附主体的这一看法,但如果你想到了《三体》一书中,三体星人全民模拟一台计算机的行径,很多人的确可能会觉得这个过程当中存在着智能。

机器人回复(Robot Reply)

机器人回复赞同 Searl 的观点,即中文房间中无论是那个人,还是房间整体,都不存在智能可言。但原因在于,仅仅语言是不足以构成智能的,按照这一方的观点,如果我们造出了一个机器人,它不仅仅可以根据语法处理中文,还可以识别图像、声音、运动,并给出相应的肢体、表情等反馈,这个机器人就具备了智能。按这样的标准,西部世界中的接待者(Hosts)显然是具备智能的。

Searl 和一众人并不认为这样的机器人真正具备了智能,反驳的主要切入点,是从意向性(Intentionality)和因果关系(Causal Connection)两点入手的。通俗一点来说,这样的智能只是一系列反应的组合,硅谷最新一季当中的 Fiona 就是典型代表,她的语言、面部表情、肢体动作只不过是按照程序例行公事,却没有自发的因果性。

v2-2b8d6ad7f7bcb3317a41f8ff13d2f730_r

模拟大脑回复(Brain Simulator Reply)

模拟大脑回复跳开了中文房间这个问题,而是直接向 Searl 质问:如果我们用非生物的方式,模拟出了一个和大脑神经元一模一样工作的装置,它拥有智能吗?想像一下西部世界中接待员脑中的白色装置,就可以认为是这个,显然西部世界里的机器人,比上面硅谷中的那个 Fiona 更像拥有智能的人类。

Searl 的回复依然是不能。模拟大脑本身并不意味着智能,他在这里给出了一个很类似于上文「中文体育馆(Chinese Gym)」的思维实验变体,目前来看,Searl 就是既不认同智能直接等同于大脑的电化学信号。不过,在这个方向上讨论下去,Searl 隐晦地表达出了他对于什么是智能的深层看法。

模拟大脑回复一方进一步质问:我们现在不模拟大脑了,而是逐步替换大脑。假设有一个人,我们把他大脑中的突触,一个接一个,替换成特制的晶体管,这些晶体管能十分逼真地模拟大脑突触。那么,当全部替换完成后,这个人还拥有智能吗?

Searl 一方的回答是:在我不知道他被替换前,我可能会认为他拥有智能。但一旦当我知道它是一台图灵机后,我便不再认为他具备智能。这个看似已经近乎于狡辩的回复,其实暗藏着 Searl 一方对智能更深层的看法:凡是可以被计算的,都不再是智能。其实,直到今天,也没有人可以给智能下一个令所有人满意的定义,而中文房间更深层次的分歧正在于:智能是一个可计算的问题吗?

他人心智回复(Other Minds Reply)和直觉回复(Intuition Reply)

还有许多人绕开了关于智能的定义,而尝试从如何辨别智能的角度出发。在他们看来,我们之所以认为中文房间中的人,或机器人不具备智能,纯粹是出于我们固有的偏见。按照同样的标准,我们一样也无法判定身边的人是否真的具备智能,或者仅仅只是表现出智能。这一系列的回复,都强调图灵测试的重要性,正在于摒弃我们固有的偏见,而单纯从结果出发来辨别智能是否存在。

美国哲学家 Daniel Dennett 提出了哲学僵尸(Philosophical Zombie),也可以作为一个补充回应。用一个非常简化但并不准确的版本来说,这里的僵尸不是美剧 Walking Dead 中的僵尸,而是指那些只表现出智能,却并不真的具备智能的人类,假设他们存在的话。Daniel Dennett 认为,首先智能显然是一个进化出来的产物。其次,中文房间提出的观点如果成立,即表现出智能的主体并不一定真正具备智能。那么,两个同样在行为上表现出智能的主体,前者不具备真正的智能(只是装得像),而后者具备真正的智能,通过他的一系列的论证(这里就简化不展开,不考虑他论证的正确性),最后得到的结论是:前者比后者更有生存优势,故而地球上大多数人,其实都是只表现出智能,却不真正具备智能的僵尸。

能看到这,说明你肯定不是一具僵尸了。

0

从零搭建一个基于Istio的服务网格

上篇文章从微服务1.0时代的三大痛点(技术门槛高,多语言支持不足和代码侵入性强)说起,由此引出服务网格的起源和演化历史。但古语有云纸上得来终觉浅,绝知此事要躬行,不亲自撸一遍命令,怎敢跟人提服务网格?本篇我将教大家如何在本地从零搭建一个基于Istio的服务网格,从而对服务网格有一个更直观的认识。

1 通关密码:上上下下左左右右ABAB

  • 原料:Mac一台,VPN账号一枚
  • 做法:依序安装和运行KubernetesMinikube,Istio

mario

2 穿墙大法:Shadowsocks

无论是Kubernetes、Minikube还是Istio,官方提供的安装文档都非常详尽,只要英文过关,依葫芦画瓢基本上都能跑通。但如果你在国内,还得加一个必要条件,学会如何突破网络审查,俗称fan墙。

Mac下的穿墙软件我首推Shadowsocks,同时支持Socks5代理和HTTP代理,最新版本可以从GitHub下载。

3 小Boss: kubectl!

3.1 安装

Kubernetes是Istio首推的运行平台,因此作为第一步,我们首先来安装kubectl,Kubernetes的命令行工具,用来控制Kubernetes集群。根据官方文档,Mac下安装kubectl只需要一行命令,brew install kubectl,这简单、极致的用户体验让你感动的想哭。But wait...

3.2 穿墙1: Brew

你敲完命令,踌躇满志的按下回车之后,可能会发现,屏幕迟迟没有输出,10秒,30秒,1分钟,3分钟,10分钟。。。恭喜你,你被墙了!

Brew默认的镜像源是GitHub,而GitHub时不时会被墙,即使不被墙访问速度有时也慢的令人发指,导致Brew命令也常常超时甚至失败。解决办法要么换源,要么给GitHub配上Socks5代理。对码农而言,我更推荐后一种,方法如下:

1) 打开~/.gitconfig文件,如果不存在则新建

2) 在文件末尾添加如下配置并保存:

[http "https://github.com"]
  proxy = socks5://127.0.0.1:1086
[https "https://github.com"]
  proxy = socks5://127.0.0.1:1086

注:socks5://127.0.0.1:1086是Shadowsocks默认开启的Socks5代理地址。

配上Socks5代理之后,一般就可以妥妥的运行Brew命令了。

3.3 验证

安装好kubectl之后,直接运行kubectl version查看版本号。完整的kubectl命令列表在这里可以找到。如果想进一步学习常见的kubectl命令,可以访问Kubernetes Playground完成在线练习。

4 中Boss: Minikube!

4.1 安装

安装完kubectl,接下来就是在本地搭建Kubernetes集群,Minikube是最简单的一种搭建方式,它通过VM模拟了一个单节点的Kubernetes集群。官方文档给出了Mac下的安装命令。

curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-amd64 && \
chmod +x minikube && \
sudo mv minikube /usr/local/bin/

Minikube默认使用的VM Driver是VirutalBox,因此启动Minikube之前,还要安装VirtualBox。

4.2 启动

安装好Minikube和VirutalBox之后,可运行如下命令第一次启动Minikube:

minikube start --docker-env HTTP_PROXY=http://<本机IP>:1087 --docker-env HTTPS_PROXY=http://<本机IP>:1087

注:官方文档给出的启动命令带有--vm-driver=xhyve,而事实上最新版本的Minikube已经废弃了xhyve driver,应去除。

4.3 穿墙2: Docker

你可能已经注意到,上面的启动命令中带了两个--docker-env参数,都指向了Shadowsocks开启的HTTP代理,为啥呢?还是因为墙。Minikube默认使用Docker作为容器运行时,并在VM中内置了一个Docker守护进程,使用的镜像源是DockerHub。如果你经常使用Docker,那你一定知道在国内使用Docker一般都要修改镜像源(比如阿里云的容器镜像服务)或者使用代理,否则拉取速度也是慢的令人发指。由于Minikube使用的是内置的Docker守护进程,使用代理更为方便,但要注意,开启Shadowsocks HTTP代理时,需要修改代理的侦听地址为本机IP,而不是默认的127.0.0.1,否则在VM中的Docker守护进程是无法访问到这个代理的。

注:--docker-env参数只有在第一次启动Minikube时需要,之后启动直接运行minikube start即可。如果需要修改代理地址,可编辑~/.minikube/machines/minikube/config.json文件。

4.4 验证

安装完Minikube之后,就可以试着创建第一个Kubernetes服务了,具体步骤参考官方文档

5 大Boss: Istio!

5.1 安装

拿到了kubectl和Minikube两大神器,搭建Istio可以说是水到渠成了。基本步骤如下,

1) 启动Minikube

minikube start \
  --extra-config=controller-manager.ClusterSigningCertFile="/var/lib/localkube/certs/ca.crt" \
  --extra-config=controller-manager.ClusterSigningKeyFile="/var/lib/localkube/certs/ca.key" \
  --extra-config=apiserver.Admission.PluginNames=NamespaceLifecycle,LimitRanger,ServiceAccount,PersistentVolumeLabel,DefaultStorageClass,DefaultTolerationSeconds,MutatingAdmissionWebhook,ValidatingAdmissionWebhook,ResourceQuota \
  --kubernetes-version=v1.9.0

2) 下载并解压Istio安装包

curl -L https://git.io/getLatestIstio | sh -

3) 进入安装目录(假设为istio-0.7),将bin/目录添加到PATH环境变量

cd istio-0.7
export PATH=$PWD/bin:$PATH

4) 部署Istio的核心组件(包括外部流量网关Ingress, 管理Envoy实例生命周期的Pilot以及执行访问控制和使用策略的Mixer)到Kubernetes

kubectl apply -f install/kubernetes/istio.yaml

注:如果你需要启用Istio的Mutual TLS Authentication(服务身份验证)功能,可以改为运行kubectl apply -f install/kubernetes/istio-auth.yaml

至此,一个基于Istio的服务网格就算安装完成了。One more thing,还记得上篇文章提到的服务网格所独有的边车模式吗?为了将一个具体的服务接入Istio,需要为每一个服务实例创建一个配套的边车进程。根据官方文档,Istio提供手动和自动两种方式来创建边车进程,前者发生于部署阶段,而后者发生于Pod创建阶段,推荐使用后者,具体步骤参考官方文档,限于篇幅,这里就不再赘述。

5.2 验证

安装完Istio之后,可运行kubectl get pods -n istio-system查看所有Istio相关的Pods,确保这些Pods都处于Running状态。然后,你就可以开始Istio的探索之旅了,建议从官方提供的Bookinginfo示例应用起步,这里就不再展开。

NAME                                      READY     STATUS    RESTARTS   AGE
istio-ca-59f6dcb7d9-5mll5                 1/1       Running   18         42d
istio-ingress-779649ff5b-x2qmn            1/1       Running   26         42d
istio-mixer-7f4fd7dff-6l5g5               3/3       Running   54         42d
istio-pilot-5f5f76ddc8-6867m              2/2       Running   36         42d
istio-sidecar-injector-7947777478-gzcfz   1/1       Running   9          41d

6 参考

0

容器管理利器:Web Terminal 简介

一. 前言

在微服务大行其道的今天,容器恰巧又是微服务的主要载体,所以我们操作的对象也由最开始的「物理机」到「虚拟机」再到今天的「容器」。由于这些载体的变更,我们的使用方式也需要随之发生一些改变。比如一个最常用的登入操作,「虚拟机」下我们可能通过 ssh 的方式 ,但如果是容器呢?ssh 的方式就需要在每个容器中都运行一个 sshd 进程,这种做法可行但略显繁琐,也不太符合一个容器只运行一个进程的思想。
那么有没有一个即方便快捷又安全的登入方式呢?

有,通过 Web Terminal 的方式,通过 Web 的方式即可以避免对客户端的依赖又能够实现用户权限控制。目前,有很多开源的 Web Terminal 的项目,基本上都是通过 ssh 代理的方式调用并返回一个 shell 的虚拟终端(pty)。

ssh_proxy.png

二. 实现容器的 Web Terminal

2.1 架构图

docker ws.png

2.2 前端 Web Termianl 页面

Linux 终端返回的内容会带很多特殊的字符,比如我输入一个 ls 指令,终端返回的结果如下:

'l'
's'
'\r\n'
'\x1b[0;0mRUNNING_PID\x1b[0m  \x1b[1;34mbin\x1b[0m          \x1b[1;34mconf\x1b[0m         \x1b[1;34mlib\x1b[0m\r\nbash-4.3# '

这样我们就需要自己做穷举处理了,这里推荐使用一款的模拟 Terminal 的 JavaScript 库 xterm.js。这个库已经帮我们做了这些复杂操作。

<script>
  var term = new Terminal();
  term.open(document.getElementById('terminal'));
  term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>

可以看到它已经将 \x1B[1;3;31mxterm.js\x1B[0m 这些特殊字符变成了红色:

xterm-example.png

2.3 调用 Docker Daemon API 返回 Shell 虚拟终端

在平常的命令行操作下,我们经常会使用 docker exec -i -t <container_id> /bin/sh 来模拟一个 Shell 的伪终端。在 Web Terminal 实现里,我们需要通过 API 调用的方式来实现同样的操作。当然,我们首先要确保 Docker Daemon 的远程调用是开启的。

  1. 先调用 execCreate 来创建一个 Exec。在调用时,需要指定TtyAttachStdinAttachStdoutAttachStderr 参数均为 true,Cmd 参数为 bash,这样才能获得 bash 进程的标准输入输出和错误;

    request

    POST /v1.24/containers/e90e34656806/exec HTTP/1.1
    Content-Type: application/json
    
    {
      "AttachStdin": true,
      "AttachStdout": true,
      "AttachStderr": true,
      "Cmd": ["sh"],
      "DetachKeys": "ctrl-p,ctrl-q",
      "Tty": true,
      ...
    }
    
  2. 如果调用 execCreate 成功,调用请求会返回该 Exec 的 ID,根据这个 ID 继续调用execStart 接口。在调用时,需要指定 Detach 为 False,Tty 为 True,这样才会返回一个 HTTP 的 stream:

    request

    POST /v1.24/exec/e90e34656806/start HTTP/1.1
    Content-Type: application/json
    
    {
     "Detach": false,
     "Tty": true
    }
    

    response:

    HTTP/1.1 200 OK
    Content-Type: application/vnd.docker.raw-stream
    
    
    {{ STREAM }}
    

2.4 d-terminal

d-terminal 是这个系统的核心,它分成两个部分:

  1. 一部分用于处理用户端的输入和输出,以及存储和展示后端 Docker Dameon 主机的 IP 和 container_id。因为像 top 这样的监控命令需要服务端定时推送数据给客户端,所以使用了 WebSocket 协议以支持服务端推送。
  2. 另一部分用于调用 Docker Daemon 返回虚拟终端。对于终端来说,通常是你输入一个字符就会立马返回,直到你输入一个 "归位键" 终端才会把你输入的字符拼接成一个字符串并发送给 shell 解释器,并将 shell 解释器的结果返回。为了提升使用流畅性,新启用了一个线程去调用 Docker Daemon API,当然也可以使用像 epoll 这样的多路复用技术来实现。

main.png

d-terminal 是使用 Python 实现的 Web 应用,核心代码如下:

@sockets.route('/echo')
def echo_socket(ws):
    ...
    # 调用 Docker API 返回一个虚拟终端
    docker_daemon_sock = get_tty()._sock
    # 启动一个与 Docker Daemon 通讯的子线
    docker_daemon_sock_thd = DockerDaemonSock(ws, docker_daemon_sock)
    docker_daemon_sock_thd.start()

    while not ws.closed:
        message = ws.receive() # 接收 terminal 的输入
        # 将用户的输入发送那个 docker daemon
        docker_daemon_sock.send(bytes(message, encoding='utf-8'))


# 子线程 DockerDaemonSock 类
class DockerDaemonSock(threading.Thread):
    def __init__(self, ws, docker_daemon_sock):
        super(DockerDaemonSock, self).__init__()
        self.ws = ws
        self.docker_daemon_sock = docker_daemon_sock

    def run(self):
        while not self.ws.closed:
            try:
                # 接收 docker daemon 的返回
                resp = self.docker_daemon_sock.recv(2048)
                if resp:
                    # 将 docker daemon 的返回发送给前端 terminal
                    self.ws.send(str(resp, encoding='utf-8'))
                else:
                    print("docker socket closed.")
                    self.ws.close()
            except Exception as e:
                print("docker termial socket error: {}".format(e))
                self.ws.close()

三. 总结

上述仅仅是描述了一个最基本的实现,完全是为了抛砖引玉,后续可以通过在中间层添加一些扩展,比如,用户权限的分配,与自己环境中的容器编排引擎集成等,最终作为 Pass 平台的一个基础的组成部分。

最后,上述的 demo 可去 github 具体查看。效果如下:

image

四. 参考

0