跳到主要内容
  1. Posts/

风谲云诡:云原生技术原理

·

精密而复杂。

容器 #

Namespace #

Linux 采用 Namespace 技术进行资源隔离,可以为不同进程分配不同的 Namespace,有点类似沙箱的概念。在 Linux 进程的数据结构中,nsproxy 结构体负责管理 Namespace:

struct nsproxy {
  atomic_t             count;
  struct uts_namespace *uts_ns;
  struct ipc_namespace *ipc_ns;
  struct mnt_namespace *mnt_ns;
  struct pid_namespace *pid_ns_for_children;
  struct net           *net_ns;
}

默认情况下父子进程共享 Namespace,但也可以通过调用 clonesetnsunshare 等方法手动指定和修改 Namespace。

以上面结构体的 pid_namespace 为例,两个不同的 PID Namespace 下的进程之间是互不影响的。类似的,网络、文件系统、用户、挂载点等的 Namespace 之间也同理。

可以看到,Docker 实际上就是对 Namespace 的一次封装,因此在宿主机上调试 Docker 内部程序时,也可以借助 Namespace 的命令行工具。先获取对应容器的 PID:

$ docker inspect [docker id] | grep pid

再用 nsenter 进入对应的 Namespace,例如进入网络 Namespace 使用 -n

$ nsenter -t [pid] -n [cmd]

容器实际上仅仅是设置了 Namespace 的特殊进程,这使得它不存在虚拟化带来的性能损耗,但也使得不同容器必须共享一个宿主机 OS 内核。

Cgroups #

Cgroups 对进程使用的计算资源进行管控,对不同类型的资源采用不同子系统,并在子系统中采用层级树结构(/sys/fs/cgroup)。

🌰 限制进程使用的 CPU 资源 #

首先进入 cpu 子系统,将进程加入 cgroup:

$ cd /sys/fs/cgroup/cpu
$ echo [pid] > cgroup.procs

随后关注 cpu.cfs_quota_uscpu.cfs_period_us,两者的比值即进程能占用 CPU 资源的最高比例,默认值为 -1(无限制) 和 100000

例如,设置最多占用 25% CPU 资源:

$ echo 25000 > cpu.cfs_quota_us

然而,如果在容器内执行 top 等系统监控命令,由于会读取 /proc 下的信息,实际获取的是宿主机的指标。这一问题可以通过 lxcfs 解决。

UnionFS #

顾名思义,UnionFS 可以对文件系统 “取并集”,也就是将不同目录挂载到同一个虚拟文件系统下。

经典的 Linux 系统中,使用 bootfs 中的 BootLoader 引导加载 Kernel 到内存中,然后 umount 掉 bootfs。Kernel 加载完成后,就会使用我们熟悉的 rootfs 文件系统(/)。启动时先将 rootfs 设为 readonly 进行检查,随后再设为 readwrite 供使用。

而在 Docker 启动时,检查完 readonly 的 rootfs 后会再 union mount 一个 readwrite 的文件系统,称为一个 FS 层。后续会继续添加 readwrite 的 FS 层,每次添加时将当前最顶层的 FS 层设为 readonly。这实际上就是 docker build 根据 Dockerfile 中每一行的指令堆叠 FS 层的过程。

那么如果要修改下层 readonly FS 层的文件怎么办呢?只需要 Copy-on-Write,将文件复制到可写的顶层并修改即可。如果是删除,则需要在可写的顶层创建 .wh. 开头的文件用来 whiteout 下层的同名文件。这样能成功是因为 Docker 采用的 OverlayFS 在合并上下层同名文件时,优先选择上层文件。

最后,FS 层可以在不同镜像之间复用,节省镜像构建时间和硬盘占用。

Serverless #

服务端运维发展 #

  • Serverfull 阶段,研发和运维完全隔离,运维完全靠人力处理,效率低下
  • DevOps 阶段,研发接替了运维的部分工作,运维工具化,一定程度上提升了效率
  • Serverless 阶段,运维完全自动化(NoOps),最终实现服务端免运维

FaaS #

Serverless 并不是指不需要服务器,而是指对服务器运维的极端抽象。我们知道,在程序设计领域发生的抽象,都是为了降低开发难度和成本、让开发者更专注于真正有价值的工作。因此,Serverless 主要是针对后端运维进行的一种优化。

Serverless 首先提出的概念是函数即服务 FaaS,它是一种临时的 Runtime,用于运行无状态函数以便自动扩缩容。FaaS 是事件驱动的,大体可以分成函数代码、函数服务、触发器三个部分。

  • 触发器接收用户请求并通知函数服务。实际上是对负载均衡、反向代理等中间件工作的抽象
  • 函数服务收到消息后,检查是否有可用的函数实例,没有则从仓库拉取函数代码来初始化一个新的函数实例;最后将用户请求作为函数参数,执行函数实例,返回的结果将原路返回。实际上是对代码运行环境的抽象
  • 函数代码一般在 git 之类的版本控制仓库。实际上是对代码上传和部署的抽象

弹性伸缩 #

值得一提的是,FaaS 能根据目前负载对占用资源进行弹性伸缩,无负载时甚至可以不占用资源(相比之下,PaaS 通常无法缩容至 0)。这能够很大程度上提升资源利用率。

冷启动 #

冷启动和热启动相反,从一个未初始化的服务开始,直到函数实例执行完毕结束。由于可能涉及比较繁琐的初始化工作,传统服务也许能够在热启动上达到很快的速度,但在冷启动上不行。

FaaS 则通过容器、运行环境、代码三者分层并分别缓存,获得了较快的冷启动速度,一般大约在几百毫秒内。显然,这是牺牲了用户对底层环境的可控性以及应用场景换来的。

例如,由于分层的结构,FaaS 可以预先下载代码,并构建镜像缓存,这一流程通常称为“预热”。

语言无关性 #

FaaS 可以替换传统前后端分离开发中的后端服务、可以用来请求公开的 Web API、更重要的是可以和其他云服务商提供的服务进行联动。由于前端只在意最后返回的数据,我们的函数服务完全可以混合采用多种不同的语言来编写,以适应不同的需求,服务编排也更灵活。

进程模型 #

FaaS 存在两种进程模型:用完即毁(无状态)和常驻进程(有状态),前者使用得更多也更符合 FaaS 的定位。后者则更适合传统 MVC 应用的迁移,但会增加冷启动时间。

数据库? #

实际上,FaaS 中的函数实例都活不了太久,有的执行完就被销毁了,而有的可能能在内存中多待一会儿,但云服务商经过一小段时间后仍会销毁它们,这是因为 FaaS 需要弹性伸缩,它的核心是无状态的函数(就像 HTTP 协议是无状态的一样)。

这就给数据持久化带来了问题,比如数据库就不能放在 FaaS 的主进程中。但把数据库单独拿出来,再通过一个进程去连接并访问它,这样又会显著增加冷启动的时间。

解决办法就是不再连接数据库,而是通过 RESTful API 访问数据库。这里的 RESTful API 实际上就是一种后端即服务 BaaS 了,它提供了访问后端数据库的接口,使得 FaaS 不再需要考虑数据持久化的问题。

实际应用中,加工来自后端的数据供前端使用的功能被封装为 BFF(Backend For Frontend) 层,这一层也可以使用 FaaS 来实现。

BaaS #

BaaS 和 FaaS 的区别在于,BaaS 可以运行第三方服务并持久化数据。

后端 BaaS 化为了降低运维成本,往往会将复杂业务逻辑拆分成单一职责的微服务,形成微服务架构。这就要求各微服务之间相对独立,意味着每个服务的数据库也需要解耦合。对这类分布式数据库而言,最重要的就是解决数据一致性的问题,例如通过消息队列或是 Raft 协议等。

值得一提的是,FaaS 和 BaaS 的底层实际上使用容器技术实现,所以我们可以在本地用 Kubernetes 搭建自己的 Serverless 平台(见后文 Kubernetes 部分)。

缺点 #

  • 技术尚不成熟,许多云服务商提供的 Serverless 服务存在不少 bug
  • Serverless 平台对开发者来说是个黑盒子,想在上面调试代码、排查问题,需要付出极大成本
  • 同理,Serverless 平台上的运行时环境只支持部分定制
  • 每次部署代码都需要压缩代码后上传,较繁琐
  • 云服务商提供的生态(如代码调试工具)都是封闭的,形成 Vendor-lock;这一点可能可以通过 Serverless、Midway FaaS 等框架解决

Kubernetes #

架构 #

K8s 用来管理容器集群,它的好处在 官方文档 里已经写得很清楚了,而它的原理大致可以概括为一张架构图:

图 1|K8s 架构

通过 CLI 工具 kubectl,我们可以访问到运行在 K8s Master Node 上的 API Server,也是整个集群的核心。Node 实际上是对计算资源的一种抽象,每个 Node 上运行一个或多个 Pod,即应用实例。一般情况下,一个 Pod 上推荐运行一个容器。

在 Master Node 上还有键值数据库 etcd、监视 Pod 的调度器 Scheduler、不同类型的控制器 Controller Manager 以及连接云服务厂商 API 的 Cloud Controller Manager。

而在普通 Node 上则运行了一个 kubelet,负责通知 API Server 容器运行状态。此外,为了让外界能够访问到容器运行的服务,需要用 K8s Service 通过 kube-proxy 暴露该服务。

最后,不同的 K8s 集群之间通过 Namespace 隔离,注意这和上文写容器技术时提到的 Linux Namespace 并非同一概念,尽管思想是相似的。

安装 #

K8s 的安装令人惊讶地简单。就像我们在架构图中看到的那样,安装 K8s 主要分为安装 kubectl 和 安装 K8s 集群两个步骤。

安装 K8s 集群 #

第一种方式是通过 Docker Desktop 安装。实际上 Docker Desktop 自带了 K8s(不是最新版本,但也比较新),在设置里勾选即可。

第二种方式是通过 kubeadm、minikube、kind 等工具安装,无论哪种方式都比较简单,这里以 minikube 为例。

minikube 内置了 kubectl,所以之后可以选择不另外安装 kubectl。

按照 官方文档,直接 install 二进制文件即可。

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-amd64
$ sudo install minikube-darwin-amd64 /usr/local/bin/minikube

安装 kubectl #

brew install kubectl,没了。

然而需要注意的是,kubectl 版本和 K8s 集群版本之间相差不能超过 0.0.2,否则容易出现不兼容的情况。例如,如果用 Docker Desktop 安装的 1.21.4 版本的集群,则需要手动安装:

$ curl -LO "https://dl.k8s.io/release/v1.21.4/bin/darwin/arm64/kubectl"
$ chmod +x ./kubectl
$ sudo mv ./kubectl /usr/local/bin/kubectl
$ sudo chown root: /usr/local/bin/kubectl

实践 #

首先设置好别名,方便后续操作(这里直接使用了 minikube 内置的 kubectl):

alias k="minikube kubectl --"
alias dps="docker ps -a"
alias dr="docker rm -f"
alias dil="docker image ls"
alias dir="docker image rm"
alias ds="docker start"
alias dx="docker exec -it"
alias mk="minikube"

启动 minikube:

$ mk start

部署应用并检查:

$ k create deploy echo-server --image=k8s.gcr.io/echoserver-arm:1.8
$ k get deploy
# result:
NAME          READY   UP-TO-DATE   AVAILABLE   AGE
echo-server   1/1     1            1           1m

因为是 M1 芯片,所以用的 ARM 镜像。

检查 Pod 情况:

$ k get po
# result:
NAME                          READY   STATUS    RESTARTS   AGE
echo-server-9f4db688c-r288r   1/1     Running   0          89

暴露服务并检查:

$ k expose deploy echo-server --type=LoadBalancer --port=8080
$ k get svc
# result:
NAME          TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
echo-server   LoadBalancer   10.111.217.237   <pending>     8080:31389/TCP   1m
kubernetes    ClusterIP      10.96.0.1        <none>        443/TCP          100m

这里暴露了一个 LoadBalancer 类型的服务,也可以换成 NodePort 类型服务。8080 是我们的 echoserver 容器内的服务端口。

此外,可以发现还有一个 kubernetes 服务,这就是 K8s 集群的 API Server。

为了访问暴露的服务,可以手动端口转发,也可以通过 minikube 自动访问:

$ mk service echo-server

注意到上面 echo-serverEXTERNAL-IP 还在等待分配,我们还可以用 mk tunnel 建立隧道从而分配外部访问的 IP。

上述信息也可以通过 Dashboard 图形化界面查看:

$ mk dashboard

有趣的是,K8s 服务也是由 K8s 自己管理的,它运行在 kube-system 的 Namespace 中。

$ k get po,svc -n kube-system
# result:
NAME                                   READY   STATUS    RESTARTS       AGE
pod/coredns-78fcd69978-xlh28           1/1     Running   0              141m
pod/etcd-minikube                      1/1     Running   0              142m
pod/kube-apiserver-minikube            1/1     Running   0              142m
pod/kube-controller-manager-minikube   1/1     Running   0              142m
pod/kube-proxy-gblfw                   1/1     Running   0              141m
pod/kube-scheduler-minikube            1/1     Running   0              142m
pod/storage-provisioner                1/1     Running   1 (141m ago)   142m

NAME               TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE
service/kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   142m

对于其他平台,kubectl 命令不变,替换上述 mk 相关命令即可。

Service Mesh #

微服务架构中,微服务之间必须要通信,导致微服务通信相关代码和业务代码的强耦合。Service Mesh 正是为了抽离出微服务通信的逻辑,让开发者专注于业务代码编写。它在数据面板中通过 Sidecar 劫持微服务 Pod 的流量,从而接管了整个网络通信的功能。

Istio 安装 #

Kubernetes 采用 Istio 作为 Server Mesh,首先下载并安装,安装前记得给 Docker Desktop 或 minikube 分配 8 - 16 G 内存:

$ curl -L https://istio.io/downloadIstio | sh -
$ mv istio-1.11.2/bin/istioctl /usr/local/bin
$ istioctl install --set profile=demo -y

令人痛心的是,Istio 官方 并不支持、也 不打算支持 ARM 架构,因此在 M1 下安装时不能直接使用最后一行命令自动化安装,而需要借助 这个社区版镜像,自己编写 Operator 进行安装:

apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
metadata:
  namespace: istio-system
  name: arm-istiocontrolplane
spec:
  hub: docker.io/querycapistio
  profile: demo
  components:
    pilot:
      k8s: # each components have to set this
        affinity: &affinity
          nodeAffinity:
            preferredDuringSchedulingIgnoredDuringExecution:
              - preference:
                  matchExpressions:
                    - key: beta.kubernetes.io/arch
                      operator: In
                      values:
                        - arm64
                        - amd64
                weight: 2
            requiredDuringSchedulingIgnoredDuringExecution:
              nodeSelectorTerms:
                - matchExpressions:
                    - key: beta.kubernetes.io/arch
                      operator: In
                      values:
                        - arm64
                        - amd64
    egressGateways:
      - name: istio-egressgateway
        k8s:
          affinity: *affinity
        enabled: true
    ingressGateways:
      - name: istio-ingressgateway
        k8s:
          affinity: *affinity
        enabled: true

将这个 Operator 保存为 install-istio.yml,随后 istioctl install -f ./install-istio.yml 完成安装。

Update: Istio 1.15 已支持 ARM64 架构。

应用部署 #

安装完成后,记得开启 Sidecar 注入来劫持流量:

$ k label ns default istio-injection=enabled

随后即可部署应用并查看状态:

$ k apply -f samples/bookinfo/platform/kube/bookinfo.yaml
$ k get po
# result:
NAME                              READY   STATUS    RESTARTS   AGE
details-v1-79f774bdb9-ns6gl       2/2     Running   0          76s
productpage-v1-6b746f74dc-qp7mg   2/2     Running   0          76s
ratings-v1-b6994bb9-mflsk         2/2     Running   0          76s
reviews-v1-545db77b95-24tsl       2/2     Running   0          76s
reviews-v2-7bf8c9648f-b8bq4       2/2     Running   0          76s
reviews-v3-84779c7bbc-hxkxg       2/2     Running   0          76s

$ k get svc
# result:
NAME          TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
details       ClusterIP   10.102.117.210   <none>        9080/TCP   105s
kubernetes    ClusterIP   10.96.0.1        <none>        443/TCP    27m
productpage   ClusterIP   10.101.203.214   <none>        9080/TCP   105s
ratings       ClusterIP   10.105.60.88     <none>        9080/TCP   105s
reviews       ClusterIP   10.100.137.99    <none>        9080/TCP   105s

最后,检查实际应用是否正常运行:

$ k exec "$(k get po -l app=ratings -o jsonpath='{.items[0].metadata.name}')" -c ratings -- curl -sS productpage:9080/productpage | grep -o "<title>.*</title>"
# result:
<title>Simple Bookstore App</title>

上述命令的意思是:在 ratings 对应的 pod 中的 ratings 容器里运行 curl -sS productpage:9080/productpage 发起请求,并在返回的 html 中查找标题。需要这么复杂是因为此时我们的服务还没有外部 IP,只能在集群内部访问。

通过 Ingress 网关让应用能够从外部访问 #

首先部署好设置了网关的应用并检查:

$ k apply -f samples/bookinfo/networking/bookinfo-gateway.yaml
$ istioctl analyze

获取主机、http2 端口和 https 端口:

$ export INGRESS_HOST=$(k -n istio-system get svc istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
$ export INGRESS_PORT=$(k -n istio-system get svc istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].port}')

如果设置完后 $INGRESS_HOST 为空,说明 LoadBalancer 此时的地址为主机名而不是 IP,只需要修改一下设置即可:

$ export INGRESS_HOST=$(k -n istio-system get svc istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')

随后访问 http://$INGRESS_HOST:$INGRESS_PORT 即可。

通过 Kiali 查看图形化界面 #

安装 Kiali、Prometheus、Grafana、Jarger 等插件,检查部署状态:

$ k apply -f samples/addons/
$ k rollout status deploy kiali -n istio-system

随后就可以查看图形化界面了:

$ istioctl dashboard kiali

编写脚本产生流量:

for i in $(seq 1 100); do
  curl -s -o /dev/null "http://localhost/productpage";
done

最后就可以看到整个 Service Mesh 的架构、以及网络请求数据流了,非常清晰。