函数式编程+ Kubernetes ,部署视频流录制服务器

avatar

汪磊是 WishLife CTO,他在 RTC 2019 大会上,分享了函数式编程,以及利用 Kubernetes 来进行视频流录制服务的部署。WishLife是一个利用视频帮助家庭解决家庭沟通的平台,平台提供视频录制服务,录制的视频会保存在云端,供家人来访问、观看。他分享了函数式编程语言Clojure、Kubernetes,以及视频录制服务部署,三方面的技术点和经验。

以下为演讲实录:

大家好,我是汪磊,我是一个重度的 Emacs 用户,现在写代码的语言有 Clojure、JavaScript 和 Java。我也是 GraphqlQL 的早期参与者。我创业过,也开发过千万用户级的 SaaS 产品。接下来会分享一些我在工作中的思考。

除了技术,我在生活中喜欢冲浪、滑雪。我发现了一个问题,我有太多想玩的东西,但自己的时间又太少。玩的时候会经常会接到工作电话。于是在想,有没有什么办法让产品少出问题,或者即使出现问题,也都是微乎其微的。

带着问题,我找到了答案,一个是函数式编程 Clojure,一个是 Kubernetes。函数式编程的优点在于,可以让我的工作效率提升了 10 倍,同样是一套代码,函数式编程写起来会更快。而 Kubernetes 让我们能将以往的服务器部署经验化为代码,让这件事情变得更具有可操作性,把运维工作变得可复制。

函数式编程如何提升 10 倍工作效率

平时我们使用的命令编程有三种基本结构:顺序、选择、循环。而函数式编程则是把电脑运算视为函数的计算,用公式来讲就是f(x)->y,我们要写的代码是函数 f,函数 f 接收一个输入 x,输出 y,函数式编程要写的代码就是这个函数 f,转换为工程语言就是“对某一个固定的输入,需要给一个输出”。我们在命令式编程中也会写这样的代码,函数式编程把它推到了另一个极端,所有做的事情都是函数,不用考虑顺序。这让你在想问题的思路完全不一样。在函数式编程中,输入的参数可以是一个函数,输出的结果也可以是一个函数。

函数式编程有什么特征呢?

在函数式编程中,你的代码就是数据。我们写代码写的多的人都会有这样一个感受:你今天写的代码,不管是自己当时写的时候多么酷,在六个月之后看,会觉得是垃圾代码。但当你的代码能够变成数据的时候,代码就具有了生命,它可以不断的变化,一个能够不断变化、不断进化的代码就不是一个死的代码。

我们写的很多代码,不管是用函数式编程还是用命令式编程写的,最终的目的是要有副作用。怎么理解这个问题?就相当于你写了一个非常精妙的算法,算法最终要有个输出,对外界要有个影响,比如在database里留下一条记录,写一个文件出来或者控制一些外界的系统,这样我们写的程序才会有用。

纯函数(pure function),它与副作用是相辅相成的。如果一个函数没有副作用,那它叫什么函数?所以在纯函数里面两者是相辅相成的。如果要让你的程序有用,要有副作用;如果要让你的函数变得可复用,那就不能有副作用。

第四个特征是不可变数据(immutable)。这是很重要的概念。在很多系统里都可以看到这个概念的身影。举个例子,在 Git 之前我们用的传统版本管理系统是SVN,存一个文件的副本,每次改了之后存一个变化,从概念上讲我们以为这样存的东西一定是很小的,但对Git来讲每份文件存一个副本,不会改原来的文件,很多人都会说Git存的文件的文件大小肯定比SVN存的文件的文件大小大很多,实际相反,每一次文件的改动存一个副本所占用的存储空间更小。同理,这也是 immutable 的优点。

最终,落到某个具体的语言上,这个语言要支持函数式编程,你不需要学习新的语言,你可以尝试用函数式的方式写Java,尽管思考问题时用的语法还是JAVA,但却是一门新的语言。我选择的函数式编程语言叫 Clojure,它基于Java虚拟机,是动态类型,没有类型定义的,基本的数据结构是不可变的数据结构。

怎么运行Clojure代码呢?第一种是解释执行,背后执行的方式是编译。Clojure代码最终会被读进去编译,生成JAVA的字节码。写JAVA代码和写Clojure代码,在运行上跟写JAVA代码没有任何区别。在写Clojure的时候更多的是用一个REPL(Read-Evalute-Print Loop)特性。我们写JAVA code的时候,把一个代码保存好,不管手多快,最少需要1分钟的时间。如果写Clojure code,你把代码写出来按回车,它就会执行这个结果,并把结果告诉你,这个过程可以快到10秒。你省去了手工编译执行的过程,Clojure会帮你做这件事。

我讲两个Clojure的基本语法。我不想教大家怎么写Clojure,主要是讲概念。我们写的JAVA code是这样的:

println("Hello World!")

函数式编程则是这样的:

(println "Hello World!")

把需要 Println 的内容用括号括起来,它是一个 list。这类语法很简单,第一个元素是函数,这个函数会被执行,这个语句会返回一个值。用函数的概念来理解就是,我们会调用函数Println,它没有输出,打印一个“Hello World!”到屏幕上。

如何定义一个函数呢?

(defn greet
  "Return a friendly greeting"
  [your-name]
  (str "Hello, " your-name))

还是通过一个 list,括号里的内容都是 list,你可以将 defn 看做为一个 function,greet 是它的函数名字,第二行是它的描述文档,第三行是它的参数,最后一行是它要执行的语句。也就是说,定义一个 greet 函数,会执行最后一行代码中所写的语句。比如,输入是“Tom”,输出就是“Hello, Tom”。

我们写JAVA代码的时候,平均一个函数最小20-50行代码。函数式编程,一个函数一般三五行,最多二十行就非常多了,二十行的代码可以顶得上一个200行的对应的JAVA代码。(演讲人在现场编程举例,详细 demo 请见文末「视频回顾」)

Apache commons 有一个 isBlank 。我们尝试着一步一步将其转换为 Clojure 代码。

  
public class StringUtils {
  public static boolean isBlank(String str) {
    int strLen;
    if (str == null || (strLen = str.length()) == 0) {
      return true;
    }
    for (int i = 0; i < strLen; i++) {
      if ((Character.isWhitespace(str.charAt(i)) == false)) {
        return false;
      }
}
    return true;
  }
}

我们之前说过,Clojure 是一个动态类型的语言,没有类型声明,所以我们首先要去掉其中所有的带有类型的代码,结果如下。

public class StringUtils {
  public isBlank(str) {
    if (str == null || (strLen = str.length()) == 0) {
      return true;
    }
    for (int i = 0; i < strLen; i++) {
      if ((Character.isWhitespace(str.charAt(i)) == false)) {
        return false;
} }
    return true;
  }
}

因为 Clojure 是一个 function program,没有 class 的概念,所以我们要去掉 JAVA 类。

  
public isBlank(str) {
  if (str == null || (strLen = str.length()) == 0) {
    return true;
  }
  for (int i = 0; i < strLen; i++) {
    if ((Character.isWhitespace(str.charAt(i)) == false)) {
      return false;
    }
}
  return true;
}

在 Clojure 中,函数是 first class citizen,所以此前代码中 for 循环在这里只需要一个 function 即可代替。

public isBlank(str) {
  if (str == null || (strlen = str.length() != 0) {
    return true;
  }
  every (ch in str) {
    Character.isWhitespace(ch);
}
  return true;
}

然后我们要去掉不需要的边界条件。

public isBlan(str) {
  every (ch in str) {
    Character.isWhitespace(ch);
  }
}

现在,我们再对比看一下 Clojure 代码应该是什么样的,如下

(defn blank? [s]
  (every? (fn [c] (Character/isWhitespace c)) s))

最终很冗长的 JAVA 代码,现在变成了一句话。这就是为什么我说,它帮我们提高了 10 倍的工作效率。绝大部分的函数已经有了,你只需要学的就是那些函数是什么。map、reduce、filter,我认为首先学会这三个 function,就可以解决大部分问题。举个例子,我们写的很多业务代码,基本上都是对一个数据集合的操作,这个操作有可能是给你一个数据集,让你返回一个同样大小的数据集,这个操作就是对应 Clojure 里的 map。另一种是,给你一个数据集,让你返回一个比它小的数据集,这就可以通过 Clojure 中的 reduce 来实现。filter 就很简单,就是条件函数。事实上,很多现金的技术,都是源于这种朴素的思想。

在用 Clojure 完成编写之后,存为一个 JAVA 包。剩下的工作就是,将它放到 production 里。之前我们要考虑机器环境是可以达到运行条件,现在就简单了,通过 Docker 就可以让我们的程序运行到任何一台机器上。不过,还有一个问题,一个 Docker container 只是一个实例,我们要如何大规模部署,这时候,我们就需要用到 Kubernetes。

用 Kubernetes 部署视频流录制服务

Kubernetes是一个生产级别的容器管理平台,可以实现自动化的容器部署、扩展和管理。所以我们之前写的代码就变成了一个 Docker 镜像,然后通过 Kubernetes 实现自动化的部署。

Kubernetes 有几个基本概念是需要我们理解的:

  1. Cluster
  2. Node
  3. Pod
  4. Service
  5. IngressController
  6. PersistentVolume
  7. PersistentVolumeClaim

我们看一下概念图。这是一个用户在云上访问到了load balancer,通过load balancer 到 cluster 之后,会有一个 ingress controller 接收用户的请求,用户请求被派到 service 上,对应depolyment,最终到某个container 被执行,最终反馈会通过原路返回。知道以上的几个概念以后你就可以把任何产品部署上去。

部署的方式是声明式部署。声明式部署是什么意思呢?我们之前在部署的时候会执行很多命令,每个命令执行完它就消失了,你没有办法让其他人知道部署的过程,这些操作的方法都是无法传递给下一个需要负责运维的同事的。在Kubernetes里,会用一个 yaml file 来描述怎么部署,Kubernetes 通过这个声明文件,来帮你进行部署,你所有关于系统部署的操作全部写在其中,后面接手值班的同事看到文件就知道该怎样操作。

接下来,我们看到的是 yaml file。根据上述的架构,我们要从底层的Deployment开始进行描述,直至ingress controller。

第一步我们要描述 Deployment。我们要部署一个 recording-server,image 的地址是us.gcr.io/qatest-220319/recording-server,具体代码如下。

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  labels:
    app: recording-server
  name: recording-server
spec:
  replicas: 1
  ...
  template:
...
    spec:
      containers:
      - image: us.gcr.io/qatest-220319/recording-server
        name: recording-server
        ports:
        - containerPort: 8080

Service 的描述如下,最终会把服务 dispatch 到 recording-server上。

apiVersion: v1
kind: Service
metadata:
  labels:
    app: recording-service
  name: recording-service
spec:
  ports:
  - port: 8080
    name: high
    protocol: TCP
    targetPort: 8080
  selector:
    app: recording-server

然后是 Ingress Controller。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: recording-service-example-com
  annotations:
    kubernetes.io/ingress.class: "kong"
    kubernetes.io/tls-acme: "true"
    certmanager.k8s.io/cluster-issuer: letsencrypt-prod
spec: tls:
  - secretName: recording-service-example-com
    hosts:
    - recording.example.com
  rules:
  - host: recording.example.com
    http:
      paths:
      - path: /
        backend:
          serviceName: recording-service
  servicePort: 80
  

完成上一步之后,一个无状态的服务就起来了。在这里,我们要做的业务是录制视频,那么最终会产生很多录制文件。但是现在 pod 是无状态的,一旦 pod 被终止,所以的录制文件都会丢失。所以我们现在需要 Volume 来存储这些录制文件。Pod 虽然会消失,但是 Volume 不会。如下是我们定义一个 Volume 的过程。

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: recording-server
          image: us.gcr.io/qatest-220319/recording-server
          volumeMounts:
          - name: "recording-server-pv-storage"
            mountPath: /data
      volumes:
        - name: "recording-server-pv-storage"
          persistentVolumeClaim:
            claimName: "recording-server-pv-claim"

但是,我们不能每一台服务器都通过手工来部署 Volume,所以这个时候我们就需要通过这个 StatefulSet 来管理录制文件的存储。

apiVersion: apps/v1
kind: StatefulSet
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: recording-server
          image: us.gcr.io/qatest-220319/recording-server
          volumeMounts:
          - name: "recording-server-pv-template"
            mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: recording-server-pv-template
      spec:
        resources:
          requests:
            storage: 128Gi
        accessModes: ["ReadWriteOnce"]
        

最终,我们还需要增加一个多用户多环境的描述。这个是通过使用Namespace 来实现的。

apiVersion: v1
kind: Namespace
metadata:
  name: qa-recording

最后为了方便地定义、安装和升级Kubernetes,我们可以引入Helm Charts。我们可以通过它提供的模板、命令行工具来完成这些操作。


最后,附上PPT下载及视频回顾地址