這篇算是之前《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_HOST 和 SSH_ACCOUNT 就是一般型別的變數,數值分別是:
SSH_HOST:production.example.comSSH_ACCOUNT:gitlab-deploy
至於「Visibility」要不要設定成 mask 或 hidden,由於性質上比較不屬於機密性資料、所以個人是覺得見仁見智。
SSH_PRIVATE_KEY 和 SSH_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-connect 的 before_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 registrydeploy:部署到 production 環境、在SSH_HOST上面拉取新的 Docker 映像檔並執行stop:停止 production 環境執行的容器、並刪除映像檔
這邊要注意的是:
- 所有的 docker 指令都要加上
sudo才行,包括docker login也要。 stop工作要記得設定成manual,否則會自動執行docker stop和docker rm後面的|| true是為了避免容器不存在的時候讓腳本失敗
如果是只有一個 production 環境的話,這樣的腳本應該是可以用的了~
多環境的版本
這邊是單一環境的狀況,如果是有多個環境要部署的話,也是可以透過把 deploy 和 stop 範本化的概念、來避免重複撰寫重複的腳本。
這邊改成範本形式的 deploy 和 stop 是:
.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-deploy 和 staging-deploy、停止的工作則是 部署的工作就是 production-stop 和 staging-stop 了;這四個工作基本上都是靠 extends 來使用前面定義的範本,主要的要設定的,就是 environment 的部分了。
而如果不同的環境還有不同的東西的話,除了設定在專案的 CI/CD 變數,也可以在這邊透過 variables 來定義。
大致上就先這樣了。
實際上,這邊的 Docker image 的 tag 會是使用 commit 的 SHA-1,雖然可以用來區隔版本,但是基本上不是給人來看的;比較好的方法應該是至少要在建立 v1.1.1 這種 tag 的時候、改用這種可以理解的名稱會比較好。
另外,其實還有一種部署方法,是在要部署的主機直接裝 gitlab runner、讓他直接接部署的工作;透過設定成專案專用 runner、並搭配 Protected Runner 的設定,應該也是可以保護到一定的程度?不過這邊還沒真的這樣玩過就是了。
