[CI/CD] 在 Docker 運行 Gitlab CI

我採用 Gitlab Runner 運行在 Container,並使用 Docker 當 Executor,為每個 CI Job 建立一個乾淨的 Container 來執行 Docker 命令的做法。

以下我會展示從 0 開始設置的各個步驟,包含 gitlab-ci.yml 開始創建 & 指令的調整、Docker 版 Gitlab Runner 的 create & register 方式,Docker in Docker 與 Docker Socket 兩種的做法我都會提到,以及過程中的一些 error 問題排解。

 

 

 

前言

 

在此之前先解釋一下 Docker SocketDocker in Docker 的差別。

Docker in Docker(又名 dind)顧名思義就是在 Docker 容器內再運行一個 Docker 容器來執行 Docker 命令的做法。

而我這裡要採用的做法是 Docker Socket(又名 docker.sock),Docker SocketDocker in Docker 很像,一樣是在 Container 內運行 Docker,但是 Docker Socket 是與 host 共用 Docker Daemon,內部建置 Container 的方式與層級不同,下面連結文章中會有詳細解釋,有興趣的小夥伴再自己去看吧,我後面會再展示使用上的差別。

關於 Docker in Docker 與 Docker Socket 的優缺點比較眾說紛紜,目前都各有支持者,大家可以參考以下文章,根據自身的情境選擇適合的做法。

Using Docker-in-Docker for your CI or testing environment? Think twice. (jpetazzo.github.io)

Making docker-in-docker builds x2 faster using Docker “cache-from” option | by Gajus Kuizinas | Medium

Why is Docker-in-Docker considered bad? - DevOps Stack Exchange

 

最後也會再跟大家提到我個人為什麼選擇使用 Docker Socket 的原因與考量~

 

 

示範環境

Linux OS Version:Ubuntu 20.4

Docker Version:20.10.21

Gitlab Version:15.5

Dockerfile TargetFramework:.NET 6  (這裡特別註明我是 .NET Project 是有原因的,後面會再提到)

 

 

安裝 Container 版 Gitlab Runner

這裡的 volume 有兩種選擇,local system volume mountsDocker named volumes,其實就只是 volume 的路徑不同而已,你可以視情況擇一即可,不一定要跟我一樣。

 

run Git Runner in Container

[local system volume mounts 版]

docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest

 

[Docker named volumes 版]

Create the Docker volume

docker volume create gitlab-runner-config
docker run -d --name gitlab-runner --restart always \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v gitlab-runner-config:/etc/gitlab-runner \
    gitlab/gitlab-runner:latest

 

 

關於 Volume 的說明我覺得這篇挺詳細的

The Complete Guide to Docker Volumes | by Mahbub Zaman | Towards Data Science

也可以看一下官方說明瞭解他們的差異

Manage data in Docker | Docker Documentation

 

Container 建立完畢

 

 

註冊 Gitlab Runner(基礎 Docker 版)

官方教學:Registering runners (deprecated) | GitLab

不要被 (deprecated) 嚇到,由於我的 Gitlab 目前是 15.5 版,所以用以下方式註冊沒有問題

 

這裡的註冊方式使用 Docker 版

後面如果沒有特別提到的部分代表是以 local system volume mounts 為主示範,如果你是用 Docker named volumes 可以再參考前面的指令自己替換

這裡要注意,如果 run git runner Container 時用哪一個 volume,註冊就必須要用同一種喔~他才有辦法建立註冊的關聯

上述官方教學內有說明去哪裏取得 registration token,我就不再說了,下面的 --registration-token 請自行更換成自己的,不要直接複製我的阿 😓

 

單行 register 指令

[local system volume mounts 版]

docker run --rm -it -v /srv/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner register

 

[Docker named volumes 版]

docker run --rm -it -v gitlab-runner-config:/etc/gitlab-runner gitlab/gitlab-runner:latest register

 

此指令會後續再一個一個選項問你

maintenance 我沒有要輸入,所以直接按 Enter 就可以了

executor 這裡我選擇 Docker,讓每個 CI Job 都建立一個全新的 Container 來做事

 

executor 的詳情可參考以下文章

GitLab CI 之 Runner 的 Executor 該如何選擇? | 艦長,你有事嗎? (chengweichen.com)

 

這裡要注意的是 default Docker image,他是指到時候 gitlab-ci.yml 要預設使用哪個 image 運行 Container 來做 Job,我這裡是選擇預設 docker:20.10.16,你可以視情況,你的 job 比較常用哪個 image 就設置哪個就可以了。image 建議指定版本,以免某天因為版本升級後會有不同的動作而導致異常。

 

但是這種一個一個選項問你的設置方式,要是途中一旦打錯字就要整個重來,麻煩死了 🙃 ,而且他也不會全部選項都問一遍,如果還要多加些設置,也還要我自己再進去設定檔做手動調整,所以這裡可以改成將所有設置整合成多行的命令,一次執行就好

 

多行 register 指令

docker run --rm -it -v /srv/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner register \
--non-interactive \
--executor="docker" \
--docker-image docker:20.10.16 \
--url "https://git.kitty.net/" \
--registration-token "GR4148941iKu8vYTueE4AuzzBi22A" \
--description "kitty_docker_gitrunner" \
--tag-list "kittydemo_docker_runner" \
--run-untagged="true" \
--locked="false";

要小心指令不要有任何多餘的空格,都有可能導致他無法正常註冊

 

 

註冊完畢後,就可以在 Gitlab 的 runners 列表中看到了(看你註冊的是哪一種 runner 就去哪裏看)

※ 如果後續還想要改註冊的 info,可以點 🖍 編輯

 

到 Server 查看 config.toml,可以看到一些 runner 註冊的資訊

這章的註冊指令是基本的 Docker 版,後面還會再提到 dind 版與 docker.sock 版的指令需要再加些什麼,如果你已經確定好要用哪種方式,你也可以直接改參考下面兩章的指令擇一註冊就好了。

註冊 Gitlab Runner(dind 版)

註冊 Gitlab Runner(docker.sock 版)

如果你原本已經使用基本的 Docker 版指令註冊了,我後面也會再提到執行 Job 時會遇到什麼問題,以及可以如何事後手動修改。

 

 

取消註冊 Gitlab Runner

如果你不小心設置有誤或者有其他原因想要重新註冊,可以用以下方式刪掉註冊的關聯重新註冊~

在 Gitlab 頁面中可以直接 Remove runner,但是這只是代表刪除了 Gitlab 上的 runner,並不代表 Server 上的 Gitlab Runner 有被刪除註冊的設置,所以還需要至 Server 上做以下操作

 

查看 gitlab runner 清單

gitlab-runner list

像上圖的範例可以看到有被註冊了 2 次

 

刪除註冊 gitlab runner

gitlab-runner verify --delete

此指令會逐一檢查所有 runner,刪除所有未註冊 / 已被刪除的 runner

 

在查看一次 list 就會發現只剩下一個了

 

 

添加 Docker 版 gitlab-ci.yml

搞定好 Git Runner 後,來設置 gitlab-ci.yml 吧~

如果你還沒新增過 ci yaml,可以使用以下方式新增,如果你已經有 yml 了,就直接跳過這章吧

至 Gitlab Project > CI/CD > Editor 頁

切換好你想要添加 yaml 的 Branch,然後點選中間的 Configure pipeline 藍色按鈕

 

一進來他就會先幫你 create 好一個 Default 的 gitlab-ci.yml,但是這不是我們要的

點選 Browse templates按鈕

 

在這裡 Gitlab 貼心的有提供很多版本的 yaml 範例

 

我們這裡可以參考 Docker 版的 yaml 來改

 

不過 Pipeline Editor 頁面沒辦法直接套用 template

你可以選擇以下方法:(這 2 種操作出來的內容都是一樣的,擇一即可)
 

🟣 前往 Browse templates 頁面,把想要的 template 內容複製,貼回 Pipeline Editor 頁
 

🟣 回 Project 主頁,使用 New file 功能

在下拉視窗中選擇

選擇完後他就會自動幫你添加你選擇的 Template 內容了

補充閱讀:Gitlab CI/CD yaml的撰寫注意事項

Development guide for GitLab CI/CD templates | GitLab

 

 

Docker 版 gitlab-ci.yml 說明

這裡我們拿這一個官方的 Docker 版 gitlab-ci.yml 範本來說明

lib/gitlab/ci/templates/Docker.gitlab-ci.yml · master · GitLab.org / GitLab FOSS · GitLab

 

官方這裡的 Template 是 Docker in Docker 版的 yaml

# This file is a template, and might need editing before it works on your project.
# To contribute improvements to CI/CD templates, please follow the Development guide at:
# https://docs.gitlab.com/ee/development/cicd/templates.html
# This specific template is located at:
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Docker.gitlab-ci.yml

# Build a Docker image with CI/CD and push to the GitLab registry.
# Docker-in-Docker documentation: https://docs.gitlab.com/ee/ci/docker/using_docker_build.html
#
# This template uses one generic job with conditional builds
# for the default branch and all other (MR) branches.

docker-build:
  # Use the official docker image.
  image: docker:latest
  stage: build
  services:
    - docker:dind
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
  # Default branch leaves tag empty (= latest tag)
  # All other branches are tagged with the escaped branch name (commit ref slug)
  script:
    - |
      if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
        tag=""
        echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
      else
        tag=":$CI_COMMIT_REF_SLUG"
        echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
      fi
    - docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" .
    - docker push "$CI_REGISTRY_IMAGE${tag}"
  # Run this job in a branch where a Dockerfile exists
  rules:
    - if: $CI_COMMIT_BRANCH
      exists:
        - Dockerfile

 

他與一般的 Job 內容相比,多了一些以下設置項目:

 

image: docker:latest:指定 executor 要使用哪一個 image 來起 Container 執行 Job,這個不一定要設置,沒有設置的話,就會使用前面 register Git Runner 時設置的 Default Image

services::可以在這裡指定其他 image,他會 create 另一個 Container,並且可以在 Job 中與其他 Container 溝通(可以指定多個)。更多使用詳情可參考官方文章 Services | GitLab

   - docker:dind:Docker in Docker 做法的話一定要加這個

docker login:這是用來登入 docker registry 的指令,如果你們的 registry 不需要登入即可使用,這行就可以不用寫

docker build:將你的 Project 包成 docker image

docker push:將你的 docker image 發布至指定的 registry

rules:
   - if: $CI_COMMIT_BRANCH
     exists:
       - Dockerfile

為此 Job 添加 rule 判定

這裡是判定如果這個 branch 內存在 Dockerfile 檔案,才會執行這個 Job,判定的路徑預設為 Gitlab Project 根目錄

 

過程中可以看到有些 $CI_COMMIT_BRANCH 等內容 ,這是官方預設的 variables,提供你更方便的存取某些 data,更多 predefined variables 可參考 Predefined variables reference | GitLab

 

 

dind 版之疑難排解

因為官方提供的預設 yaml 是 docker in docker 版的,所以我先使用 Docker in Docker 的部屬方式,上面的 yaml 照理來說應該就要可以直接使用了,不過…………

 

當我第一次執行 Job 時,發現出現以下錯誤  👇

問題 1dial tcp: lookup docker on 168.95.1.1:53: no such host

 

🟢 問題 1 解決辦法:這是因為 dind 做法必須開啟特權模式,default 的 register Runner 指令預設 privileged  = false 才會出此錯誤

 

進到 Git Runner Container bash 內的 /etc/gitlab-runner 路徑,編輯 config.toml

將 privileged 改為 true,改好儲存,不用重啟 Container,再執行一次 Pipeline 即可排除

 

接著會遇到第 2 個錯誤  👇

問題 2Cannot connect to the Docker daemon at tcp://docker:2375. Is the docker daemon running?

 

🟢 問題 2 解決辦法:這是因為 TLS 的問題,因為預設會啟用 TLS 進行通訊,可以參考官方解答 Use Docker to build Docker images | GitLab 選擇 啟用 TLS 的設置做法 or 禁用 TLS(官方寫得很詳細了,我就不補充了)。

TLS 的補充說明

docker - Official Image | Docker Hub

dockerd | Docker Documentation

 

dind 疑難排解的手動設置項目,可以參考 註冊 Gitlab Runner(dind 版)章節,在註冊時直接指定,就不用再進去手動調整了。

 

 

註冊 Gitlab Runner(dind 版)

多行註冊的腳本

docker run --rm -it -v /srv/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner register \
--non-interactive \
--executor="docker" \
--docker-image docker:20.10.16 \
--url "https://git.kitty.net/" \
--registration-token "GR4148941iKu8vYTueE4AuzzBi22A" \
--description "kitty_docker_gitrunner" \
--tag-list "kittydemo_docker_runner" \
--run-untagged="true" \
--docker-privileged \
--locked="false";

 

與基礎版相比,多了 --docker-privileged

 

 

dind 版 yml 優化

做完上述異常排除後,就可以正常的產出 docker image 了,不過由於他每次 Job 都會重新啟動一個全新的 Container,所以導致他執行時間非常非常久,所以在 gitlab-ci.yml 還需要做一些優化~

 

在執行 docker build 前,預先下載前一個版本的 docker image

docker pull $CI_REGISTRY_IMAGE

在 docker build 命令中加入 --cache-from

docker build --pull --cache-from $CI_REGISTRY_IMAGE -t "$CI_REGISTRY_IMAGE" .

 

因為 dind 是 create 子容器 & 在子容器中執行 CI Job,所以導致他預設無法使用任何 image layer 的 cache,就必須要 pull 上一個版本的 image ,並指定進 Container 中當 image cache

 

使用後,可以看到 CI Job 就會判定,有完全相同的 image layer 就直接用,不用重新 create layer,如此一來就可以節省不少 build 的時間

不過這時我卻發現,原本應該要節省不少時間的,卻發現實際總時長根本沒有減少???

 

並且仔細觀察之後,發現除了 base 階段 & final 階段的 image layer 有 Using cache 以外,其他東西都沒有 cache 到!!

 

後來我才意識到……因為在 .NET Core 的多階段 Dockerfile 中,只會把 base & pulish & final 階段的東西打包成 image,build 階段完全沒有包進 image 裡面(也不應該包進去,因為太龐大了),也就是說,因為 dind 是使用 image 當作 cache 來源的,所以 image 內沒有的 layer 當然也就無法重複使用

 

這麼一來,執行時間最久的 restore 等內容就完全無法用到 cache,每次都要重新 build layer ……😭

 

這裡如果看不懂我指的 .NET Core 多階段 Dockerfile 是什麼的話,可以參考我的上一篇文章

[Docker] .NET Core 的 Dockerfile 指令詳解 | K. C. - 點部落 (dotblogs.com.tw)

 

 

改用 docker.sock 版運行 Gitlab CI

因為 dind 版無法完整的使用 .NET 的 cache 減少 build 的時間,所以我必須改用 Docker Socket 的運行方式。改綁定 docker.sock 的話,git runner & yaml 都需要做些更改。

 

Gitlab Runner register info 調整

進到 Git Runner Container bash 內的 /etc/gitlab-runner 路徑,編輯 config.toml

改綁定 docker.sock,就可以不用再開 privileged mode 了

還有在 volumes 添加 "/var/run/docker.sock:/var/run/docker.sock"

 

以上設置也可以直接參考 註冊 Gitlab Runner(docker.sock 版)章節,在註冊時直接指定,就不用再進去手動調整了。

 

gitlab-ci.yml 調整

砍掉

services:
    - docker:dind

砍掉 --cache-from

綁定 docker.sock 後,他不會再像 dind 是 create 子層級的容器,他會 create 同層級的容器,因此 image layer cache 可以共用,不需要再指定 image 當 cache

預先 pull image 下來用於 cache 的動作也可以砍掉了

docker pull $CI_REGISTRY_IMAGE

 

執行 Job 後,就可以看到 build 階段的 csproj 那些東西也可以使用 cache 了 🥳

 

拿來跟 dind 版的 Job 進行比較

(為了可以更清楚展示哪些部分會使用 cache,所以這裡以在不動 code 的情況下,直接拿同個 branch 重複 run Pipeline)

 

dind 的第一次執行 & 第二次執行,由於能 cache 的內容不多,所以執行時間並沒有減少,每次執行都花費差不多的時間

 

docker.sock 的第一次執行 & 第二次執行,雖然第一次執行的時間比較久,但是由於執行時間最耗時的 build 階段有 Using cache,所以第二次的執行時間比第一次整整減少了 6 倍~~~

這裡是因為 code 完全沒有任何變更所以才有辦法減少那麼多執行時間,實際上每次的執行效率還是會以你改動的幅度、能使用 cache 的量而有所增減喔!

 

 

註冊 Gitlab Runner(docker.sock 版)

docker run --rm -it -v /srv/gitlab-runner/config:/etc/gitlab-runner gitlab/gitlab-runner register \
--non-interactive \
--executor="docker" \
--docker-image docker:20.10.16 \
--url "https://git.kitty.net/" \
--registration-token "GR4148941iKu8vYTueE4AuzzBi22A" \
--description "kitty_docker_gitrunner" \
--tag-list "kittydemo_docker_runner" \
--run-untagged="true" \
--docker-volumes /var/run/docker.sock:/var/run/docker.sock \
--locked="false";

 

與基礎版相比,多了 --docker-volumes /var/run/docker.sock:/var/run/docker.sock

 

 

最終 Gitlab yml

最後附上我的 docker.sock 版最終 yml 給大家參考

default:
  before_script:
    - echo CI_COMMIT_BRANCH=$CI_COMMIT_BRANCH
    - >
      if [ "$CI_COMMIT_BRANCH" == 'deploy/docker-poc' ]; then
        export ENVIRONMENT="POC"
      elif [ "$CI_COMMIT_BRANCH" == 'deploy/docker-sit' ]; then
        export ENVIRONMENT="SIT"
      elif [ "$CI_COMMIT_BRANCH" == 'deploy/docker-uat' ]; then
        export ENVIRONMENT="UAT"
      elif [ "$CI_COMMIT_BRANCH" == 'deploy/docker-prod' ]; then
        export ENVIRONMENT="PROD"
      else 
        export ENVIRONMENT="POC"
      fi
    - echo ENVIRONMENT=$ENVIRONMENT

stages:
  - build

docker-build:
  stage: build
  tags:
    - kittydemo_docker_runner
  variables:
    CI_PRIVATE_REGISTRY: 'docker.private.net'
    DOCKERFILE_PATH: Demo.Api/Dockerfile
    IMAGE_NAME=$CI_PROJECT_NAME:$ENVIRONMENT
  script:
    - docker build --pull -t "$CI_PRIVATE_REGISTRY/$IMAGE_NAME" -f $DOCKERFILE_PATH .
    - docker push "$CI_PRIVATE_REGISTRY/$IMAGE_NAME"
  # Run this job in a branch where a Dockerfile exists
  rules:
    - if: $CI_COMMIT_BRANCH
      exists:
        - '*/Dockerfile''

 

這篇文章由於是著重在 Docker 執行 CI Job 的部分,所以這裡我只展示 build Job 的內容,其他的 Job 我之後會再獨立一篇文章說明。