[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 Socket 與 Docker in Docker 的差別。

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

而我這裡要採用的做法是 Docker Socket(又名 docker.sock),Docker Socket 跟 Docker 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 mounts 與 Docker 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 時,發現出現以下錯誤  👇

⛔ 問題 1:dial 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 個錯誤  👇

⛔ 問題 2:Cannot 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 之後有機會的話我再獨立一篇文章說明 ~ 😄