GitLab-CI/CD入門實操

以Spring boot項目為例。傳統方式是本地生成jar包,FTP上傳服務器,重啟服務;如果是內網測試服,也可以在服務器上安裝git,在服務器上編譯打包。但這都需要人為干預,於是CI/CD就出現了。

  • CI:Continuous Integration(持續集成)。自動構建和測試每次提交的代碼,以確保所引入的更改符合所有測試、準則和代碼合規性標準。
  • CD:Continuous Delivery(持續交付)和Continuous Deployment(持續部署)。基於CI,前者側重於交付給客戶或質量團隊(比如決定是否對新版本進行壓測),而後手動部署/自動部署,如果是自動部署的話就是持續部署了。

CI/CD的工具有很多,最流行的當屬jenkins。不過以筆者為數不多的經驗來看,作為後起之秀的gitlab更簡單一點,也更靈活,不會像jenkins那樣笨重。當然,兩者的概念都是挺多的,沒有師父,光靠自己入門都不容易。

GitLab-CI/CD流程示例

從左往右看,首先研發人員完成需求提交代碼到 GitLab。GitLab 觸發一次 Build,構建好服務,然後開始跑單元測試、集成測試。等待測試結果通過後,再由負責該項目的同事進行 CodeReview,灰度發佈,正式部署到線上。

概念

本文基於GitLab 13.7版本

Pipline

Pipelines comprise:

  • Jobs, which define what to do. For example, jobs that compile or test code.
  • Stages, which define when to run the jobs. For example, stages that run tests after stages that compile the code.

Jobs are executed by runners. Multiple jobs in the same stage are executed in parallel, if there are enough concurrent runners.

Stages

  • Manage:項目周期或團隊周期的各項數據統計分析。主要是各環節耗時統計,比如對於典型的Issue(提出問題)->Plan(列入計劃)->Code(編碼)->Test(測試)->Package(打包)流程,每個環節的耗時決定了整體問題處理的響應速度。
  • Plan:藉助諸多工具進行有效的項目管理。
  • Create:代碼管理。
  • Verify:代碼質量分析、代碼合併(持續集成)、單元測試等。
  • Package:將代碼打包,並作為依賴庫對外提供。
  • Secure(ULTIMATE版提供):檢查應用程序是否存在可能導致未經授權訪問、數據泄漏或拒絕服務的安全漏洞。GitLab可以對應用程序的代碼執行靜態和動態測試,查找已知的缺陷並在合併請求中報告它們。然後可以在合併之前修復缺陷。安全團隊可以使用儀錶板獲取項目和組的高級視圖,並在需要時啟動修正過程。
  • Release:持續交付。
  • Configure:配置[文件]參與DevOps各環節。
  • Monitor:GitLab收集並顯示已部署應用程序的性能指標,以便您可以立即知道代碼更改如何影響生產環境。
  • Defend:若干用於服務安全防禦的中間件。

上述包含了GitLab-DevOps整個流程的所有環節,CI/CD只是其中的一部分。

GitLab Runner

可以安裝在任意機子上,通過它可以[在一台機子上]註冊多個runner實例到gitlab服務器。每個runner用於執行一個或多個具體任務(如build、test)。

runner有以下三類,可用範圍從大到小

  • Shared runners are available to all groups and projects in a GitLab instance.
  • Group runners are available to all projects and subgroups in a group.
  • Specific runners are associated with specific projects. Typically, specific runners are used for one project at a time.

我們可以直接安裝GitLab Runner到宿主機,也可以使用docker方式安裝。注意這兩種方式會影響到後續.gitlab-ci.yml中對pipline的定義。比如要編譯maven項目,如果executor設為shell,那麼若宿主機中安裝有mvn命令,前者可以在scripts中直接使用mvn,而後者並不能,只能通過定義Dockerfile,在其中定義搭建mvn環境到編譯代碼的整個流程。

採用docker方式安裝的話,可以參考Docker搭建自己的Gitlab CI Runner

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

註冊runner實例

docker exec -it inkscreen-api-runner gitlab-runner register

會讓我們填一系列配置項,如下:

Enter the GitLab instance URL (for example, //gitlab.com/):
//192.168.1.26:9980/
Enter the registration token:
cJMXGJWx7qx9AmpSc6ee
Enter a description for the runner:
[a0debaaf80a9]: runner for InkScreen-API project
Enter tags for the runner (comma-separated):
InkScreenAPI
Registering runner... succeeded                     runner=cJMXGJWx
Enter an executor: docker-ssh, shell, docker-ssh+machine, kubernetes, custom, parallels, ssh, virtualbox, docker+machine, docker:
docker
Enter the default Docker image (for example, ruby:2.6):
maven:3-jdk-8

完事後,我們在gitlab->xxxProjct中就能找到該runner:

接下來,就可以定義項目構建流程了。項目的構建流程是由項目根目錄的.gitlab-ci.yml文件控制的。當然了,一個pipeline可以涉及到多個runner。

.gitlab-ci.yml

定義一個pipline,以下為示例

variables:
  DOCKER_TLS_CERTDIR: "/certs"

# stage也可以自定義
stages:
  - build jar
#  - test
  - build and run image

#job's name 可以隨意取
buildJar:
  stage: build jar
  variables:
    # 若要使cache生效,須指定-Dmaven.repo.local
    MAVEN_OPTS: "-Dmaven.repo.local=.m2"
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - .m2
  only:
    - dev
  script:
#    package 已包含 test 步驟,所以流程中不需要另外配置test job
    - mvn clean package
  tags:
    - inkscreen_api
  artifacts:
    paths:
      - target/admin.jar
    expire_in: 3600 seconds

deploy:
  stage: build and run image
  image: docker:stable
  services:
    - docker:dind
  only:
    - dev
  variables:
    IMAGE_NAME: newton/inkscreen-api:$CI_COMMIT_REF_NAME
    PORT: 38082
  script:
    - docker build --build-arg JAR_PATH=target/admin.jar -t $IMAGE_NAME .
    - docker run -p $PORT:$PORT  -d --name inkscreen-$CI_COMMIT_REF_NAME --env spring.redis.host=myredis $IMAGE_NAME
  tags:
    - inkscreen_api

cache

cache常用在dacker-based job之間傳遞文件。比如項目依賴的公共jar包,jobA辛辛苦苦從網上down了下來,結果運行完了,jobA所在容器也跟着被移除,自然裏面的所有文件都不存在了。後續其它job用到相同的jar包還要重新下載。同樣的,pipline多次執行,jobA自己每次也要重新下載。

為了解決這個問題,gitlab-ci採用了cache的方式。指定文件/目錄,每次job結束前將其打包,放到/etc/gitlab-runner/config.toml中對應的[runners.docker][volumes]指定的卷內,其它job(包括自己)運行前,對應的cache都會被加載並解壓到容器內。

artifacts

artifacts是job生成的中間產物,會以壓縮包(.zip)的形式生成。它會自動上傳到gitlab服務器,the artifacts will be downloaded and extracted in the context of later stages。所以它和cache很像,但是設計它們的初衷是不同的。

Don’t use caching for passing artifacts between stages, as it is designed to store runtime dependencies needed to compile the project:

  • cache: For storing project dependencies

    Caches are used to speed up runs of a given job in subsequent pipelines, by storing downloaded dependencies so that they don’t have to be fetched from the internet again (like npm packages, Go vendor packages, etc.) While the cache could be configured to pass intermediate build results between stages, this should be done with artifacts instead.

  • artifacts: Use for stage results that will be passed between stages.

    Artifacts are files generated by a job which are stored and uploaded, and can then be fetched and used by jobs in later stages of the same pipeline. In other words, you can’t create an artifact in job-A in stage-1, and then use this artifact in job-B in stage-1. This data will not be available in different pipelines, but is available to be downloaded from the UI.

The name artifacts sounds like it’s only useful outside of the job, like for downloading a final image, but artifacts are also available in later stages within a pipeline.

另外,同樣key的cache會被覆蓋,而artifacts一旦生成就固定了,當然我們可以設置expire_in,過期刪除之。

可參看各類語言/平台的.gitlab-ci.yml模板

實戰

我們定義一個最簡單的pipline:第一步編譯生成jar包,第二步將jar包導入docker鏡像並運行,在某些環節還需加入代碼review。因為最後我們會以docker容器運行jar包,所以這裡不建議docker-based runner/executor的形式,因為該形式導致Docker-in-Docker的場景,帶來可能的一些麻煩且難以解決的問題(比如內嵌容器如何關聯外部服務以及對外提供服務)。所以我們直接宿主機安裝GitLab Runner。

如果一定要以docker-based形式,那麼可參看使用GitLab CI和Docker自動部署SpringBoot應用。在該文中,並沒有在runner所在宿主機中運行容器,而是將生成的鏡像發佈到鏡像倉庫,再登錄目標服務器拉取鏡像運行,所以不存在Docker-in-Docker的麻煩事。

安裝&註冊[GitLab ]Runner

# 1.Add the official GitLab repository
curl -L "//packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
# 2.Install the latest version of GitLab Runner
export GITLAB_RUNNER_DISABLE_SKEL=true; sudo -E yum install gitlab-runner

註冊runner

sudo gitlab-runner register -n \
  --url //192.168.1.26:9980/ \
  --registration-token cJMXGJWx7qx9AmpSc6ee \
  --executor shell \
  --tag-list "inkscreen_hostrunner" \
  --description "Host Runner for InkScreen"

Add the gitlab-runner user to the docker group:

sudo usermod -aG docker gitlab-runner

.gitlab-ci.yml

stages:
  - build jar
  - build and run image

#job's name 可以隨意取
buildJar:
  stage: build jar
  variables:
    # 默認是clone,改為fetch加快拉取速度(若本地無則會自動clone)
    GIT_STRATEGY: fetch
  only:
    - dev
  script:
    - >
      docker run -d --rm --name justforpackage-$CI_COMMIT_REF_NAME
      -v "$(pwd)":/build/inkscreen
      -v /inkscreen/maven/m2:/root/.m2
      -w /build/inkscreen
      maven:3-jdk-8 mvn clean package

    - sleep 60
  tags:
    - inkscreen_hostrunner
  artifacts:
    paths:
      - louwen-admin/target/louwen-admin.jar
    expire_in: 3600 seconds

testDeploy:
  stage: build and run image
  only:
    - dev
  variables:
    # 不拉取代碼
    GIT_STRATEGY: none
    IMAGE_NAME: louwen/inkscreen-api:$CI_COMMIT_REF_NAME
    PORT: 38082
  before_script:
    # 移除舊容器和鏡像。這裡為什麼要寫成一行,下面有講
    - if [ docker ps | grep inkscreen-$CI_COMMIT_REF_NAME ]; then docker stop inkscreen-$CI_COMMIT_REF_NAME; docker rm inkscreen-$CI_COMMIT_REF_NAME; docker rmi $IMAGE_NAME; fi
  script:
    - docker build --build-arg JAR_PATH=louwen-admin/target/louwen-admin.jar -t $IMAGE_NAME .
    - >
      docker run -d --name inkscreen-$CI_COMMIT_REF_NAME
      -p $PORT:$PORT
      --network my_bridge --env spring.redis.host=myredis
      -v /inkscreen/inkscreen-api/logs/:/logs/
      -v /inkscreen/inkscreen-api/louwen-admin/src/main/resources/:/configs/
      $IMAGE_NAME
  tags:
    - inkscreen_hostrunner

注意build jar環節我們sleep了60秒,是因為docker run並不會等待內部腳本執行完,而是啟動後就直接返回了,此時jar包尚未生成,所以此處阻塞一段時間等待打包結束。正常應該寫一段腳本循環判斷jar包是否已生成,若生成或超時則跳出循環,此處作為演示簡單sleep。

在testDeploy任務中,before_script被我寫成了一行,最初版本是:

  before_script:
    # 若未找到記錄,則該條命令會返回1,gitlab就直接報錯返回了ERROR: Job failed: exit status 1
    - docker ps | grep inkscreen-$CI_COMMIT_REF_NAME
    - >
      if [ $? -eq 0 ]
      then
        docker stop inkscreen-$CI_COMMIT_REF_NAME
        docker rm inkscreen-$CI_COMMIT_REF_NAME
        docker rmi $IMAGE_NAME
      fi

後改為

  before_script:
    # 將檢測語句直接作為條件內置,解決了上面的問題
    - >
      if       
      docker ps | grep inkscreen-$CI_COMMIT_REF_NAME
      then
        docker stop inkscreen-$CI_COMMIT_REF_NAME
        docker rm inkscreen-$CI_COMMIT_REF_NAME
        docker rmi $IMAGE_NAME      
      fi

報錯syntax error near unexpected token 'fi',估計是換行/回車格式的原因。上述兩個問題都可以通過單獨建.sh文件的方式解決,我這裡簡單地將所有語句排成一行。

Dockerfile

生成鏡像自然少不了Dockerfile

FROM openjdk:8-jdk-oracle
MAINTAINER louwen

# 外部傳入,主程序路徑
ARG JAR_PATH
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8

COPY $JAR_PATH /app.jar
EXPOSE 38082
ENTRYPOINT ["java","-jar","/app.jar"]

題外話,其實我們完全可以將build jar環節也放在Dockerfile中,如下

#
# build jar stage
#
FROM maven:3-jdk-8 AS MAVEN_BUILD

COPY pom.xml /build/
COPY src /build/src/
WORKDIR /build/
RUN mvn clean package

#
FROM openjdk:8-jdk-oracle
MAINTAINER louwen
COPY --from=MAVEN_BUILD /build/target/*.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

代碼規範

目前較流行的代碼檢測工具是SonarQube,不過其社區版本對同一個代碼倉庫無法區分不同分支,從而實現按代碼的不同分支顯示對應分支的掃描結果。這裡我們使用Gitlab-CI的Code Quality stage,其使用的是Codeclimate,它是為代碼質量分析平台提供的一個命令行接口工具,通過它可以在本機 Docker 容器中對要分析的代碼執行質量分析,並生成分析報告。我們熟知常用的代碼質量檢測工具例如 SonarQube、CheckStyle 等等,而 Codeclimate 接入了這些工具,而且支持我們自定義檢測工具。

按照官方說法,使用Code Quality需要基於docker-based runner/executor,所以我們另外使用docker方式安裝GitLab-Runner並註冊一個runner(參考上述概念小節),executor選擇docker。

include:
  - template: Code-Quality.gitlab-ci.yml

# 以下配置參考網上一些資料,據說是官方示例,然而我沒有在官方文檔找到
code_quality:
  image: docker:stable
  variables:
    DOCKER_DRIVER: overlay2
    # gitlab 13.6及之後版本支持
    REPORT_FORMAT: html
  allow_failure: true
  services:
    - docker:dind
  script:
  # 鏡像版本號格式參看 //gitlab.com/gitlab-org/ci-cd/codequality/-/tree/master#versioning-and-release-cycle
#    - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')
    - docker run
      --net=host
      --env SOURCE_CODE="$PWD"
      --volume "$PWD":/code
      --volume /var/run/docker.sock:/var/run/docker.sock
      "registry.gitlab.com/gitlab-org/security-products/codequality:${VERSION:-latest}" /code
  artifacts:
    paths: [ gl-code-quality-report.html ]
  tags:
    - InkScreenAPI

執行的時候可能會卡在拉取鏡像環節。手動docker pull registry.gitlab.com/gitlab-org/security-products/codequality:latest,發現各種超時。我開個阿里雲香港ECS的搶佔式實例(便宜)然後docker pull | save | load將鏡像文件遷移到公司測試服,還是報Unable to find image 'registry.gitlab.com/gitlab-org/security-products/codequality:latest' locally,不知如何將host中的鏡像映射到docker:stable中。看來還是得kexue上網。

理論上,需要專人在合適的時候對提交的代碼進行質量把關,一般這工作可以放在Merge Request下進行。Merge Request的工作流程可以參看在團隊中使用GitLab中的Merge Request工作模式

FAQ

  1. dial tcp: lookup docker on 192.168.1.1:53: no such host錯誤。
    This error occurs with docker-based gitlab runners such as the one we』re that are configured using a docker executor. The error message means that the inner docker container doesn』t have a connection to the host docker daemon.
    解決:將/etc/gitlab-runner/config.toml中對應的[runners.docker]節點設置privileged = true,增加卷映射volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]或在.gitlab-ci.yml的job定義中增加services: - docker:dind

  2. Cannot connect to the Docker daemon at tcp://docker:2375. Is the docker daemon running?錯誤
    解決:增加卷映射volumes = ["/certs/client", "/cache"],然後在.gitlab-ci.yml中增加變量DOCKER_TLS_CERTDIR: "/certs"

  3. 拉取代碼時提示warning: failed to remove xxxx: Permission denied
    簡單粗暴地編輯/etc/passwd,將gitlab-runner賬號對應的uid:gid改為0:0(和root一樣)。

  4. Code Quality提示docker: Error response from daemon: Head //registry.gitlab.com/v2/gitlab-org/security-products/codequality/manifests/13-7-stable: Get //gitlab.com/jwt/auth?scope=repository%3Agitlab-org%2Fsecurity-products%2Fcodequality%3Apull&service=container_registry: dial tcp [2606:4700:90:0:f22e:fbec:5bed:a9b9]:443: connect: cannot assign requested address.
    在scripts->docker run增加參數–net=host

其它

發件郵箱配置

在pipline流程執行過程中,我們希望有任何風吹草動都能及時收到消息,郵件就是一個比較好的提醒方式。

vi /etc/gitlab/gitlab.rb

### GitLab email server settings
###! Docs: //docs.gitlab.com/omnibus/settings/smtp.html
###! **Use smtp instead of sendmail/postfix.**

gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.exmail.qq.com"
gitlab_rails['smtp_port'] = 465
gitlab_rails['smtp_user_name'] = "[email protected]"
gitlab_rails['smtp_password'] = "xxxxxxxx"
gitlab_rails['smtp_domain'] = "exmail.qq.com"
gitlab_rails['smtp_authentication'] = "login"
gitlab_rails['smtp_enable_starttls_auto'] = true
gitlab_rails['smtp_tls'] = true

### Email Settings

gitlab_rails['gitlab_email_enabled'] = true

##! If your SMTP server does not like the default 'From: [email protected]'
##! can change the 'From' with this setting.
##! 要與上面的 smtp_user_name 保持一致
gitlab_rails['gitlab_email_from'] = '[email protected]'
# gitlab_rails['gitlab_email_display_name'] = 'Example'
# gitlab_rails['gitlab_email_reply_to'] = '[email protected]'
# gitlab_rails['gitlab_email_subject_suffix'] = ''
# gitlab_rails['gitlab_email_smime_enabled'] = false
# gitlab_rails['gitlab_email_smime_key_file'] = '/etc/gitlab/ssl/gitlab_smime.key'
# gitlab_rails['gitlab_email_smime_cert_file'] = '/etc/gitlab/ssl/gitlab_smime.crt'
# gitlab_rails['gitlab_email_smime_ca_certs_file'] = '/etc/gitlab/ssl/gitlab_smime_cas.crt'

gitlab-ctl reconfigure使配置生效
測試

gitlab-rails console
irb(main):003:0> Notify.test_email('[email protected]', 'Message Subject', 'Message Body').deliver_now

登錄[email protected]查看受否收到信件。


jenkins + gitlab

如果使用jenkins作為CI/CD工具,代碼由gitlab託管,那麼它們之間的交互需要兩個token:

  1. api token,用於jenkins調用gitlab api使用
  2. ssh密鑰對,jenkins拉取代碼使用(當然我們也可以使用用戶名/密碼方式拉取)

mvn package、install、deploy都幹了什麼

  • mvn clean package依次執行了clean、resources、compile、testResources、testCompile、test、jar(打包)等7個階段。
  • mvn clean install依次執行了clean、resources、compile、testResources、testCompile、test、jar(打包)、install等8個階段。
  • mvn clean deploy依次執行了clean、resources、compile、testResources、testCompile、test、jar(打包)、install、deploy等9個階段。

由上可知:

  • package命令完成了項目編譯、單元測試、打包功能,但沒有把打好的可執行jar包(war包或其它形式的包)布署到本地maven倉庫和遠程maven私服倉庫
  • install命令完成了項目編譯、單元測試、打包功能,同時把打好的可執行jar包(war包或其它形式的包)布署到本地maven倉庫,但沒有布署到遠程maven私服倉庫
  • deploy命令完成了項目編譯、單元測試、打包功能,同時把打好的可執行jar包(war包或其它形式的包)布署到本地maven倉庫和遠程maven私服倉庫

alpine

Alpine Linux 是一個社區開發的面向安全應用的輕量級Linux發行版。很多鏡像都會專門基於Alpine構建,大小會小很多。比如:

  • gitlab/gitlab-runner:latest based on Ubuntu.
  • gitlab/gitlab-runner:alpine based on Alpine with much a smaller footprint (~160/350 MB Ubuntu vs ~45/130 MB Alpine compressed/decompressed).

修改GitLab-ce域名

剛部署好的GitLab新建的項目ssh地址一般是個短鏈接如git@AKDJF3ld:xxx,有時候會不太好使,可以通過配置文件的修改,指向域名。

vim /opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
# host: 192.168.xx.xx
# port: xxxx
gitlab-ctl restart

參考資料

當談到 GitLab CI 的時候,我們該聊些什麼(上篇)
什麼是devops,基於Gitlab從零開始搭建自己的持續集成流水線(Pipeline)
持續集成之.gitlab-ci.yml篇
Building Docker images with GitLab CI/CD
自動化 DevOps 使用 Codeclimate 執行代碼質量分析
GitLab CI/CD
基於 Gitlab 的 Code Review 最佳實踐