Linux中國

Kubernetes 分散式應用部署實戰:以人臉識別應用為例

簡介

夥計們,請搬好小板凳坐好,下面將是一段漫長的旅程,期望你能夠樂在其中。

我將基於 Kubernetes 部署一個分散式應用。我曾試圖編寫一個儘可能真實的應用,但由於時間和精力有限,最終砍掉了很多細節。

我將聚焦 Kubernetes 及其部署。

讓我們開始吧。

應用

TL;DR

該應用本身由 6 個組件構成。代碼可以從如下鏈接中找到:Kubenetes 集群示例

這是一個人臉識別服務,通過比較已知個人的圖片,識別給定圖片對應的個人。前端頁面用表格形式簡要的展示圖片及對應的個人。具體而言,向 接收器 發送請求,請求包含指向一個圖片的鏈接。圖片可以位於任何位置。接受器將圖片地址存儲到資料庫 (MySQL) 中,然後向隊列發送處理請求,請求中包含已保存圖片的 ID。這裡我們使用 NSQ 建立隊列。

圖片處理 服務一直監聽處理請求隊列,從中獲取任務。處理過程包括如下幾步:獲取圖片 ID,讀取圖片,通過 gRPC 將圖片路徑發送至 Python 編寫的 人臉識別 後端。如果識別成功,後端給出圖片對應個人的名字。圖片處理器進而根據個人 ID 更新圖片記錄,將其標記為處理成功。如果識別不成功,圖片被標記為待解決。如果圖片識別過程中出現錯誤,圖片被標記為失敗。

標記為失敗的圖片可以通過計劃任務等方式進行重試。

那麼具體是如何工作的呢?我們深入探索一下。

接收器

接收器服務是整個流程的起點,通過如下形式的 API 接收請求:

curl -d '{"path":"/unknown_images/unknown0001.jpg"}' http://127.0.0.1:8000/image/post

此時,接收器將 路徑 path 存儲到共享資料庫集群中,該實體存儲後將從資料庫服務收到對應的 ID。本應用採用「 實體對象 Entity Object 的唯一標識由持久層提供」的模型。獲得實體 ID 後,接收器向 NSQ 發送消息,至此接收器的工作完成。

圖片處理器

從這裡開始變得有趣起來。圖片處理器首次運行時會創建兩個 Go 協程 routine ,具體為:

Consume

這是一個 NSQ 消費者,需要完成三項必需的任務。首先,監聽隊列中的消息。其次,當有新消息到達時,將對應的 ID 追加到一個線程安全的 ID 片段中,以供第二個協程處理。最後,告知第二個協程處理新任務,方法為 sync.Condition

ProcessImages

該協程會處理指定 ID 片段,直到對應片段全部處理完成。當處理完一個片段後,該協程並不是在一個通道上睡眠等待,而是進入懸掛狀態。對每個 ID,按如下步驟順序處理:

  • 人臉識別服務建立 gRPC 連接,其中人臉識別服務會在人臉識別部分進行介紹
  • 從資料庫獲取圖片對應的實體
  • 斷路器 準備兩個函數
    • 函數 1: 用於 RPC 方法調用的主函數
    • 函數 2: 基於 ping 的斷路器健康檢查
  • 調用函數 1 將圖片路徑發送至人臉識別服務,其中路徑應該是人臉識別服務可以訪問的,最好是共享的,例如 NFS
  • 如果調用失敗,將圖片實體狀態更新為 FAILEDPROCESSING
  • 如果調用成功,返回值是一個圖片的名字,對應資料庫中的一個個人。通過聯合 SQL 查詢,獲取對應個人的 ID
  • 將資料庫中的圖片實體狀態更新為 PROCESSED,更新圖片被識別成的個人的 ID

這個服務可以複製多份同時運行。

斷路器

即使對於一個複製資源幾乎沒有開銷的系統,也會有意外的情況發生,例如網路故障或任何兩個服務之間的通信存在問題等。我在 gRPC 調用中實現了一個簡單的斷路器,這十分有趣。

下面給出工作原理:

當出現 5 次不成功的服務調用時,斷路器啟動並阻斷後續的調用請求。經過指定的時間後,它對服務進行健康檢查並判斷是否恢復。如果問題依然存在,等待時間會進一步增大。如果已經恢復,斷路器停止對服務調用的阻斷,允許請求流量通過。

前端

前端只包含一個極其簡單的表格視圖,通過 Go 自身的 html/模板顯示一系列圖片。

人臉識別

人臉識別是整個識別的關鍵點。僅因為追求靈活性,我將這個服務設計為基於 gRPC 的服務。最初我使用 Go 編寫,但後續發現基於 Python 的實現更加適合。事實上,不算 gRPC 部分的代碼,人臉識別部分僅有 7 行代碼。我使用的人臉識別庫極為出色,它包含 OpenCV 的全部 C 綁定。維護 API 標準意味著只要標準本身不變,實現可以任意改變。

注意:我曾經試圖使用 GoCV,這是一個極好的 Go 庫,但欠缺所需的 C 綁定。推薦馬上了解一下這個庫,它會讓你大吃一驚,例如編寫若干行代碼即可實現實時攝像處理。

這個 Python 庫的工作方式本質上很簡單。準備一些你認識的人的圖片,把信息記錄下來。對於我而言,我有一個圖片文件夾,包含若干圖片,名稱分別為 hannibal_1.jpghannibal_2.jpggergely_1.jpgjohn_doe.jpg。在資料庫中,我使用兩個表記錄信息,分別為 personperson_images,具體如下:

+----+----------+
| id | name     |
+----+----------+
|  1 | Gergely  |
|  2 | John Doe |
|  3 | Hannibal |
+----+----------+
+----+----------------+-----------+
| id | image_name     | person_id |
+----+----------------+-----------+
|  1 | hannibal_1.jpg |         3 |
|  2 | hannibal_2.jpg |         3 |
+----+----------------+-----------+

人臉識別庫識別出未知圖片後,返回圖片的名字。我們接著使用類似下面的聯合查詢找到對應的個人。

select person.name, person.id from person inner join person_images as pi on person.id = pi.person_id where image_name = 'hannibal_2.jpg';

gRPC 調用返回的個人 ID 用於更新圖片的 person 列。

NSQ

NSQ 是 Go 編寫的小規模隊列,可擴展且佔用系統內存較少。NSQ 包含一個查詢服務,用於消費者接收消息;包含一個守護進程,用於發送消息。

在 NSQ 的設計理念中,消息發送程序應該與守護進程在同一台主機上,故發送程序僅需發送至 localhost。但守護進程與查詢服務相連接,這使其構成了全局隊列。

這意味著有多少 NSQ 守護進程就有多少對應的發送程序。但由於其資源消耗極小,不會影響主程序的資源使用。

配置

為了儘可能增加靈活性以及使用 Kubernetes 的 ConfigSet 特性,我在開發過程中使用 .env 文件記錄配置信息,例如資料庫服務的地址以及 NSQ 的查詢地址。在生產環境或 Kubernetes 環境中,我將使用環境變數屬性配置。

應用小結

這就是待部署應用的全部架構信息。應用的各個組件都是可變更的,他們之間僅通過資料庫、消息隊列和 gRPC 進行耦合。考慮到更新機制的原理,這是部署分散式應用所必須的;在部署部分我會繼續分析。

使用 Kubernetes 部署應用

基礎知識

Kubernetes 是什麼?

這裡我會提到一些基礎知識,但不會深入細節,細節可以用一本書的篇幅描述,例如 Kubernetes 構建與運行。另外,如果你願意挑戰自己,可以查看官方文檔:Kubernetes 文檔

Kubernetes 是容器化服務及應用的管理器。它易於擴展,可以管理大量容器;更重要的是,可以通過基於 yaml 的模板文件高度靈活地進行配置。人們經常把 Kubernetes 比作 Docker Swarm,但 Kubernetes 的功能不僅僅如此。例如,Kubernetes 不關心底層容器實現,你可以使用 LXC 與 Kubernetes 的組合,效果與使用 Docker 一樣好。Kubernetes 在管理容器的基礎上,可以管理已部署的服務或應用集群。如何操作呢?讓我們概覽一下用於構成 Kubernetes 的模塊。

在 Kubernetes 中,你給出期望的應用狀態,Kubernetes 會盡其所能達到對應的狀態。狀態可以是已部署、已暫停,有 2 個副本等,以此類推。

Kubernetes 使用標籤和注釋標記組件,包括服務、部署、副本組、守護進程組等在內的全部組件都被標記。考慮如下場景,為了識別 pod 與應用的對應關係,使用 app: myapp 標籤。假設應用已部署 2 個容器,如果你移除其中一個容器的 app 標籤,Kubernetes 只能識別到一個容器(隸屬於應用),進而啟動一個新的具有 myapp 標籤的實例。

Kubernetes 集群

要使用 Kubernetes,需要先搭建一個 Kubernetes 集群。搭建 Kubernetes 集群可能是一個痛苦的經歷,但所幸有工具可以幫助我們。Minikube 為我們在本地搭建一個單節點集群。AWS 的一個 beta 服務工作方式類似於 Kubernetes 集群,你只需請求節點並定義你的部署即可。Kubernetes 集群組件的文檔如下:Kubernetes 集群組件

節點

節點 node 是工作單位,形式可以是虛擬機、物理機,也可以是各種類型的雲主機。

Pod

Pod 是本地容器邏輯上組成的集合,即一個 Pod 中可能包含若干個容器。Pod 創建後具有自己的 DNS 和虛擬 IP,這樣 Kubernetes 可以對到達流量進行負載均衡。你幾乎不需要直接和容器打交道;即使是調試的時候,例如查看日誌,你通常調用 kubectl logs deployment/your-app -f 查看部署日誌,而不是使用 -c container_name 查看具體某個容器的日誌。-f 參數表示從日誌尾部進行流式輸出。

部署

在 Kubernetes 中創建任何類型的資源時,後台使用一個 部署 deployment 組件,它指定了資源的期望狀態。使用部署對象,你可以將 Pod 或服務變更為另外的狀態,也可以更新應用或上線新版本應用。你一般不會直接操作副本組 (後續會描述),而是通過部署對象創建並管理。

服務

默認情況下,Pod 會獲取一個 IP 地址。但考慮到 Pod 是 Kubernetes 中的易失性組件,我們需要更加持久的組件。不論是隊列,MySQL、內部 API 或前端,都需要長期運行並使用保持不變的 IP 或更好的 DNS 記錄。

為解決這個問題,Kubernetes 提供了 服務 service 組件,可以定義訪問模式,支持的模式包括負載均衡、簡單 IP 或內部 DNS。

Kubernetes 如何獲知服務運行正常呢?你可以配置健康性檢查和可用性檢查。健康性檢查是指檢查容器是否處於運行狀態,但容器處於運行狀態並不意味著服務運行正常。對此,你應該使用可用性檢查,即請求應用的一個特別 介面 endpoint

由於服務非常重要,推薦你找時間閱讀以下文檔:服務。嚴肅的說,需要閱讀的東西很多,有 24 頁 A4 紙的篇幅,涉及網路、服務及自動發現。這也有助於你決定是否真的打算在生產環境中使用 Kubernetes。

DNS / 服務發現

在 Kubernetes 集群中創建服務後,該服務會從名為 kube-proxykube-dns 的特殊 Kubernetes 部署中獲取一個 DNS 記錄。它們兩個用於提供集群內的服務發現。如果你有一個正在運行的 MySQL 服務並配置 clusterIP: no,那麼集群內部任何人都可以通過 mysql.default.svc.cluster.local 訪問該服務,其中:

  • mysql – 服務的名稱
  • default – 命名空間的名稱
  • svc – 對應服務分類
  • cluster.local – 本地集群的域名

可以使用自定義設置更改本地集群的域名。如果想讓服務可以從集群外訪問,需要使用 DNS 服務,並使用例如 Nginx 將 IP 地址綁定至記錄。服務對應的對外 IP 地址可以使用如下命令查詢:

  • 節點埠方式 – kubectl get -o jsonpath="{.spec.ports[0].nodePort}" services mysql
  • 負載均衡方式 – kubectl get -o jsonpath="{.spec.ports[0].LoadBalancer}" services mysql

模板文件

類似 Docker Compose、TerraForm 或其它的服務管理工具,Kubernetes 也提供了基礎設施描述模板。這意味著,你幾乎不用手動操作。

以 Nginx 部署為例,查看下面的 yaml 模板:

apiVersion: apps/v1
kind: Deployment #(1)
metadata: #(2)
  name: nginx-deployment
  labels: #(3)
    app: nginx
spec: #(4)
  replicas: 3 #(5)
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers: #(6)
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

在這個示例部署中,我們做了如下操作:

  • (1) 使用 kind 關鍵字定義模板類型
  • (2) 使用 metadata 關鍵字,增加該部署的識別信息
  • (3) 使用 labels 標記每個需要創建的資源
  • (4) 然後使用 spec 關鍵字描述所需的狀態
  • (5) nginx 應用需要 3 個副本
  • (6) Pod 中容器的模板定義部分
  • 容器名稱為 nginx
  • 容器模板為 nginx:1.7.9 (本例使用 Docker 鏡像)

副本組

副本組 ReplicaSet 是一個底層的副本管理器,用於保證運行正確數目的應用副本。相比而言,部署是更高層級的操作,應該用於管理副本組。除非你遇到特殊的情況,需要控制副本的特性,否則你幾乎不需要直接操作副本組。

守護進程組

上面提到 Kubernetes 始終使用標籤,還有印象嗎? 守護進程組 DaemonSet 是一個控制器,用於確保守護進程化的應用一直運行在具有特定標籤的節點中。

例如,你將所有節點增加 loggermission_critical 的標籤,以便運行日誌 / 審計服務的守護進程。接著,你創建一個守護進程組並使用 loggermission_critical 節點選擇器。Kubernetes 會查找具有該標籤的節點,確保守護進程的實例一直運行在這些節點中。因而,節點中運行的所有進程都可以在節點內訪問對應的守護進程。

以我的應用為例,NSQ 守護進程可以用守護進程組實現。具體而言,將對應節點增加 recevier 標籤,創建一個守護進程組並配置 receiver 應用選擇器,這樣這些節點上就會一直運行接收者組件。

守護進程組具有副本組的全部優勢,可擴展且由 Kubernetes 管理,意味著 Kubernetes 管理其全生命周期的事件,確保持續運行,即使出現故障,也會立即替換。

擴展

在 Kubernetes 中,擴展是稀鬆平常的事情。副本組負責 Pod 運行的實例數目。就像你在 nginx 部署那個示例中看到的那樣,對應設置項 replicas:3。我們可以按應用所需,讓 Kubernetes 運行多份應用副本。

當然,設置項有很多。你可以指定讓多個副本運行在不同的節點上,也可以指定各種不同的應用啟動等待時間。想要在這方面了解更多,可以閱讀 水平擴展Kubernetes 中的互動式擴展;當然 副本組 的細節對你也有幫助,畢竟 Kubernetes 中的擴展功能都來自於該模塊。

Kubernetes 部分小結

Kubernetes 是容器編排的便捷工具,工作單元為 Pod,具有分層架構。最頂層是部署,用於操作其它資源,具有高度可配置性。對於你的每個命令調用,Kubernetes 提供了對應的 API,故理論上你可以編寫自己的代碼,向 Kubernetes API 發送數據,得到與 kubectl 命令同樣的效果。

截至目前,Kubernetes 原生支持所有主流雲服務供應商,而且完全開源。如果你願意,可以貢獻代碼;如果你希望對工作原理有深入了解,可以查閱代碼:GitHub 上的 Kubernetes 項目

Minikube

接下來我會使用 Minikube 這款本地 Kubernetes 集群模擬器。它並不擅長模擬多節點集群,但可以很容易地給你提供本地學習環境,讓你開始探索,這很棒。Minikube 基於可高度調優的虛擬機,由 VirtualBox 類似的虛擬化工具提供。

我用到的全部 Kubernetes 模板文件可以在這裡找到:Kubernetes 文件

注意:在你後續測試可擴展性時,會發現副本一直處於 Pending 狀態,這是因為 minikube 集群中只有一個節點,不應該允許多副本運行在同一個節點上,否則明顯只是耗盡了可用資源。使用如下命令可以查看可用資源:

kubectl get nodes -o yaml

構建容器

Kubernetes 支持大多數現有的容器技術。我這裡使用 Docker。每一個構建的服務容器,對應代碼庫中的一個 Dockerfile 文件。我推薦你仔細閱讀它們,其中大多數都比較簡單。對於 Go 服務,我採用了最近引入的多步構建的方式。Go 服務基於 Alpine Linux 鏡像創建。人臉識別程序使用 Python、NSQ 和 MySQL 使用對應的容器。

上下文

Kubernetes 使用命名空間。如果你不額外指定命名空間,Kubernetes 會使用 default 命名空間。為避免污染默認命名空間,我會一直指定命名空間,具體操作如下:

❯ kubectl config set-context kube-face-cluster --namespace=face
Context "kube-face-cluster" created.

創建上下文之後,應馬上啟用:

❯ kubectl config use-context kube-face-cluster
Switched to context "kube-face-cluster".

此後,所有 kubectl 命令都會使用 face 命名空間。

(LCTT 譯註:作者後續並沒有使用 face 命名空間,模板文件中的命名空間仍為 default,可能 face 命名空間用於開發環境。如果希望使用 face 命令空間,需要將內部 DNS 地址中的 default 改成 face;如果只是測試,可以不執行這兩條命令。)

應用部署

Pods 和 服務概覽:

MySQL

第一個要部署的服務是資料庫。

按照 Kubernetes 的示例 Kubenetes MySQL 進行部署,即可以滿足我的需求。注意:示例配置文件的 MYSQL_PASSWORD 欄位使用了明文密碼,我將使用 Kubernetes Secrets 對象以提高安全性。

我創建了一個 Secret 對象,對應的本地 yaml 文件如下:

apiVersion: v1
kind: Secret
metadata:
  name: kube-face-secret
type: Opaque
data:
  mysql_password: base64codehere
  mysql_userpassword: base64codehere

其中 base64 編碼通過如下命令生成:

echo -n "ubersecurepassword" | base64
echo -n "root:ubersecurepassword" | base64

(LCTT 譯註:secret yaml 文件中的 data 應該有兩條,一條對應 mysql_password,僅包含密碼;另一條對應 mysql_userpassword,包含用戶和密碼。後文會用到 mysql_userpassword,但沒有提及相應的生成)

我的部署 yaml 對應部分如下:

...
- name: MYSQL_ROOT_PASSWORD
  valueFrom:
    secretKeyRef:
      name: kube-face-secret
      key: mysql_password
...

另外值得一提的是,我使用卷將資料庫持久化,卷對應的定義如下:

...
        volumeMounts:
        - name: mysql-persistent-storage
          mountPath: /var/lib/mysql
...
      volumes:
      - name: mysql-persistent-storage
        persistentVolumeClaim:
          claimName: mysql-pv-claim
...

其中 presistentVolumeClain 是關鍵,告知 Kubernetes 當前資源需要持久化存儲。持久化存儲的提供方式對用戶透明。類似 Pods,如果想了解更多細節,參考文檔:Kubernetes 持久化存儲

(LCTT 譯註:使用 presistentVolumeClain 之前需要創建 presistentVolume,對於單節點可以使用本地存儲,對於多節點需要使用共享存儲,因為 Pod 可以能調度到任何一個節點)

使用如下命令部署 MySQL 服務:

kubectl apply -f mysql.yaml

這裡比較一下 createapplyapply 是一種 宣告式 declarative 的對象配置命令,而 create 命令式 imperative 的命令。當下我們需要知道的是, create 通常對應一項任務,例如運行某個組件或創建一個部署;相比而言,當我們使用 apply 的時候,用戶並沒有指定具體操作,Kubernetes 會根據集群目前的狀態定義需要執行的操作。故如果不存在名為 mysql 的服務,當我執行 apply -f mysql.yaml 時,Kubernetes 會創建該服務。如果再次執行這個命令,Kubernetes 會忽略該命令。但如果我再次運行 create ,Kubernetes 會報錯,告知服務已經創建。

想了解更多信息,請閱讀如下文檔:Kubernetes 對象管理命令式配置宣告式配置

運行如下命令查看執行進度信息:

# 描述完整信息
kubectl describe deployment mysql
# 僅描述 Pods 信息
kubectl get pods -l app=mysql

(第一個命令)輸出示例如下:

...
  Type           Status  Reason
  ----           ------  ---  Available      True    MinimumReplicasAvailable
  Progressing    True    NewReplicaSetAvailable
OldReplicaSets:  <none>
NewReplicaSet:   mysql-55cd6b9f47 (1/1 replicas created)
...

對於 get pods 命令,輸出示例如下:

NAME                     READY     STATUS    RESTARTS   AGE
mysql-78dbbd9c49-k6sdv   1/1       Running   0          18s

可以使用下面的命令測試資料庫實例:

kubectl run -it --rm --image=mysql:5.6 --restart=Never mysql-client -- mysql -h mysql -pyourpasswordhere

特別提醒:如果你在這裡修改了密碼,重新 apply 你的 yaml 文件並不能更新容器。因為資料庫是持久化的,密碼並不會改變。你需要先使用 kubectl delete -f mysql.yaml 命令刪除整個部署。

運行 show databases 後,應該可以看到如下信息:

If you don&apos;t see a command prompt, try pressing enter.

mysql>
mysql>
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| kube               |
| mysql              |
| performance_schema |
+--------------------+
4 rows in set (0.00 sec)

mysql> exit
Bye

你會注意到,我還將一個資料庫初始化 SQL 文件掛載到容器中,MySQL 容器會自動運行該文件,導入我將用到的部分數據和模式。

對應的卷定義如下:

  volumeMounts:
  - name: mysql-persistent-storage
    mountPath: /var/lib/mysql
  - name: bootstrap-script
    mountPath: /docker-entrypoint-initdb.d/database_setup.sql
volumes:
- name: mysql-persistent-storage
  persistentVolumeClaim:
    claimName: mysql-pv-claim
- name: bootstrap-script
  hostPath:
    path: /Users/hannibal/golang/src/github.com/Skarlso/kube-cluster-sample/database_setup.sql
    type: File

(LCTT 譯註:資料庫初始化腳本需要改成對應的路徑,如果是多節點,需要是共享存儲中的路徑。另外,作者給的 sql 文件似乎有誤,person_images 表中的 person_id 列數字都小 1,作者默認 id 從 0 開始,但應該是從 1 開始)

運行如下命令查看引導腳本是否正確執行:

~/golang/src/github.com/Skarlso/kube-cluster-sample/kube_files master*
❯ kubectl run -it --rm --image=mysql:5.6 --restart=Never mysql-client -- mysql -h mysql -uroot -pyourpasswordhere kube
If you don&apos;t see a command prompt, try pressing enter.

mysql> show tables;
+----------------+
| Tables_in_kube |
+----------------+
| images         |
| person         |
| person_images  |
+----------------+
3 rows in set (0.00 sec)

mysql>

(LCTT 譯註:上述代碼塊中的第一行是作者執行命令所在路徑,執行第二行的命令無需在該目錄中進行)

上述操作完成了資料庫服務的初始化。使用如下命令可以查看服務日誌:

kubectl logs deployment/mysql -f

NSQ 查詢

NSQ 查詢將以內部服務的形式運行。由於不需要外部訪問,這裡使用 clusterIP: None 在 Kubernetes 中將其設置為 無頭服務 headless service ,意味著該服務不使用負載均衡模式,也不使用單獨的服務 IP。DNS 將基於服務 選擇器 selectors

我們的 NSQ 查詢服務對應的選擇器為:

  selector:
    matchLabels:
      app: nsqlookup

那麼,內部 DNS 對應的實體類似於:nsqlookup.default.svc.cluster.local

無頭服務的更多細節,可以參考:無頭服務

NSQ 服務與 MySQL 服務大同小異,只需要少許修改即可。如前所述,我將使用 NSQ 原生的 Docker 鏡像,名稱為 nsqio/nsq。鏡像包含了全部的 nsq 命令,故 nsqd 也將使用該鏡像,只是使用的命令不同。對於 nsqlookupd,命令如下:

command: ["/nsqlookupd"]
args: ["--broadcast-address=nsqlookup.default.svc.cluster.local"]

你可能會疑惑,--broadcast-address 參數是做什麼用的?默認情況下,nsqlookup 使用容器的主機名作為廣播地址;這意味著,當用戶運行回調時,回調試圖訪問的地址類似於 http://nsqlookup-234kf-asdf:4161/lookup?topics=image,但這顯然不是我們期望的。將廣播地址設置為內部 DNS 後,回調地址將是 http://nsqlookup.default.svc.cluster.local:4161/lookup?topic=images,這正是我們期望的。

NSQ 查詢還需要轉發兩個埠,一個用於廣播,另一個用於 nsqd 守護進程的回調。在 Dockerfile 中暴露相應埠,在 Kubernetes 模板中使用它們,類似如下:

容器模板:

        ports:
        - containerPort: 4160
          hostPort: 4160
        - containerPort: 4161
          hostPort: 4161

服務模板:

spec:
  ports:
  - name: main
    protocol: TCP
    port: 4160
    targetPort: 4160
  - name: secondary
    protocol: TCP
    port: 4161
    targetPort: 4161

埠名稱是必須的,Kubernetes 基於名稱進行區分。(LCTT 譯註:埠名更新為作者 GitHub 對應文件中的名稱)

像之前那樣,使用如下命令創建服務:

kubectl apply -f nsqlookup.yaml

nsqlookupd 部分到此結束。截至目前,我們已經準備好兩個主要的組件。

接收器

這部分略微複雜。接收器需要完成三項工作:

  • 創建一些部署
  • 創建 nsq 守護進程
  • 將本服務對外公開

部署

第一個要創建的部署是接收器本身,容器鏡像為 skarlso/kube-receiver-alpine

NSQ 守護進程

接收器需要使用 NSQ 守護進程。如前所述,接收器在其內部運行一個 NSQ,這樣與 nsq 的通信可以在本地進行,無需通過網路。為了讓接收器可以這樣操作,NSQ 需要與接收器部署在同一個節點上。

NSQ 守護進程也需要一些調整的參數配置:

        ports:
        - containerPort: 4150
          hostPort: 4150
        - containerPort: 4151
          hostPort: 4151
        env:
        - name: NSQLOOKUP_ADDRESS
          value: nsqlookup.default.svc.cluster.local
        - name: NSQ_BROADCAST_ADDRESS
          value: nsqd.default.svc.cluster.local
        command: ["/nsqd"]
        args: ["--lookupd-tcp-address=$(NSQLOOKUP_ADDRESS):4160", "--broadcast-address=$(NSQ_BROADCAST_ADDRESS)"]

其中我們配置了 lookup-tcp-addressbroadcast-address 參數。前者是 nslookup 服務的 DNS 地址,後者用於回調,就像 nsqlookupd 配置中那樣。

對外公開

下面即將創建第一個對外公開的服務。有兩種方式可供選擇。考慮到該 API 負載較高,可以使用負載均衡的方式。另外,如果希望將其部署到生產環境中的任選節點,也應該使用負載均衡方式。

但由於我使用的本地集群只有一個節點,那麼使用 NodePort 的方式就足夠了。NodePort 方式將服務暴露在對應節點的固定埠上。如果未指定埠,將從 30000-32767 數字範圍內隨機選其一個。也可以指定埠,可以在模板文件中使用 nodePort 設置即可。可以通過 <NodeIP>:<NodePort> 訪問該服務。如果使用多個節點,負載均衡可以將多個 IP 合併為一個 IP。

更多信息,請參考文檔:服務發布

結合上面的信息,我們定義了接收器服務,對應的模板如下:

apiVersion: v1
kind: Service
metadata:
  name: receiver-service
spec:
  ports:
  - protocol: TCP
    port: 8000
    targetPort: 8000
  selector:
    app: receiver
  type: NodePort

如果希望固定使用 8000 埠,需要增加 nodePort 配置,具體如下:

apiVersion: v1
kind: Service
metadata:
  name: receiver-service
spec:
  ports:
  - protocol: TCP
    port: 8000
    targetPort: 8000
  selector:
    app: receiver
  type: NodePort
  nodePort: 8000

(LCTT 譯註:雖然作者沒有寫,但我們應該知道需要運行的部署命令 kubectl apply -f receiver.yaml。)

圖片處理器

圖片處理器用於將圖片傳送至識別組件。它需要訪問 nslookupd、 mysql 以及後續部署的人臉識別服務的 gRPC 介面。事實上,這是一個無聊的服務,甚至其實並不是服務(LCTT 譯註:第一個服務是指在整個架構中,圖片處理器作為一個服務;第二個服務是指 Kubernetes 服務)。它並需要對外暴露埠,這是第一個只包含部署的組件。長話短說,下面是完整的模板:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: image-processor-deployment
spec:
  selector:
    matchLabels:
      app: image-processor
  replicas: 1
  template:
    metadata:
      labels:
        app: image-processor
    spec:
      containers:
      - name: image-processor
        image: skarlso/kube-processor-alpine:latest
        env:
        - name: MYSQL_CONNECTION
          value: "mysql.default.svc.cluster.local"
        - name: MYSQL_USERPASSWORD
          valueFrom:
            secretKeyRef:
              name: kube-face-secret
              key: mysql_userpassword
        - name: MYSQL_PORT
          # TIL: If this is 3306 without " kubectl throws an error.
          value: "3306"
        - name: MYSQL_DBNAME
          value: kube
        - name: NSQ_LOOKUP_ADDRESS
          value: "nsqlookup.default.svc.cluster.local:4161"
        - name: GRPC_ADDRESS
          value: "face-recog.default.svc.cluster.local:50051"

文件中唯一需要提到的是用於配置應用的多個環境變數屬性,主要關注 nsqlookupd 地址 和 gRPC 地址。

運行如下命令完成部署:

kubectl apply -f image_processor.yaml

人臉識別

人臉識別服務的確包含一個 Kubernetes 服務,具體而言是一個比較簡單、僅供圖片處理器使用的服務。模板如下:

apiVersion: v1
kind: Service
metadata:
  name: face-recog
spec:
  ports:
  - protocol: TCP
    port: 50051
    targetPort: 50051
  selector:
    app: face-recog
  clusterIP: None

更有趣的是,該服務涉及兩個卷,分別為 known_peopleunknown_people。你能猜到卷中包含什麼內容嗎?對,是圖片。known_people 卷包含所有新圖片,接收器收到圖片後將圖片發送至該卷對應的路徑,即掛載點。在本例中,掛載點為 /unknown_people,人臉識別服務需要能夠訪問該路徑。

對於 Kubernetes 和 Docker 而言,這很容易。卷可以使用掛載的 S3 或 某種 nfs,也可以是宿主機到虛擬機的本地掛載。可選方式有很多 (至少有一打那麼多)。為簡潔起見,我將使用本地掛載方式。

掛載卷分為兩步。第一步,需要在 Dockerfile 中指定卷:

VOLUME [ "/unknown_people", "/known_people" ]

第二步,就像之前為 MySQL Pod 掛載卷那樣,需要在 Kubernetes 模板中配置;相比而言,這裡使用 hostPath,而不是 MySQL 例子中的 PersistentVolumeClaim

        volumeMounts:
        - name: known-people-storage
          mountPath: /known_people
        - name: unknown-people-storage
          mountPath: /unknown_people
      volumes:
      - name: known-people-storage
        hostPath:
          path: /Users/hannibal/Temp/known_people
          type: Directory
      - name: unknown-people-storage
        hostPath:
          path: /Users/hannibal/Temp/
          type: Directory

(LCTT 譯註:對於多節點模式,由於人臉識別服務和接收器服務可能不在一個節點上,故需要使用共享存儲而不是節點本地存儲。另外,出於 Python 代碼的邏輯,推薦保持兩個文件夾的嵌套結構,即 known_people 作為子目錄。)

我們還需要為 known_people 文件夾做配置設置,用於人臉識別程序。當然,使用環境變數屬性可以完成該設置:

        env:
        - name: KNOWN_PEOPLE
          value: "/known_people"

Python 代碼按如下方式搜索圖片:

        known_people = os.getenv(&apos;KNOWN_PEOPLE&apos;, &apos;known_people&apos;)
        print("Known people images location is: %s" % known_people)
        images = self.image_files_in_folder(known_people)

其中 image_files_in_folder 函數定義如下:

    def image_files_in_folder(self, folder):
        return [os.path.join(folder, f) for f in os.listdir(folder) if re.match(r&apos;.*.(jpg|jpeg|png)&apos;, f, flags=re.I)]

看起來不錯。

如果接收器現在收到一個類似下面的請求(接收器會後續將其發送出去):

curl -d &apos;{"path":"/unknown_people/unknown220.jpg"}&apos; http://192.168.99.100:30251/image/post

圖像處理器會在 /unknown_people 目錄搜索名為 unknown220.jpg 的圖片,接著在 known_folder 文件中找到 unknown220.jpg 對應個人的圖片,最後返回匹配圖片的名稱。

查看日誌,大致信息如下:

# 接收器
❯ curl -d &apos;{"path":"/unknown_people/unknown219.jpg"}&apos; http://192.168.99.100:30251/image/post
got path: {Path:/unknown_people/unknown219.jpg}
image saved with id: 4
image sent to nsq

# 圖片處理器
2018/03/26 18:11:21 INF    1 [images/ch] querying nsqlookupd http://nsqlookup.default.svc.cluster.local:4161/lookup?topic=images
2018/03/26 18:11:59 Got a message: 4
2018/03/26 18:11:59 Processing image id:  4
2018/03/26 18:12:00 got person:  Hannibal
2018/03/26 18:12:00 updating record with person id
2018/03/26 18:12:00 done

我們已經使用 Kubernetes 部署了應用正常工作所需的全部服務。

前端

更進一步,可以使用簡易的 Web 應用更好的顯示資料庫中的信息。這也是一個對外公開的服務,使用的參數可以參考接收器。

部署後效果如下:

回顧

到目前為止我們做了哪些操作呢?我一直在部署服務,用到的命令匯總如下:

kubectl apply -f mysql.yaml
kubectl apply -f nsqlookup.yaml
kubectl apply -f receiver.yaml
kubectl apply -f image_processor.yaml
kubectl apply -f face_recognition.yaml
kubectl apply -f frontend.yaml

命令順序可以打亂,因為除了圖片處理器的 NSQ 消費者外的應用在啟動時並不會建立連接,而且圖片處理器的 NSQ 消費者會不斷重試。

使用 kubectl get pods 查詢正在運行的 Pods,示例如下:

❯ kubectl get pods
NAME                                          READY     STATUS    RESTARTS   AGE
face-recog-6bf449c6f-qg5tr                    1/1       Running   0          1m
image-processor-deployment-6467468c9d-cvx6m   1/1       Running   0          31s
mysql-7d667c75f4-bwghw                        1/1       Running   0          36s
nsqd-584954c44c-299dz                         1/1       Running   0          26s
nsqlookup-7f5bdfcb87-jkdl7                    1/1       Running   0          11s
receiver-deployment-5cb4797598-sf5ds          1/1       Running   0          26s

運行 minikube service list

❯ minikube service list
|-------------|----------------------|-----------------------------|
|  NAMESPACE  |         NAME         |             URL             |
|-------------|----------------------|-----------------------------|
| default     | face-recog           | No node port                |
| default     | kubernetes           | No node port                |
| default     | mysql                | No node port                |
| default     | nsqd                 | No node port                |
| default     | nsqlookup            | No node port                |
| default     | receiver-service     | http://192.168.99.100:30251 |
| kube-system | kube-dns             | No node port                |
| kube-system | kubernetes-dashboard | http://192.168.99.100:30000 |
|-------------|----------------------|-----------------------------|

滾動更新

滾動更新 Rolling Update 過程中會發生什麼呢?

在軟體開發過程中,需要變更應用的部分組件是常有的事情。如果我希望在不影響其它組件的情況下變更一個組件,我們的集群會發生什麼變化呢?我們還需要最大程度的保持向後兼容性,以免影響用戶體驗。謝天謝地,Kubernetes 可以幫我們做到這些。

目前的 API 一次只能處理一個圖片,不能批量處理,對此我並不滿意。

代碼

目前,我們使用下面的代碼段處理單個圖片的情形:

// PostImage 對圖片提交做出響應,將圖片信息保存到資料庫中
// 並將該信息發送給 NSQ 以供後續處理使用
func PostImage(w http.ResponseWriter, r *http.Request) {
...
}

func main() {
    router := mux.NewRouter()
    router.HandleFunc("/image/post", PostImage).Methods("POST")
    log.Fatal(http.ListenAndServe(":8000", router))
}

我們有兩種選擇。一種是增加新介面 /images/post 給用戶使用;另一種是在原介面基礎上修改。

新版客戶端有回退特性,在新介面不可用時回退使用舊介面。但舊版客戶端沒有這個特性,故我們不能馬上修改代碼邏輯。考慮如下場景,你有 90 台伺服器,計劃慢慢執行滾動更新,依次對各台伺服器進行業務更新。如果一台服務需要大約 1 分鐘更新業務,那麼整體更新完成需要大約 1 個半小時的時間(不考慮並行更新的情形)。

更新過程中,一些伺服器運行新代碼,一些伺服器運行舊代碼。用戶請求被負載均衡到各個節點,你無法控制請求到達哪台伺服器。如果客戶端的新介面請求被調度到運行舊代碼的伺服器,請求會失敗;客戶端可能會回退使用舊介面,(但由於我們已經修改舊介面,本質上仍然是調用新介面),故除非請求剛好到達到運行新代碼的伺服器,否則一直都會失敗。這裡我們假設不使用 粘性會話 sticky sessions

而且,一旦所有伺服器更新完畢,舊版客戶端不再能夠使用你的服務。

這裡,你可能會說你並不需要保留舊代碼;某些情況下,確實如此。因此,我們打算直接修改舊代碼,讓其通過少量參數調用新代碼。這樣操作操作相當於移除了舊代碼。當所有客戶端遷移完畢後,這部分代碼也可以安全地刪除。

新的介面

讓我們添加新的路由方法:

...
router.HandleFunc("/images/post", PostImages).Methods("POST")
...

更新舊的路由方法,使其調用新的路由方法,修改部分如下:

// PostImage 對圖片提交做出響應,將圖片信息保存到資料庫中
// 並將該信息發送給 NSQ 以供後續處理使用
func PostImage(w http.ResponseWriter, r *http.Request) {
    var p Path
    err := json.NewDecoder(r.Body).Decode(&p)
    if err != nil {
      fmt.Fprintf(w, "got error while decoding body: %s", err)
      return
    }
    fmt.Fprintf(w, "got path: %+vn", p)
    var ps Paths
    paths := make([]Path, 0)
    paths = append(paths, p)
    ps.Paths = paths
    var pathsJSON bytes.Buffer
    err = json.NewEncoder(&pathsJSON).Encode(ps)
    if err != nil {
      fmt.Fprintf(w, "failed to encode paths: %s", err)
      return
    }
    r.Body = ioutil.NopCloser(&pathsJSON)
    r.ContentLength = int64(pathsJSON.Len())
    PostImages(w, r)
}

當然,方法名可能容易混淆,但你應該能夠理解我想表達的意思。我將請求中的單個路徑封裝成新方法所需格式,然後將其作為請求發送給新介面處理。僅此而已。在 滾動更新批量圖片的 PR 中可以找到更多的修改方式。

至此,我們使用兩種方法調用接收器:

# 單路徑模式
curl -d &apos;{"path":"unknown4456.jpg"}&apos; http://127.0.0.1:8000/image/post

# 多路徑模式
curl -d &apos;{"paths":[{"path":"unknown4456.jpg"}]}&apos; http://127.0.0.1:8000/images/post

這裡用到的客戶端是 curl。一般而言,如果客戶端本身是一個服務,我會做一些修改,在新介面返回 404 時繼續嘗試舊介面。

為了簡潔,我不打算為 NSQ 和其它組件增加批量圖片處理的能力。這些組件仍然是一次處理一個圖片。這部分修改將留給你作為擴展內容。 🙂

新鏡像

為實現滾動更新,我首先需要為接收器服務創建一個新的鏡像。新鏡像使用新標籤,告訴大家版本號為 v1.1。

docker build -t skarlso/kube-receiver-alpine:v1.1 .

新鏡像創建後,我們可以開始滾動更新了。

滾動更新

在 Kubernetes 中,可以使用多種方式完成滾動更新。

手動更新

不妨假設在我配置文件中使用的容器版本為 v1.0,那麼實現滾動更新只需運行如下命令:

kubectl rolling-update receiver --image:skarlso/kube-receiver-alpine:v1.1

如果滾動更新過程中出現問題,我們總是可以回滾:

kubectl rolling-update receiver --rollback

容器將回滾到使用上一個版本鏡像,操作簡捷無煩惱。

應用新的配置文件

手動更新的不足在於無法版本管理。

試想下面的場景。你使用手工更新的方式對若干個伺服器進行滾動升級,但其它人並不知道這件事。之後,另外一個人修改了模板文件並將其應用到集群中,更新了全部伺服器;更新過程中,突然發現服務不可用了。

長話短說,由於模板無法識別已經手動更新的伺服器,這些伺服器會按模板變更成錯誤的狀態。這種做法很危險,千萬不要這樣做。

推薦的做法是,使用新版本信息更新模板文件,然後使用 apply 命令應用模板文件。

對於滾動擴展,Kubernetes 推薦通過部署結合副本組完成。但這意味著待滾動更新的應用至少有 2 個副本,否則無法完成 (除非將 maxUnavailable 設置為 1)。我在模板文件中增加了副本數量、設置了接收器容器的新鏡像版本。

  replicas: 2
...
    spec:
      containers:
      - name: receiver
        image: skarlso/kube-receiver-alpine:v1.1
...

更新過程中,你會看到如下信息:

❯ kubectl rollout status deployment/receiver-deployment
Waiting for rollout to finish: 1 out of 2 new replicas have been updated...

通過在模板中增加 strategy 段,你可以增加更多的滾動擴展配置:

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

關於滾動更新的更多信息,可以參考如下文檔:部署的滾動更新部署的更新部署的管理使用副本控制器完成滾動更新等。

MINIKUBE 用戶需要注意:由於我們使用單個主機上使用單節點配置,應用只有 1 份副本,故需要將 maxUnavailable 設置為 1。否則 Kubernetes 會阻止更新,新版本會一直處於 Pending 狀態;這是因為我們在任何時刻都不允許出現沒有(正在運行的) receiver 容器的場景。

擴展

Kubernetes 讓擴展成為相當容易的事情。由於 Kubernetes 管理整個集群,你僅需在模板文件中添加你需要的副本數目即可。

這篇文章已經比較全面了,但文章的長度也越來越長。我計劃再寫一篇後續文章,在 AWS 上使用多節點、多副本方式實現擴展。敬請期待。

清理環境

kubectl delete deployments --all
kubectl delete services -all

寫在最後的話

各位看官,本文就寫到這裡了。我們在 Kubernetes 上編寫、部署、更新和擴展(老實說,並沒有實現)了一個分散式應用。

如果你有任何疑惑,請在下面的評論區留言交流,我很樂意回答相關問題。

希望閱讀本文讓你感到愉快。我知道,這是一篇相對長的文章,我也曾經考慮進行拆分;但整合在一起的單頁教程也有其好處,例如利於搜索、保存頁面或更進一步將頁面列印為 PDF 文檔。

Gergely 感謝你閱讀本文。

via: https://skarlso.github.io/2018/03/15/kubernetes-distributed-application/

作者:hannibal 譯者:pinewall 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國