kubernetes學習筆記 (三):阿里雲遊戲業務實戰

段鵬舉發表於2018-10-17

本人一直做業務開發,不曾瞭解過運維知識,因為要對一個專案的技術部分負責,開發業務的同時還需要思考系統層面的事情,團隊人數又少,不得不採用k8s這種能達到事半功倍效果的工具。本文是在阿里雲kubernetes部署遊戲業務的實戰筆記,不涉及k8s原理等深層知識。我學習k8s的時間也比較短,如有理解錯誤的地方,還望海涵。

目標

我們遊戲按照業務邏輯劃分,伺服器可分為三種型別,前端伺服器(客戶端直接進行連線的)、後端伺服器(只負責處理各種遊戲邏輯不提供連線)、任務伺服器(各種cron、job任務),其中前端伺服器按照功能劃分為http短連線伺服器和socket長連線伺服器,後端伺服器按照業務劃分 例如matching匹配伺服器。

在部署這些伺服器的同時,我需要使用kubernetes達到的目標有:

  • 對於每種型別的伺服器,需要同時存在若干個版本
  • 對於無狀態伺服器如http、cron可以比較方便的更新、回滾
  • 對於有狀態伺服器如socket、matching可以業務無間斷的進行更新、回滾,使用者不會掉線、無感知
  • 可以進行灰度釋出
  • 當伺服器的負載變化時,能夠自動伸縮伺服器數量
  • 當伺服器異常當機時,能夠自我修復

準備Docker映象

  1. 使用阿里雲容器映象服務準備好docker遠端倉庫
  2. 在應用(伺服器程式碼)準備好之後,使用Docker構建映象,並打上版本號,Push到遠端倉庫(這一步驟可以通過Jekins自動完成,後續實踐的時候會更新文件,目前就以手工進行)

部署應用

類似於web前端框架中的命令式(jquery)與宣告式(react),我對k8s有一種理解與之類似:我們只需要通過配置檔案“告訴”k8s我們想要的最終結果就行,中間的過程無須再關心,k8s會以各種機制保證這一結果

  1. 因為使用的Docker遠端倉庫是私有倉庫,部署應用時就需要新增imagePullSecrets,首先使用kubectl在default名稱空間裡建立secret,如需指定名稱空間新增 -n引數,後面命令類似
kubectl create secret docker-registry yourSecretName --docker-server=xxx.cn-hangzhou.aliyuncs.com --docker-username=xxx@aliyun.com --docker-password=xxxxxx --docker-email=xxx@aliyun.com
複製程式碼
  1. 根據伺服器的特點建立部署yaml檔案
  • http 無狀態應用
apiVersion: extensions/v1beta1 # kubectl api的版本
kind: Deployment # kubernetes的資源型別 對於無狀態應用 Deployment即可
metadata:
    name: http-prod-1.0.0000 # 部署的名稱 不能重複 因為我需要多個版本共存因此使用 名稱-環境-版本號的命名方式
spec:
    strategy:
        rollingUpdate: # 滾動更新策略
            maxSurge: 10% # 數值越大 滾動更新時新建立的副本數量越多
            maxUnavailble: 10% # 數值越大 滾動更新時銷燬的舊副本數量越多
    replicas: 3 # 期待執行的Pod副本數量
    template:
        metadata:
            labels: # 自定義標籤
                serverType: http
                env: production
                version: 1.0.0000
        spec:
            containers:
                - name: httpapp
                  image: yourDockerRegistry:1.0.0000
                  readinessProbe: # 一種健康檢查決定是否加入到service 對外服務 當介面返回200-400之外的狀態碼時,k8s會認為這個pod已經不可用,會從Service中移除
                      httpGet:
                          scheme: HTTP # 支援http https
                          path: /
                          port: 81
                      initialDelaySeconds: 10 # 容器啟動多久後開始檢查
                      periodSecods: 5 # 幾秒檢查一次
                  env: # 映象啟動時的環境變數
                      - name: DEBUG
                        value: 'ccgame:*'
                      - name: NODE_ENV
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['env'] # 從labels中讀取env
                      - name: HTTP_PORT
                        value: '80'
                      - name: SERVER_PORT
                        value: '80'
                      - name: HEALTHY_CHECK_PORT
                        value: '81'
                      - name: SERVER_TYPE
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['serverType'] # 從labels中讀取SERVER_TYPE
                      - name: NATS_ADDRESS
                        value: 'nats://xxx:xxx@nats:4222' # 使用的訊息佇列叢集地址
                      - name: VERSION
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['version'] # 從labels中讀取version

            imagePullSecrets:
                - name: regsecret
複製程式碼

建立對應的service

apiVersion: v1 # kubectl api的版本
kind: Service # kubernetes的資源型別 這裡是Service
metadata:
    name: http-prod-v100000 # 服務的名稱 不能重複 不能有. 因為我需要多個版本共存因此使用 名稱-環境-版本號並去掉.的方式命名
spec:
    type: ClusterIP # service的型別 ClusterIp型別 只有Cluster內部節點和Pod可以訪問 NodePort Cluster外部可以通過<NodeIp>:<NodePort>訪問 LoadBalancer負載均衡
    selector: # 匹配pod的標籤與上文Deployment中的labels一致
        serverType: http
        env: production
        version: 1.0.0000
    ports:
        - protocol: TCP # 只有TCP 或 UDP
          port: 80 # 服務 監聽的埠
          targetPort: 80 # Pod 監聽的埠 對應上面的Deployment中的HTTP_PORT
複製程式碼

建立對應的ingress(路由)對外提供服務

apiVersion: extensions/v1beta1 # kubectl api的版本
kind: Ingress # kubernetes的資源型別 這裡是Ingress
metadata:
  name: https # 路由的名稱
spec:
  rules:
    - host: xx.xxxx.com # 域名
      http:
        paths:
          - backend:
              serviceName: http-prod-v100000 # 轉發的服務名
              servicePort: 80 # 轉發到服務的哪個埠 對應上文的service port
            path: / # 匹配路徑
  tls: # 開啟tls
    - hosts:
        - xx.xxxx.com
      secretName: yourSecretName # 證書 可通過 kubectl create secret generic yourSecretName --from-file=tls.crt --from-file=tls.key -n kube-system建立
status:
  loadBalancer:
    ingress:
      - ip: x.x.x.x # 負載均衡的ip下文會講
複製程式碼

此時已經可以通過域名進行訪問了,這就是我們想要的“最終狀態”,而具體實現細節以及如何維持這個狀態不變,我們無需再關心

為何不直接使用Service對外提供服務?

其實我們只需要把Service的型別改成LoadBlancer,阿里雲(其他雲服務商類似)會給Service新增一個監聽的nodePort,再自動建立一個負載均衡,通過tcp轉發到Service的nodePort上(這地方阿里雲有個bug每次更新Service它都會把轉發型別改成tcp),可想而知,當我們的Service越來越多時,nodePort的管理成本也就越來越高, k8s提供了另外一個資源解決這種問題,就是Ingress

Ingress工作機制

Ingress其實就是從 kuberenets 叢集外部訪問叢集的一個入口,將外部的請求根據配置的規則轉發到叢集內不同的 Service 上,其實就相當於 nginx、haproxy 等負載均衡代理伺服器,我們直接使用Nginx也可以達到一樣的目的,只是nginx這種方式當新增、移除Service時動態重新整理會比較麻煩一點,Ingress相當於都給你做好了,不需要再次實現一遍,Ingress預設使用的Controller就是nginx。

Ingress controller 可以理解為一個監聽器,通過不斷地與 kube-apiserver 打交道,實時的感知後端 service、pod 的變化,當得到這些變化資訊後,Ingress controller 再結合 Ingress 的配置,更新反向代理負載均衡器,達到服務發現的作用。

配置Ingress

可以通過annotations註解的方式告訴Ingress你的配置,例如:如果你使用的是Nginx-Ingress-Controller,可以通過nginx.ingress.kubernetes.io/cors-allow-origin: *來配置cors,和配置Nginx幾乎是一樣的,只是名稱不一樣而已。

所有的Nginx-Ingress-Controller的註解可以在這裡查詢 傳送門

可以進入nginx-Ingress-controller的pod中,新增一些註解,更新,會看到nginx重新生成了配置,並“重新啟動”,對比註解和nginx.conf 很快就能理解Ingress

Ingress灰度釋出

可以通過新增註解nginx.ingress.kubernetes.io/service-match: 'test-svc: header("Version", "1.0.0000")',來進行灰度釋出,比如匹配 request headers中Version=1.0.0000的流量轉發到test-svc,可以匹配header、query、cookie,同時還可以配置權重等,例如修復問題時只把10%的流量切進來,待問題驗證得到解決後再設定100。

我們每次遊戲前端釋出版本都會在header中新增一個Version引數,我設定灰度釋出之後就可以把特定前端版本的流量自由的切到某個特定的服務中,比較靈活。

滾動更新

當不需要灰度釋出時,僅僅需要對某個Service的pod進行更新,只需要更改上文Deployment中映象版本即可,當k8s檢測到template欄位更改時,會根據設定的rollingUpdate strategy策略進行滾動更新,對於http這種無狀態的服務,也能達到業務不間斷更新

  • 長連線 有狀態應用

無狀態: 該服務執行的例項不會在本地儲存需要持久化的資料,並且多個例項對於同一個請求響應的結果是完全一致的

有狀態:和上面的概念是對立的了,該服務執行的例項需要在本地儲存持久化資料,比如socket長連線

apiVersion: apps/v1beta1 # kubectl api的版本
kind: StatefulSet # kubernetes的資源型別 對於有狀態應用選擇StatefulSet
metadata:
    name: connector-prod-v100000 # 部署的名稱 不能重複 因為我需要多個版本共存因此使用 名稱-環境-版本號的命名方式
spec:
    replicas: 3 # 執行的Pod副本數量
    template:
        metadata:
            labels: # 自定義標籤
                serverType: connector
                wsType: socket.io
                env: production
                version: 1.0.0000
        spec:
            containers:
                - name: connectorapp
                  image: yourDockerRegistry:1.0.0000
                  readinessProbe: # 一種健康檢查決定是否加入到service 對外服務
                      httpGet:
                          scheme: HTTP # 支援http https
                          path: /
                          port: 82
                      initialDelaySeconds: 10 # 容器啟動多久後開始檢查
                      periodSecods: 5 # 幾秒檢查一次
                  env: # 映象啟動時的環境變數
                      - name: DEBUG
                        value: 'ccgame:*'
                      - name: NODE_ENV
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['env']
                      - name: WS_PORT
                        value: '80'
                      - name: HEALTHY_CHECK_PORT
                        value: '82'
                      - name: SERVER_TYPE
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['serverType']
                      - name: WS_TYPE
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['wsType']
                      - name: NATS_ADDRESS
                        value: 'nats://xxx:xxx@nats:4222'
                      - name: VERSION
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['version']
# 對於StatefulSet k8s會在metadata.name中自動加上一個序號,從0開始,如connector-prod-v100000-0,connector-prod-v100000-1
                      - name: SERVER_ID
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.name

            imagePullSecrets:
                - name: regsecret
複製程式碼

Service和Ingress與無狀態http應用基本一致,參照上文部署即可。全部部署完成後,觀察k8s後臺可以看到,有name分別為connector-prod-v100000-0、connector-prod-v100000-1、connector-prod-v100000-2的三個pod正在執行,後面的 -n是由於資源型別設定為StatefulSet k8s自動加上的以作區分。

在容器中獲取pod資訊

一般來說對於StatefulSet 我們可能會在容器內知道這個pod的name,這時候就可以採用類似於上面的方法,通過valueFrom fieldPath: metadata.name把pod name資訊注入到容器的環境變數中,這種特殊的語法是Downward API,幫助我們獲取許多pod的資訊,可參照傳送門進行學習

滾動更新

對於StatefulSet 預設的滾動更新策略是OnDelete, 也就是當這個pod被刪除後,k8s再次建立時會更新映象。即使我們改變這個策略,那麼可以直接對齊進行更新嗎?對於大多數StatefulSet是不太合適的(比如pod上面有使用者的長連線 如果直接更新使用者會斷線 影響體驗),或者說對於StatefulSet的滾動更新一直都是個很複雜的話題,所以如果要更新,推薦使用灰度釋出

灰度釋出的過程與上文http一致,對於我們的業務來說,使用者的下一次連線會切到指定的版本上

  • matching 後端有狀態應用

因為後端伺服器不需要外界的訪問,所以建立一個StatefulSet 啟動後端微服務就可以,啟動後會監聽訊息佇列進行處理並返回資料

apiVersion: apps/v1beta1 # kubectl api的版本
kind: StatefulSet # kubernetes的資源型別
metadata:
    name: matching-v100000 # 部署的名稱 不能重複 因為我需要多個版本共存因此使用 名稱-環境-版本號的命名方式
spec:
    replicas: 1 # 執行的Pod副本數量
    template:
        metadata:
            labels:
                serverType: matching
                env: production
                version: 1.0.0000
        spec:
            containers:
                - name: matchingapp
                  image: yourDockerRegistry:1.0.0000
                  readinessProbe: # 一種健康檢查決定是否加入到service 對外服務
                      httpGet:
                          scheme: HTTP # 支援http https
                          path: /
                          port: 80
                      initialDelaySeconds: 10 # 容器啟動多久後開始檢查
                      periodSecods: 5 # 幾秒檢查一次
                  env: # 映象啟動時的環境變數
                      - name: DEBUG
                        value: 'ccgame:*'
                      - name: NODE_ENV
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['env']
                      - name: SERVER_TYPE
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['serverType']
                      - name: HEALTHY_CHECK_PORT
                        value: '80'
                      - name: NATS_ADDRESS
                        value: 'nats://xxx:xxx@nats:4222'
                      - name: SERVER_ID
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.name
                      - name: VERSION
                        valueFrom:
                            fieldRef:
                                fieldPath: metadata.labels['version']
            imagePullSecrets:
                - name: regsecret

複製程式碼

RPC設計

可以看到,我把許多POD資訊注入到了容器中

  • 環境變數SERVER_ID是名稱-環境-版本號-序號命名的可以選擇某一個伺服器;
  • 環境變數SERVER_TYPE可以選擇某一型別的伺服器;
  • 環境變數VERSION可以選擇某一版本的伺服器。

因此就可以通過類似於標籤選擇的方式傳送和接受訊息佇列,例如:

在程式碼中獲取環境變數

    get SERVER_ID() {
        return process.env.SERVER_ID;
    }
    
    get VERSION() {
        return process.env.VERSION || 'latest';
    }
    
    get SERVER_TYPE() {
        return process.env.SERVER_TYPE;
    }
複製程式碼

如果想匹配某一個伺服器:

    @MessagePattern({ serverId: Config.SERVER_ID, handler: 'some_string' })
    async rpcPush(d: PushDto) {
        // some code ...
    }

複製程式碼

如果想匹配某一型別的所有伺服器:

    @MessagePattern({ serverType: Config.SERVER_TYPE, handler: 'boardcast' })
    async boardcast(d: BroadcastDto) {
        // some code ...
    }
複製程式碼

在其他應用內傳送rpc(如在http應用內呼叫matching應用),按照上面的標籤格式傳送訊息即可:

this.rpcService
    .doRPC(
        {
            // rpc messagepattern
            serverId: 'matching-v100000-0',
            handler: 'user_join_matching',
        },
        {
            // some data ...
        },
    )
複製程式碼

長連線客戶端傳送rpc

和前端同學約定,在socket請求中按照event欄位分為4個型別:Push、Notify、Request、Response

Request-Response

客戶端主動發起,要求有迴應,類似於http:

    export class GatewayMessageDto implements GateWayMessage {
    
        // 客戶端傳送的資料
        data: any;
    
        // 路由請求字串 用來根據業務選擇伺服器 發起rpc
        @IsString()
        route: string;
    
        // 路由請求時間戳,在REQUEST下會帶上
        @IsNumber()
        @IsOptional()
        timestamp: number;
    
        // 伺服器版本號
        @IsString()
        @IsOptional()
        version: string;
    }
複製程式碼
    @SubscribeMessage(EventType.REQUEST)
    async onRequest(socket: any, message: GatewayMessageDto) {
    
        // 按照業務邏輯最終生成 訊息佇列的 pattern和 data
        const result = await this.rpcBack(socket, message);
        
        // 返回時間戳和路由字串 客戶端會做 request-response的匹配
        socket.emit(EventType.RESPONSE, {
            route: message.route,
            timestamp: message.timestamp,
            data: result,
        });
    }
複製程式碼

Notify-Push

客戶端主動發起,不要求迴應

    @SubscribeMessage(EventType.NOTIFY)
    async onRequest(socket: any, message: GatewayMessageDto) {
    
        // 按照業務邏輯最終生成 訊息佇列的 pattern和 data
        this.rpcBack(socket, message);
    }
複製程式碼

在伺服器需要回應的地方傳送PUSH事件

   socket.emit(EventType.PUSH, { //some data });
複製程式碼

對於處理訊息會傳送到哪個後端伺服器,寫一個路由函式發起rpc即可

"灰度釋出"

同理,有狀態後端伺服器也不適用滾動更新,因為會丟失業務資訊。因為後端伺服器外界不可訪問,也不能用Ingress路由灰度釋出的方式來更新,怎麼辦呢?

其實按照上面的rpc設計已經解決了這個問題,例如現在匹配服是1.0.0000版本,如果想要釋出1.0.0001版本,只需要部署一個matching-v100001的應用,客戶端在配置檔案裡把Version改成1.0.0001,那麼下一次請求就會匹配到matching-v100001的應用上,這樣可以根據客戶端配置隨時切換伺服器版本號,達到了灰度釋出的效果。

  • cron 定時任務
apiVersion: batch/v1beta1 # kubectl api的版本
kind: CronJob # kubernetes的資源型別 這裡選擇CronJob 如果不需要定時選擇Job
metadata:
    name: test-cron
spec:
    schedule: '0 0 * * *' # 每天晚上執行一次 cron表示式
    jobTemplate:
        spec:
            template:
                metadata:
                    labels:
                        serverType: cron
                        env: production
                        version: 1.0.0000
                spec:
                    containers:
                        - name: cronapp
                          image: yourDockerRegistry:1.0.0000
                          args:
                              - npm
                              - run
                              - start:testCron
                          env: #
                              - name: DEBUG
                                value: 'ccgame:*'
                              - name: NODE_ENV
                                valueFrom:
                                    fieldRef:
                                        fieldPath: metadata.labels['env']
                              - name: NATS_ADDRESS
                                value: 'nats://xxx:xxx@nats:4222'
                    restartPolicy: OnFailure
                    imagePullSecrets:
                        - name: regsecret

複製程式碼

部署之後定時器就開始執行了,非常簡單。通過spec.successfulJobsHistoryLimitspec.failedJobsHistoryLimit,表示歷史限制,是可選的欄位。它們指定了可以保留多少完成和失敗的Job,預設沒有限制,所有成功和失敗的Job都會被保留。然而,當執行一個Cron Job時,Job可以很快就堆積很多,所以一般推薦設定這兩個欄位的值。如果設定限制的值為 0,那麼相關型別的Job完成後將不會被保留。

更新

直接更改映象版本號就可以了,下次執行的時候會以新的映象版本執行

結束

至此,基本的遊戲業務框架已經搭建完成,最初的目標都達成了。下一期部落格更新kubernetes學習筆記 (四):自動化部署k8s實戰

一起來學習

新增我的微信,拉你進群一起學習k8s

kubernetes學習筆記 (三):阿里雲遊戲業務實戰

相關文章