【2022鐵人賽】不同.Net Docker Image共用Dockerfile

終於在前面把該拆成範本的YAML差不多都搞定了。

不過如果這樣子就要開始應用在多個不同的Azure DevOps專案,並且是.Net開發的系統而且使用Docker Image的話,可能就會碰到一個需要思考的問題,那就是每個.Net系統都要有一個自己的Dockerfile檔案嗎?

為什麼會這樣子說呢?如果你還記得前面「基本版-建立CI Pipeline(2)」這篇文章裡面使用到的Dockerfile,最後一行的內容是下面這樣:

ENTRYPOINT ["dotnet", "IronmanWeb.dll"]

也就是說在EntryPoint裡面使用dotnet指令傳入的是.Net DLL的名稱,不同的系統會編譯出不同的DLL名稱,所以這個內容如果是寫死在Dockerfile裡面,那麼每一個.Net系統都需要有一份自己的Dockerfile…

我知道現在新版本的Visual Studio已經有功能可以自動從專案或方案自動產生出來相對應的Dockerfile,不過…其實不少開發人員是搞不懂裡面的內容是在做什麼的。

而且每一個系統需要一份Dockerfile,那如果忘了產生,或是內容忘了更新,或是檔案沒有傳到版控亦或是被不小心刪掉了呢?

或許你會說…檔案不是放在各別專案的Pipelines Git Repo嗎?開發人員不是碰不到這個Repository?

對…,放在各別專案的Pipelines Git Repo是不歸開發人員管,但是檔案是開發人員產生之後給你?還是你幫每一個專案都寫一份Dockerfile?弄一個範本的Dockerfile然後新專案再根據DLL名稱去更改?

上面不管是哪一個方法都可行,放在Source Code的Git Repo裡面,從YAML範本中去checkout sources找到指定檔名的Dockerfile,或是放在Pipelines Git Repo裡面,檔案由開發人員提供或是用範本改DLL名稱都可以。

不過更好的方式是在Templates Git Repo裡面有一份.Net用的Dockerfile,不同的系統傳入不同的DLL名稱,也就是在Dockerfile裡面加上ARG和ENV的使用,下面比較一下修改前的Dockerfile和修改後的Dockerfile差異吧!

# 原本的Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine

WORKDIR /app
COPY . .

ENTRYPOINT ["dotnet", "IronmanWeb.dll"]
# 修改後的Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine

# 加上ARG,名稱為TARGET_FILE
ARG TARGET_FILE

WORKDIR /app
COPY . .

# 加上ENV,名稱為APP,設定值是/app/${TARGET_FILE}
ENV APP=/app/${TARGET_FILE}

# 改用CMD執行dotnet並且使用APP環境變數
CMD dotnet ${APP}

# 加上EXPOSE對外開放80 Port
EXPOSE 80

改成新的內容之後,對應的steps/docker-build.yaml step template也要修改一下:

parameters:
  - name: imgRepository
    type: string
  - name: imgTags
    type: object
  - name: buildDockerfile
    type: string
    default: Build.Dockerfile
  - name: buildContext
    type: string
  - name: containerRegistry
    type: string
  # 增加buildArgs參數
  - name: buildArgs
    type: string
    default: ''

steps:
  - task: Docker@2
    displayName: Build image
    inputs:
      repository: '${{ parameters.imgRepository }}'
      command: 'build'
      Dockerfile: ${{ parameters.buildDockerfile }}
      buildContext: ${{ parameters.buildContext }}
      # 原本是arguments: '--no-cache',利用條件判斷決定要不要傳入--build-arg
      ${{ if eq(parameters.buildArgs, '') }}:
        arguments: '--no-cache'
      ${{ else }}:
        arguments: '--no-cache --build-arg ${{ parameters.buildArgs }}'
      tags: ${{ parameters.imgTags }}
  - task: Docker@2
    displayName: "Login to Container Registry"
    inputs:
      command: login
      containerRegistry: ${{ parameters.containerRegistry }}
  - task: Bash@3
    displayName: Push docker image
    inputs:
      targetType: 'inline'
      script: |
        docker push -a ${{ parameters.imgRepository }}

當然相對應的jobs/buildImage.yaml job template也要加上對應的參數設定:

parameters:
  - name: artifactName
    type: string
    default: OutputFiles
  - name: unzip
    type: boolean
    default: false
  - name: zipFileName
    type: string
    default: ''
  - name: unzipToFolderPath
    type: string
    default: ''
  # 因為Dockerfile從範本的Git Repo取得,所以要加上定義的名稱
  - name: templateResourceName
    type: string
    default: templates

  - name: imgRepository
    type: string
  - name: imgTags
    type: object
  - name: buildDockerfile
    type: string
    default: Build.Dockerfile
  - name: buildContext
    type: string
  - name: containerRegistry
    type: string
  # 加上buildArgs參數
  - name: buildArgs
    type: string
    default: ''

jobs:
  - job: BuildImage
    dependsOn: BuildCode
    steps:
    # 加上checkout從範本所在的Git Repo取得Dockerfile
    - checkout: ${{ parameters.templateResourceName }}
      path: ${{ parameters.templateResourceName }}
      clean: true
    - template: ../steps/download-pipeline-artifacts.yaml
      parameters:
        artifactName: ${{ parameters.artifactName }}
        unzip: ${{ parameters.unzip }}
        zipFileName: ${{ parameters.zipFileName }}
        unzipToFolderPath: ${{ parameters.unzipToFolderPath }}
    - template: ../steps/docker-build.yaml
      parameters:
        imgRepository: ${{ parameters.imgRepository }}
        imgTags: ${{ parameters.imgTags }}
        buildDockerfile: ${{ parameters.buildDockerfile }}
        buildContext: ${{ parameters.buildContext }}
        containerRegistry: ${{ parameters.containerRegistry }}
        # 用條件判斷要不要加上buildArgs參數設定
        ${{ if ne(parameters.buildArgs, '') }}:
          buildArgs: ${{ parameters.buildArgs }}

在stages/dotnet-build-stage.yaml stage template的部份則是增加startFileName參數,讓專案的Pipeline YAML傳入真正要執行的檔名:

parameters:
  # buildCode.yaml parameters
  - name: sourcePath
    type: string
    default: $(Build.SourcesDirectory)
  - name: slnOrCsprojName
    type: string
    default: Pipeline.sln

  # buildImage.yaml parameters
  - name: artifactName
    type: string
    default: OutputFiles
  - name: unzip
    type: boolean
    default: false
  - name: zipFileName
    type: string
    default: ''
  - name: unzipToFolderPath
    type: string
    default: ''
  - name: imgRepository
    type: string
  - name: imgTags
    type: object
  - name: buildDockerfile
    type: string
    default: Build.Dockerfile
  - name: buildContext
    type: string
  - name: containerRegistry
    type: string
  # 讓專案的YAML傳入真正要執行的DLL檔名
  - name: startFileName
    type: string
  # 因為Dockerfile從範本的Git Repo取得,所以要加上定義的名稱
  - name: templateResourceName
    type: string
    default: templates    

stages:
- stage: BuildSourceAndImage
  displayName: 編譯程式碼和建立Docker Image
  jobs:
    - template: ../jobs/buildCode.yaml
      parameters:
        sourcePath: ${{ parameters.sourcePath }}
        slnOrCsprojName: ${{ parameters.slnOrCsprojName }}
    - template: ../jobs/buildImage.yaml
      parameters:
        # 加上templateResourceName參數
        templateResourceName: ${{ parameters.templateResourceName }}
        artifactName: ${{ parameters.artifactName }}
        unzip: ${{ parameters.unzip }}
        zipFileName: ${{ parameters.zipFileName }}
        unzipToFolderPath: ${{ parameters.unzipToFolderPath }}
        imgRepository: ${{ parameters.imgRepository }}
        imgTags: ${{ parameters.imgTags }}
        buildDockerfile: ${{ parameters.buildDockerfile }}
        buildContext: ${{ parameters.buildContext }}
        containerRegistry: ${{ parameters.containerRegistry }}
        # 因為在.Net系統一定會使用buildArgs,並且設定TARGET_FILE這個在Dockerfile內設定的ARG,所以不用條件判斷
        buildArgs: "TARGET_FILE=${{ parameters.startFileName }}"

最後就是在專案的CI Pipeline YAML內增加startFileName的參數設定就行了,這部份只貼有修改的部份YAML內容:

stages:
  - template: stages/dotnet-build-stage.yaml@templates
    parameters:
      sourcePath: $(Build.SourcesDirectory)
      slnOrCsprojName: $(slnOrCsprojName)
      artifactName: '$(pipelineArtifact)'
      unzip: true
      zipFileName: buildResult.zip
      unzipToFolderPath: $(System.ArtifactsDirectory)/buildImage
      imgRepository: $(imgRepository)
      imgTags: |
        latest
      buildDockerfile: $(buildDockerfile)
      buildContext: $(System.ArtifactsDirectory)/buildImage
      containerRegistry: $(imgRegistryService)
      # 加上下面這兩行
      startFileName: IronmanWeb.dll
      templateResourceName: templates

上面加上startFileName的部份更好的方式應該是透過變數來設定,因為別的地方可能也會用到專案的名稱(以這邊的例子就是IronmanWeb),所以或許會是下面這樣:

variables:
  projectName: IronmanWeb
  startFileName: $(projectName).dll

這樣和projectName一樣的地方就只需要設定一次,修改也不會漏改其它地方。

發佈留言