在 GitLab CI/CD 透過 SSH 控制伺服器進行部署

| | 0 Comments| 13:55|
Categories:

這篇算是之前《GitLab CI/CD 的「環境」管理架構》的延續。當時在介紹的時候,主要是針對 GitLab 的「環境」這個架構在做介紹,但是在 CI/CD 腳本的部分,實際上都沒有真的實作,有做的事情就是透過 echo 輸出資訊而已。

而真的想要實作自動部署的時候,該怎麼做呢?這邊就針對 Heresy 這邊的情境,來稍微整理一下、留個紀錄。

首先,Heresy 這邊的網站算是相對簡單的:

  • 一台 Ubuntu 伺服器、透過 Docker 跑一個包含所有東西的 docker image

    (假設是 production.example.com)
  • Docker image 透過 CI/CD 腳本建置後、放在 GitLab 的 Docker registry 裡面

而由於 GitLab CI/CD 的執行者主要是 Docker 版的 GitLab Runner、所以這邊的基本概念,就是 GitLab Runner 在收到工作後,要透過 SSH 連線到目標伺服器來執行部署的工作了。

這部分 GitLab 官方其實就有一篇《Using SSH keys with GitLab CI/CD》在說明這件事,這篇文章很大一部分也是參考它的。

接下來,這邊稍微整理一下 Heresy 這邊自己的操作,大致上是:

  • 建立登入用的 SSH 金鑰
    • 要把公鑰放到伺服器上、並取得公鑰資訊
    • 私鑰會透過 GitLab CI/CD 變數儲存
  • 伺服器端的操作
    • 建立部署專用的帳號
    • 針對部署專用的帳號設定必要權限
  • GitLab 端操作
    • 建立環境
    • 針對環境設定 CI/CD 變數
  • CI/CD 腳本
    • 設定部署工作

接下來就來看細節了。


建立 SSH 金鑰對

這邊要先找一台 Linux 的機器、透過下面的指令產生一組 SSH 的金鑰對:

ssh-keygen -t ed25519 -C "gitlab-deploy" -f deploy_key -N ""

這個指令會產生一組沒有密碼的公私鑰,其中私鑰是 deploy_key、公鑰是 deploy_key.pub

之後公鑰要放到伺服器上,私鑰則是要設定成 GitLab 專案的 CI/CD 變數、讓 runner 在取得工作的時候有私鑰可以用來登入。


建立部署專用帳號

建立部署專用的帳號,主要是方便追蹤、同時也更容易透過帳號的權限設定限制能做的事情,在帳號外洩的時候,可以在一定的程度上控制受影響的程度。

這邊是先透過下面的指令,建立一個不能用密碼登入的帳號「gitlab-deploy」:

useradd -m -s /bin/bash gitlab-deploy 
passwd -l gitlab-deploy

這個帳號的密碼會被鎖住、只能用 SSH 金鑰登入、降低被入侵的機會。


接下來,要幫這個帳號建立 .ssh 的資料夾、並將前面建立的公鑰放進來。

建立資料夾的指令:

mkdir -p /home/gitlab-deploy/.ssh

然後建立一個檔案 /home/gitlab-deploy/.ssh/authorized_keys,把前面 deploy_key.pub 的內容貼進來。

完成後要設定檔案和資料夾的權限:

chown -R gitlab-deploy:gitlab-deploy /home/gitlab-deploy/.ssh 
chmod 700 /home/gitlab-deploy/.ssh 
chmod 600 /home/gitlab-deploy/.ssh/authorized_keys

都設定完了之後,建議先在別台電腦測試看看能不能正確地使用私鑰來登入,指令是:

ssh -i ./deploy_key gitlab-deploy@production.example.com

這邊的 ./deploy_key 就是前面建立的私鑰。


由於之後會需要讓這個帳號進行 docker 的操作,所以必須要授予他特殊的 sudo 能力。

這邊是要建立 /etc/sudoers.d/gitlab-deploy 這個檔案,內容基本上如下:

gitlab-deploy ALL=(root) NOPASSWD: /usr/bin/docker pull *, /usr/bin/docker run *, /usr/bin/docker login *, /usr/bin/docker stop *, /usr/bin/docker rm *, /usr/bin/docker rmi *

這邊允許的指令列表需要根據要做的事情來調整,基本上是越少越好。

(這邊其實已經允許了他去執行、停止、刪除所有 docker 容器、算是有點太多了?)

雖然說直接給他完整的 root 或 docker 權限也是一種方法,但是這也會增加帳號被盜用時的風險。

修改完成後,一樣要修改檔案權限:

chmod 440 /etc/sudoers.d/gitlab-deploy

到這邊就算是完成要在伺服器上的動作了。


GitLab 網頁端的設定

這邊首先是要在 GitLab 的專案裡面、建立要使用的環境。

這部分在前面介紹環境的文章就有說明了,基本上就是點選專案左邊欄的 Operate、Environments,點選「New environment」來輸入環境的必要資訊。

這邊先把環境名稱取名為「production」,只要名稱正確、其他的都沒什麼關係。


接下來,則是要設定必要的 CI/CD 環境變數。

要新增變數的介面,是在 Settings、CI/CD、Variables 下;找到「CI/CD Variables」的區域後,點選右邊的「Add variable」來新增。

新增的時候要設定的欄位包括:

  • Type:變數類型
  • Environments:對應的環境,這邊會是 production
  • Visibility:在 CI/CD 執行階段是否可以看到變數的內容
  • Flags:主要是看要不要勾選「Protect variable」
  • Key:變數名稱
  • Value:變數值

單就 SSH 連線來說,這邊會需要四個變數:

  • SSH_HOST:要連線的伺服器
  • SSH_KNOWN_HOSTS:要連線的伺服器的公鑰指紋
  • SSH_ACCOUNT:用來連線到伺服器的帳號
  • SSH_PRIVATE_KEY:用來做登入驗證的私鑰

這四個變數裡面,SSH_HOSTSSH_ACCOUNT 就是一般型別的變數,數值分別是:

  • SSH_HOSTproduction.example.com
  • SSH_ACCOUNTgitlab-deploy

至於「Visibility」要不要設定成 mask 或 hidden,由於性質上比較不屬於機密性資料、所以個人是覺得見仁見智。


SSH_PRIVATE_KEYSSH_KNOWN_HOSTS 的資訊由於是多行的文字,所以這邊在新增變數的時候要把「Type」切換成「File」;然後由於裡面的文字應該都會有 GitLab 無法用來 Mask 的字元,所以「Visibility」就只能設定成「Visible」了。

內容的部分,以 SSH_PRIVATE_KEY 來說,就是找個文字檔編輯器開啟前面產生的 deploy_key 這個檔案,然後把內容都貼到「Value」裡面就可以了。

要注意最後的換行要留著,否則可能會有 Error loading key "SSH_PRIVATE_KEY": error in libcrypto 這類的錯誤。

SSH_KNOWN_HOSTS 的部分,則是要透過下面的指令來取得公鑰的指紋:

ssh-keyscan production.example.com

這邊應該會列出幾行目標伺服器上的公鑰資訊,要把他們全部複製下來、貼到「Value」裡面。

這個變數主要是用來防止中間人攻擊導致連到錯誤的主機;如果不設定這個變數的話,之後要使用 SSH 登入的時候會卡在要求使用者確認連線主機而無法完成工作。

而檔案類型的變數在 GitLab CI/CD 環境中,會把變數的值寫入到個別檔案裡面;透過變數名稱去存取時、會拿到的是檔案的路徑,所以可以拿來做進一步的檔案操作(例如複製、搬移)。


至於這四個變數要不要設定成「Protected variable」,就請參考之前的說明自己判斷吧;如果是以正式環境來說,最好是設定成被保護的狀態。

在設定成 protect variables 後,這些變數將會只能在受保護的分支(預設只有主線)和受保護的 tag(預設沒有、要自己到專案的 Settings、Repository 裡的「Protected tags」加規則)可以存取到這些變數;理論上這邊的程式會是經過審查過的,所以應該不會有試著去外流變數的可能性。(當然前提是有認真 review)

而這邊是先假設只有「production」一個環境,如果有多個環境的話,就是要重複上面的動作;重點就是要針對每個環境各自設定同樣名稱的變數。


CI/CD 腳本:SSH 連線

前面都設定完了,接下來就是看要怎麼在 GitLab Runner 透過 SSH 私鑰連線到伺服器了。

這邊由於 runner 的執行器是使用 docker,所以每次起來都是乾淨的環境,因此要透過 SSH Key 來連線的話、會需要有一些前置作業。

以 Heresy 這邊來說,後來是建立了一個「.ssh-connect」的範本:

.ssh-connect:
  image: ubuntu:24.04
  tags: [docker, linux]
  before_script:
  - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
  - eval $(ssh-agent -s)
  - chmod 400 "$SSH_PRIVATE_KEY"
  - ssh-add "$SSH_PRIVATE_KEY"
  - mkdir -p ~/.ssh
  - chmod 700 ~/.ssh
  - cp "$SSH_KNOWN_HOSTS" ~/.ssh/known_hosts
  - chmod 644 ~/.ssh/known_hosts

這邊是使用 ubuntu:24.04 作為 Docker 執行的映象檔,如果有自己的偏好也可以自己調整;tags 的部分是因為 Heresy 這邊有包含 Windows 在內的多種 Runner、所以需要透過 tags 來做限制。

而這個範本主要是在定義 before_script、也就是正式執行腳本工作前要先做的事情;這邊指令的內容依序是:

  • 確認是否有有安裝 ssh-agent、沒有的話就透過 apt-get 安裝 ssh-client
  • 執行 ssh-agent
  • 讀取 透過 CI/CD 變數儲存的 SSH 私鑰($SSH_PRIVATE_KEY 是檔案路徑)
    • 修改私鑰的檔案權限
    • 透過 ssh-add 加入私鑰檔案
  • 建立 known hosts 資訊
    • 建立 ~/.ssh 資料夾
    • 將資料夾權限改成 700
    • $SSH_KNOWN_HOSTS 複製成 ~/.ssh/known_hosts
    • 修改 ~/.ssh/known_hosts 的檔案權限

透過這樣的 before_script 腳本,之後在 script 區段的正式腳本就可以直接透過 ssh 連線到伺服器了。


要透過 CI/CD 測試連線的話,測試用的工作可以寫成:

test:
   extends: [.ssh-connect]
   environment:
     name: production
   script:
   - |
     ssh -t -T "$SSH_ACCOUNT@$SSH_HOST" << EOF
       ls
     EOF

這邊基本上就是繼承前面定義的 .ssh-connect 這個範本,然後指定使用 production 這個環境。

然後這邊的 script 基本上就是透過 ssh 連線到 SSH_HOST,執行 ls 這個指令來列出主機上的檔案;就算指令比較複雜,只要寫在兩個 EOF 之間就可以了。

而這邊的 script 能這麼簡單,主要是因為他會先去執行 .ssh-connectbefore_script 來完成登入前的準備,所以要注意這邊不能另外定義 before_script 來覆蓋掉這些準備動作。


CI/CD 腳本:透過 SSH 執行工作

有了 .ssh-connect 這個範本,整個用來建置 Docker Image 和部署的腳本,可以變成下面的樣子:

stages:
- build
- deploy
 
variables:
  APP_NAME: test_app
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE/$APP_NAME:$CI_COMMIT_SHORT_SHA .ssh-connect:
  image: ubuntu:24.04
  before_script:
  - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
  - eval $(ssh-agent -s)
  - chmod 400 "$SSH_PRIVATE_KEY"
  - ssh-add "$SSH_PRIVATE_KEY"
  - mkdir -p ~/.ssh
- chmod 700 ~/.ssh
  - cp "$SSH_KNOWN_HOSTS" ~/.ssh/known_hosts
  - chmod 644 ~/.ssh/known_hosts build:
  stage: build
  image: docker:latest
  services:
  - docker:dind
  before_script:
  - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
  script:
  - docker build -t $DOCKER_IMAGE .
  - docker push $DOCKER_IMAGE deploy:
  extends: [.ssh-connect]
  stage: deploy
  environment:
    name: production
    on_stop: stop
    url: http://$SSH_HOST
  script:
  - |
    ssh -t -T "$SSH_ACCOUNT@$SSH_HOST" << EOF
      echo "$CI_REGISTRY_PASSWORD" | sudo docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
      sudo docker pull "$DOCKER_IMAGE"
      sudo docker stop "$APP_NAME" || true
      sudo docker rm "$APP_NAME" || true
      sudo docker run -d --restart=always -p 80:80 --name "$APP_NAME" "$DOCKER_IMAGE"
    EOF stop:
  extends: [.ssh-connect]
  stage: deploy
  environment:
    name: production
    action: stop
  needs: [deploy]
  when: manual
  script:
  - |
    ssh -t -T "$SSH_ACCOUNT@$SSH_HOST" << EOF
      sudo docker stop "$APP_NAME" || true
      sudo docker rm "$APP_NAME" || true
      sudo docker rmi "$DOCKER_IMAGE" || true
    EOF

這邊有先定義兩個變數、分別是:

  • APP_NAME:代表建置出來的映像檔名稱、以及之後的容器名稱
  • DOCKER_IMAGE:組合好的 Docker 映像檔 URL

工作的部分有三個:

  • build:建置 Docker image、並推送到 GitLab 提供的 Container registry
  • deploy:部署到 production 環境、在 SSH_HOST 上面拉取新的 Docker 映像檔並執行
  • stop:停止 production 環境執行的容器、並刪除映像檔

這邊要注意的是:

  • 所有的 docker 指令都要加上 sudo 才行,包括 docker login 也要。
  • stop 工作要記得設定成 manual,否則會自動執行
  • docker stopdocker rm 後面的 || true 是為了避免容器不存在的時候讓腳本失敗

如果是只有一個 production 環境的話,這樣的腳本應該是可以用的了~


多環境的版本

這邊是單一環境的狀況,如果是有多個環境要部署的話,也是可以透過把 deploystop 範本化的概念、來避免重複撰寫重複的腳本。

這邊改成範本形式的 deploystop 是:

.deploy:
  extends: [.ssh-connect]
  stage: deploy
  script:
  - |
    ssh -t -T "$SSH_ACCOUNT@$SSH_HOST" << EOF
      echo "$CI_REGISTRY_PASSWORD" | sudo docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
      sudo docker pull "$DOCKER_IMAGE"
      sudo docker stop "$APP_NAME" || true
      sudo docker rm "$APP_NAME" || true
      sudo docker run -d --restart=always -p 80:80 --name "$APP_NAME" "$DOCKER_IMAGE"
    EOF .stop:
  extends: [.ssh-connect]
  stage: deploy
  when: manual
  script:
  - |
    ssh -t -T "$SSH_ACCOUNT@$SSH_HOST" << EOF
      sudo docker stop "$APP_NAME" || true
      sudo docker rm "$APP_NAME" || true
      sudo docker rmi "$DOCKER_IMAGE" || true
    EOF

基本上就是把 environment 的部分都拿掉了。

之後如果有 production 和 staging 兩個環境的話,就可以寫成:

.production-env:
  rules:
  - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
    when: manual
  - when: never production-deploy:
  environment:
    name: production
    on_stop: production-stop
    url: http://$SSH_HOST
  extends: [.deploy,.production-env] production-stop:
  environment:
    name: production
    action: stop
  extends: [.stop, .production-env]
  needs: [production-deploy] staging-deploy:
  environment:
    name: staging
    on_stop: staging-stop
    url: http://$SSH_HOST
  extends: [.deploy] staging-stop:
  environment:
    name: staging
    action: stop
  extends: [.stop]
  needs: [staging-deploy]

這邊的 .production-env 是定義 production 環境的工作只有在建立 v1.1.1 這種 tag 的時候才會出現,在其他分支或其他狀況的 pipeline 都不會建立出 production 環境的工作。

這通常也要到專案的 settings、Repository 裡的「Protected tags」來建立被保護的 tag 的規則才能搭配 protected variables 使用。以這邊的狀況,就是要建立一個「v*.*.*」的 wildcard。

而之後部署的工作就是 production-deploystaging-deploy、停止的工作則是 部署的工作就是 production-stopstaging-stop 了;這四個工作基本上都是靠 extends 來使用前面定義的範本,主要的要設定的,就是 environment 的部分了。

而如果不同的環境還有不同的東西的話,除了設定在專案的 CI/CD 變數,也可以在這邊透過 variables 來定義。


大致上就先這樣了。

實際上,這邊的 Docker image 的 tag 會是使用 commit 的 SHA-1,雖然可以用來區隔版本,但是基本上不是給人來看的;比較好的方法應該是至少要在建立 v1.1.1 這種 tag 的時候、改用這種可以理解的名稱會比較好。

另外,其實還有一種部署方法,是在要部署的主機直接裝 gitlab runner、讓他直接接部署的工作;透過設定成專案專用 runner、並搭配 Protected Runner 的設定,應該也是可以保護到一定的程度?不過這邊還沒真的這樣玩過就是了。

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *