基於Docker Compose的.NET Core微服務持續發布

是不是現在每個團隊都需要上K8s才夠潮流,不用K8s是不是就落伍了。今天,我就通過這篇文章來回答一下。

一、先給出我的看法和建議

我想說的是,對於很多的微小團隊來說,可能都不是一定要上K8s,畢竟上K8s也是需要成本和人力的。對像我司一樣的傳統企業做數字化轉型的資訊團隊來講,人數不多,沒有專門的Ops人員,領導又想要儘快迭代支援公司業務發展,而且關鍵還要節省成本(內心想法是:WTF)。

在此之下,資訊團隊需要綜合引入先進技術帶來的價值以及需要承擔的成本和風險。任何架構的產生,都會解決一定的問題,但是同樣也會引入新的複雜度,正如微服務架構風格,看著香實際吃著才知道需要承受很多的「苦」(比如數據一致性又比如服務的治理等等)。

因此,結合考慮下來,我的建議是開發測試環境使用Docker Compose進行容器編排即可,而UAT或生產環境則建議使用雲廠商的K8s服務(比如阿里雲ACK服務)而不選擇自建K8s集群

那麼,今天就跟大家介紹一下如何使用Docker Compose這個輕量級的編排工具實現.NET Core微服務的持續發布。

二、Docker Compose

Docker主要用來運行單容器應用,而Docker Compose則是一個用來定義和應用多容器應用的工具,如下圖所示:

使用Docker Compose,我們可以將多容器的定義和部署方式定義在一個yml文件中,這種方式特別是微服務這種架構風格,可以將多個微服務的定義及部署都規範在一個yml文件中,然後一鍵部署、啟動或銷毀整個微服務應用。所有的一切操作,只需要下面的一句話:

$docker-compose up

Compose 的安裝請參考://docs.docker.com/compose/install/#install-compose,這裡就不再贅述,它不是本文重點。

安裝後驗證:

$docker-compose --version
docker-compose version 1.25.1, build a82fef07

三、一個簡單的發布流程示例

本文演示示例的流程大概會如下圖所示:

閱讀過我之前的一篇文章《基於Jenkins Pipeline的ASP.NET Core持續集成實踐》的童鞋應該對這個流程比較熟悉了。這裡,我仍然延續這個流程,作為一個平滑過渡。首先,我們在Jenkins上觸發容器的發布流水線任務,此任務會從Git伺服器上拉取指定分支(一般都是測試分支)的最新程式碼。

其次,在CI伺服器上使用.NET Core SDK執行Build編譯和發布Release文件,基於發布後的Release文件進行鏡像的打包(確保你的項目裡面都有Dockerfile且設置為「始終複製」)。然後,基於打包後的鏡像,將其推送到企業的私有Registry伺服器上(即本地鏡像倉庫,可以基於Harbor搭建一個,也可以直接用Docker Registry搭建一個,不建議使用docker hub的公有庫,如何搭建私有鏡像倉庫可以參考我的這一篇文章:《Docker常用流行鏡像倉庫的搭建》)。

最後,在測試伺服器或要運行容器的伺服器上執行docker compose up完成容器的版本更新。當然,也可以直接在docker-compose.yml文件內設置編譯路徑完成編譯和發布的操作(Dockerfile裡面定義進行Build和Publish)。這裡目的在於讓實例更簡單,且能讓初學者更容易理解,於是我就分開了。

四、.NET Core微服務發布示例

微服務示例準備

假設我們有一堆使用ASP.NET Core開發的微服務,這些微服務主要是為了實現諸如API網關、Identity鑒權、Notification通知、Job中心等基礎設施服務,因此我們將他們整合在一起進行持續集成和部署。

這裡為了讓示例儘可能簡單,每個微服務的Dockerfile只有以下幾句(這裡以一個通知API服務為例):

FROM reg.xdp.xi-life.cn/xdp-service-runtime:2.2
WORKDIR /app
COPY . /app
EXPOSE 80
ENTRYPOINT ["dotnet", "XDP.Core.Notification.API.dll"]

其中這裡的容器鏡像來自於私有鏡像倉庫,是一個封裝過的用於ASP.NET Core Runtime的容器鏡像。當然,上面說過,也可以在Dockerfile裡面進行服務的編譯和發布。

流水線任務腳本

同樣,為了在Jenkins上快速進行微服務的鏡像構建和推送以及部署,我們也需要編寫一個流水線構建任務。

下面是這個示例流水線任務的腳本:

pipeline{
    agent any
    environment {
        API_CODE_BRANCH="*/master"
        SSH_SERVER_NAME_REGISTRY="XDP-REGISTRY-Server"
        SSH_SERVER_NAME_DEV="XDP-DEV-Server"
        SSH_SERVER_NAME_AT="XDP-AT-Server"
        SSH_SERVER_NAME_SIT="XDP-SIT-Server"
    }
    stages {
        stage('XDP Core APIs Checkout & Build') {
            steps{
             checkout([$class: 'GitSCM', branches: [[name: env.API_CODE_BRANCH]], doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [], userRemoteConfigs: [[credentialsId: '35b9890b-2338-45e2-8a1a-78e9bbe1d3e2', url: '//192.168.18.150:3000/XDP.Core/XDP.Core.git']]])
             echo 'Core APIs Dev Branch Checkout Done' 
             bat  '''
               dotnet build XDP.Core-InfraServices.sln
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Components\\XDP.Core.ApiGateway\\XDP.Core.ApiGateway.csproj" -o "%WORKSPACE%\\XDP.Core.ApiGateway.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Components\\XDP.Core.ApiGateway.Internal\\XDP.Core.ApiGateway.Internal.csproj" -o "%WORKSPACE%\\XDP.Core.ApiGateway.Internal.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Authorization.API\\XDP.Core.Authorization.API.csproj" -o "%WORKSPACE%\\XDP.Core.Authorization.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Authorization.Job\\XDP.Core.Authorization.Job.csproj" -o "%WORKSPACE%\\XDP.Core.Authorization.Job\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Identity.API\\XDP.Core.Identity.API.csproj" -o "%WORKSPACE%\\XDP.Core.Identity.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.Notification.API\\XDP.Core.Notification.API.csproj" -o "%WORKSPACE%\\XDP.Core.Notification.API\\publish" --framework netcoreapp2.2
               dotnet publish "%WORKSPACE%\\src\\services\\XDP.Core\\Services\\XDP.Core.JobCenter\\XDP.Core.JobCenter.csproj" -o "%WORKSPACE%\\XDP.Core.JobCenter.API\\publish" --framework netcoreapp2.2
               '''
             echo 'Core APIs Build & Publish Done'
            }
        }
        stage('XDP API Gateway Docker Image') {
            steps{
                bat '''
                    docker rmi reg.xdp.xi-life.cn/core-apigateway-portal:latest;
                    cd XDP.Core.ApiGateway.API/publish;
                    docker build -t reg.xdp.xi-life.cn/core-apigateway-portal:latest .;
                    docker push reg.xdp.xi-life.cn/core-apigateway-portal:latest;
                '''
                echo 'XDP Portal API Gateway Deploy Done'    

                bat '''
                    docker rmi reg.xdp.xi-life.cn/core-apigateway-internal:latest;
                    cd XDP.Core.ApiGateway.Internal.API/publish;
                    docker build -t reg.xdp.xi-life.cn/core-apigateway-internal:latest .;
                    docker push reg.xdp.xi-life.cn/core-apigateway-internal:latest;
                '''
                echo 'XDP Internal API Gateway Deploy Done'
            }
        }
        stage('Core Identity API Docker Image') {
            steps{
                ......
            }
        }
        stage('Core Authorization Job Docker Image') {
            steps{
                ......
            }
        }
        stage('Core Notification API Docker Image') {
            steps{
                ......  
            }
        }
        stage('Core JobCenter API Docker Image') {
            steps{
                ......     
            }
        }
        stage('Deploy to Local SIT Server') {
            steps{
                sshPublisher(publishers: [sshPublisherDesc(configName: env.SSH_SERVER_NAME_SIT, 
                    transfers: [sshTransfer(cleanRemote: false, excludes: '', 
                    execCommand: '''
                     cd compose/xdp;
                     IMAGE_TAG=latest docker-compose down;
                     docker rmi $(docker images -q)
                     IMAGE_TAG=latest docker-compose up -d;
                    ''', 
                    execTimeout: 120000, flatten: false, makeEmptyDirs: false, 
                    noDefaultExcludes: false, patternSeparator: '[, ]+', 
                    remoteDirectory: 'compose/xdp/', remoteDirectorySDF: false, 
                    removePrefix: '', 
                    sourceFiles: '', 
                    excludeFiles: '')], 
                    usePromotionTimestamp: false, useWorkspaceInPromotion: false, verbose: false)])
                echo 'Deploy to XDP SIT Server Done'     
            }
        }
    }
}

這個腳本我省去了一些重複的內容,只需要了解它的職責即可。

需要注意的地方有幾點:

(1)在進行dotnet build的時候,要明確SDK使用哪個版本,比如因為這裡的示例程式碼是基於.NET Core 2.2開發的因此這裡使用的是2.2。如果你使用的是2.1,則標註2.1,如果是3.1,則標註3.1。

(2)在進行docker build的時候,要明確鏡像使用哪個Tag,這裡因為是本地開發測試環境,所以直接簡單暴力的直接使用了latest這個Tag。

(3)在進行sshPublish的時候,要提前將docker-compose.yml配置拷貝到對應的指定目錄下。當然,這一塊建議也將其納入git倉庫進行統一管理和統一發布到不同的環境的指定目錄下。

(4)如果你的Jenkins是裝在Windows Server上,要記住只有Windows Server 2016及以上版本才支援Docker,否則無法直接進行docker的命令行操作。如果低於2016,Windows 10專業版也可以,不過不建議。

擴展點:

是否可以一套docker-compose方案標準化部署到多個測試環境?是可以的,我們可以在Jenkins構建任務中配置Parameters,這樣就可以一次性部署到多個環境。例如,下面的示例中我設置了一個每次發布可以選擇到底要發布到哪個環境,這裡是單選,你也可以設置為多選。

效果如下:

docker-compose.yml

終於來到了compose的重點內容:docker-compose.yml

這裡我給出上面這個示例的yml示例內容(同樣,也省略了重複性的內容):

version: '2'

services:
  core_apigateway_portal:
    image: reg.xdp.xi-life.cn/core-apigateway-portal:${IMAGE_TAG}
    container_name: xdp_core_apigateway_portal
    restart: always
    privileged: true
    mem_limit: 1024m
    memswap_limit: 1024m
    env_file:
      - ../docker-variables.env
    ports:
      - 5000:80
    volumes:
      - /etc/localtime:/etc/localtime

  core_apigateway_internal:
    image: reg.xdp.xi-life.cn/core-apigateway-internal:${IMAGE_TAG}
    container_name: xdp_core_apigateway_internal
    restart: always
    privileged: true
    mem_limit: 1024m
    memswap_limit: 1024m
    env_file:
      - ../docker-variables.env
    ports:
      - 5100:80
    volumes:
      - /etc/localtime:/etc/localtime

  core_identity_api:
    image: reg.xdp.xi-life.cn/core-identity-api:${IMAGE_TAG}
    container_name: xdp_core_identity_api
    restart: always
    privileged: true
    mem_limit: 512m
    memswap_limit: 512m
    env_file:
      - ../docker-variables.env
    ports:
      - 6010:80
    volumes:
      - /etc/localtime:/etc/localtime

  core_authorization_api:
    ......

  core_authorization_job:
    ......

  core_notification_api:
    ......

  core_jobcenter_api:
    ......

  bff_xams_api:
    ......

備註:這裡使用的是version:2的語法,因為3開始不支援記憶體限制mem_limit等屬性設置。當然,你可以使用3的語法,去掉mem_limit和memswap_limit屬性即可。

這裡的env環境變數配置是定義在另外一個單獨的env文件裡面的,建議每個環境建立一個單獨的env文件供docker-compose.yml文件使用,比如下面是一個AT(自動化測試)環境的env文件內容示例:

# define xdp containers env
ASPNETCORE_ENVIRONMENT=at
ALIYUN_ACCESS_KEY=sxxdfdskjfkdsjkds
ALIYUN_ACCESS_SECRET=xdfsfjiwerowuoi
JWT_TOKEN=sdfsjkfjsdkfjlerwewe
IDENTITY_DB_CONNSTR=Server=192.168.16.150;Port=3306;Database=identity_at;Uid=xdpat;Pwd=xdpdba;Charset=utf8mb4
APIGATEWAY_DB_CONNSTR=Server=192.168.16.150;Port=3306;Database=services_at;Uid=xdpat;Pwd=xdpdba;Charset=utf8mb4
......
API_VERSION=AT-v1.0.0

這裡,最主要的環境變數就是ASPNETCORE_ENVIRONMENT,你需要指定這些要編排的微服務容器使用哪個環境的appSettings。同樣,這裡也引申出另一個問題,那就是配置的集中管理,可能你會說出類似Apollo,Spring Cloud Config,K8s Configmap之類的解決方案。這裡不是本文的重點,也就跳過。

快速實操體驗

現在我們來通過在Jenkins中觸發構建任務,可以看到如下圖所示的流水線任務狀態示意:

這樣,一個簡單的快速發布流水線就完成了,在單機多容器編排部署方面,Docker Compose是個不錯的選擇。

五、一些擴展

Consul服務發現容器編排

相比很多童鞋也都在使用Consul作為服務發現組件,我們也可以將Consul納入到Compose中來統一編排。例如,我們可以這樣來將其配置到docker-compose.yml中:

services:
  consul_agent_server:
    image: reg.xdp.xi-life.cn/xdp-consul-runtime:${IMAGE_TAG}
    container_name: xdp_consul_agent_server
    restart: always
    privileged: true
    mem_limit: 1024m
    memswap_limit: 1024m
    env_file:
      - ../docker-variables.env
    ports:
      - 8500:8500
    command: 
      agent -server -bootstrap-expect=1 -ui -node=xdp_local_server -client='0.0.0.0' -data-dir /consul/data -config-dir /consul/config -datacenter=xdp_local_dc
    volumes:
      - /etc/localtime:/etc/localtime
      - /docker/consul/data:/consul/data
      - /docker/consul/conf:/consul/config

這裡只使用到了一個Consul Server Agent,你可以配置一個3個Server節點的Consul Server集群,請自行查閱相關資料。此外,基於Compose我們也可以為API網關設置links從而實現服務發現的效果,當然前提是你的服務數量不多的前提下。這種方式是通過網路層面幫你做了一層解析,從而實現多個容器之間的互連。這裡也推薦一下俺們成都地區的小馬甲老哥的一篇《docker-compose真香》的文章,他講解了docker的網橋模式。

基於Compose的編譯發布一體化

我們可以看到在很多開源項目中都是將編譯發布一體化的,因此我們可以看到在這些項目的Dockerfile中是這樣寫的:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /app

COPY ./*.sln ./NuGet.Config ./
COPY ./build/*.props ./build/

# Copy the main source project files
COPY src/*/*.csproj ./
RUN for file in $(ls *.csproj); do mkdir -p src/${file%.*}/ && mv $file src/${file%.*}/; done

RUN dotnet restore

# Copy everything else and build app
COPY . .
RUN dotnet build -c Release

# api-publish

FROM build AS api-publish
WORKDIR /app/src/Exceptionless.Web

RUN dotnet publish -c Release -o out

# api

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS api
WORKDIR /app
COPY --from=api-publish /app/src/Exceptionless.Web/out ./
ENTRYPOINT [ "dotnet", "Exceptionless.Web.dll" ]

......

在Dockerfile中我們看到的是拉取.NET Core SDK來進行Restore、Build和Publish,進一步地提高了標準化的遷移性,也儘可能發揮Docker的集裝箱作用。
這時你可以在docker-compose.yml中定義Dockerfile告訴compose先幫我進行Build鏡像(這裡的build配置下就需要指定Dockerfile的位置):

services:
  api:
    build:
      context: .
    image: exceptionless/api:latest
    restart: always
    ......

六、小結

Docker是容器技術的核心、基礎,Docker Compose是一個基於Docker的單主機容器編排工具,功能並不像Docker Swarm和Kubernetes是基於Docker的跨主機的容器管理平台那麼豐富。

我想你看到這裡也應該有了自己的答案,結合我在最開頭給的建議,如果你處在一個小團隊中,綜合人員水平、技能儲備、運維成本 及 真實業務量要求,可以在開發測試環境(一般都是單主機環境的話)中使用Docker Compose進行初步編排。而在生產環境,即使是小團隊也建議上雲主機,利用雲的彈性為未來的業務發展做基礎,然後可以考慮使用雲上的K8s服務來進行生產級的容器編排。