昨天在把我的本地專案部署到 GCE 上的過程可以說蠻不順利,什麼權限、金鑰等等的資源我總是管理得零零落落,這真的是我的一個大課題,跟開發要克服的難點完全不同,但對於成為一個成熟可靠的工程師,甚至不單只是對工程師而言都非常重要。昨天是在 workflow 上多下了一些功夫才克服或者說避開那些障礙,今天就來 recap 一下 workflow 的腳本,看看部署的 jobs 流程都做了哪些事,另外因為昨天剛開始測試 GCE 時,先 run 了 docker 確保可以運行,所以昨天實際上 github actions 的 CICD 最終敗在了端口衝突,但因為很晚了我就還沒來得及解決,今天就把舊的容器 remove 掉重新部署一次應該就沒什麼問題了。
首先我連到 GCE 上看看 8080 是否真的有端口被佔用:
che43700@playground-vm01:~$ sudo netstat -tulnp | grep 8080
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 124365/docker-proxy
tcp6 0 0 :::8080 :::* LISTEN 124371/docker-proxy
che43700@playground-vm01:~$ sudo ss -tulnp | grep 8080
tcp LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=124533,fd=4))
tcp LISTEN 0 4096 [::]:8080 [::]:* users:(("docker-proxy",pid=124539,fd=4))
大部分的 Linux 發行版都有預先安裝這兩個指令工具,netstat 是比較傳統的指令了,可以查看主機的基本信息,而 ss 是更現代化的工具,提供更詳盡的主機統計資料,查詢速度也更快。而 -tulnp
參數代表的意義如下:
-t # 顯示 TCP 連接
-u # 顯示 UDP 連接
-l # 只顯示監聽 (LISTEN) 狀態的端口
-n # 以數字形式顯示地址和端口(不解析主機名)
-p # 顯示進程 ID 和進程名稱(需要 root 權限)
我原本想說直接找到 8080 的 PID 用 Kill 殺掉應用就好,但這樣可能只殺掉了 docker-proxy 端口代理的進程,容器實際上還是在運行,也就是說這樣只單純把端口映射刪除了而已,Docker Daemon 並不知道我手動殺掉了 proxy,導致容器狀態不一致。其實我應該直接用 docker 指令查看有沒有容器在運行就好,這樣更方便且可以直接用 containerId 來 stop & rm 容器:
docker stop
會優雅停止容器,先發送 SIGTERM 給容器時間清理資源docker rm
會完全移除容器及其相關資源在這之前先釐清一個概念,也就是這些 job 裡的指令都是在「本地」執行的,至於這邊的本地指的是什麼?就是所謂的 GitHub Actions Runner,也就是 GitHub 提供的一台臨時虛擬機,由 runs-on: ubuntu-latest
指令定義的,所以這邊 Google 的認證還是授權之類的,都是針對 GitHub 本地的 VM 在執行的:
name: Deploy to GCE Instance
on:
push:
branches: [ release ]
pull_request:
branches: [ release ]
types: [ closed ]
env:
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
GAR_LOCATION: ${{ secrets.GCP_REGION }}
REPOSITORY: ${{ secrets.ARTIFACT_REPO }}
SERVICE: ${{ secrets.SERVICE }}
GCE_INSTANCE: ${{ secrets.GCE_INSTANCE_NAME }}
GCE_ZONE: ${{ secrets.GCE_ZONE }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Google Auth
uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker to use gcloud
run: gcloud auth configure-docker $GAR_LOCATION-docker.pkg.dev --quiet
- name: Build and Push Docker image
id: build
run: |
IMAGE_URL=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/$SERVICE:$GITHUB_SHA
docker build -t $IMAGE_URL .
docker push $IMAGE_URL
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Google Auth
uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Setup SSH
run: |
gcloud compute config-ssh --quiet
gcloud compute ssh $GCE_INSTANCE --zone=$GCE_ZONE --command="echo 'SSH connection test successful'" || echo "First SSH connection failed, but should work on retry"
- name: Deploy to GCE Instance
run: |
IMAGE_URL=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/$SERVICE:$GITHUB_SHA
echo "Using image: $IMAGE_URL"
gcloud compute scp $GOOGLE_APPLICATION_CREDENTIALS $GCE_INSTANCE:/tmp/sa-key.json --zone=$GCE_ZONE
gcloud compute ssh $GCE_INSTANCE --zone=$GCE_ZONE --command="
export GOOGLE_CLOUD_PROJECT=$PROJECT_ID
export CLOUDSDK_CORE_PROJECT=$PROJECT_ID
gcloud auth activate-service-account --key-file=/tmp/sa-key.json
ACCESS_TOKEN=\$(gcloud auth print-access-token)
echo \$ACCESS_TOKEN | sudo docker login -u oauth2accesstoken --password-stdin $GAR_LOCATION-docker.pkg.dev
sudo docker stop $SERVICE || true
sudo docker rm $SERVICE || true
sudo docker pull $IMAGE_URL
sudo docker run -d --name $SERVICE --restart unless-stopped -p 8080:8080 $IMAGE_URL
rm -f /tmp/sa-key.json
"
加了兩個新的 GCE 的環境變數:
GCE_INSTANCE: ${{ secrets.GCE_INSTANCE_NAME }}
GCE_ZONE: ${{ secrets.GCE_ZONE }}
擴展區塊用於部署 docker image 到 GCE 實例,上面的第一個 job build
是負責把專案建構成 docker image 並推送上 GCP 的 Artifact Registry,而 deploy
會接在後面從 Artifact Registry
拉取 image 並用 docker run
在 GCE 上運行起容器:
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Google Auth
uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Setup SSH
run: |
gcloud compute config-ssh --quiet
gcloud compute ssh $GCE_INSTANCE --zone=$GCE_ZONE --command="echo 'SSH connection test successful'" || echo "First SSH connection failed, but should work on retry"
- name: Deploy to GCE Instance
run: |
IMAGE_URL=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/$SERVICE:$GITHUB_SHA
echo "Using image: $IMAGE_URL"
gcloud compute scp $GOOGLE_APPLICATION_CREDENTIALS $GCE_INSTANCE:/tmp/sa-key.json --zone=$GCE_ZONE
gcloud compute ssh $GCE_INSTANCE --zone=$GCE_ZONE --command="
export GOOGLE_CLOUD_PROJECT=$PROJECT_ID
export CLOUDSDK_CORE_PROJECT=$PROJECT_ID
gcloud auth activate-service-account --key-file=/tmp/sa-key.json
ACCESS_TOKEN=\$(gcloud auth print-access-token)
echo \$ACCESS_TOKEN | sudo docker login -u oauth2accesstoken --password-stdin $GAR_LOCATION-docker.pkg.dev
sudo docker stop $SERVICE || true
sudo docker rm $SERVICE || true
sudo docker pull $IMAGE_URL
sudo docker run -d --name $SERVICE --restart unless-stopped -p 8080:8080 $IMAGE_URL
rm -f /tmp/sa-key.json
"
needs: build
:確保 job 順序,一定會在建構完後才執行 deploy 的 flow,避免並行處理。gcloud
指令都會以這個服務帳戶的身份執行:name: Google Auth
uses: google-github-actions/auth@v2
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
gcloud compute config-ssh --quiet
是指 GitHub 的本地 VM 為了要在之後能連上 GCE 做操作,所以先生成一個 GitHub Actions 的 ssh 公鑰放到 GCP 專案的 metadata,有點像是註冊的一個概念,讓它之後可以自由通行,註冊完再去指定要連到這個專案下的哪台 GCE 實例。
gcloud compute scp $GOOGLE_APPLICATION_CREDENTIALS $GCE_INSTANCE:/tmp/sa-key.json --zone=$GCE_ZONE
:把 GitHub Actions 的 SA 金鑰複製到 GCE 的 /tmp/sa-key.json
,它後續需要權限才能拉取 Artifact Registry 的 image。
在 GCE 上設定環境變數:
export GOOGLE_CLOUD_PROJECT=$PROJECT_ID
export CLOUDSDK_CORE_PROJECT=$PROJECT_ID
gcloud auth activate-service-account --key-file=/tmp/sa-key.json
在 GCE 上認證 SA,讓 GCE 實例以 SA 身份操作。
Docker 登入到 Artifact Registry:取得 GCP Access token,用這個 token 登入私有的 Docker registry:
ACCESS_TOKEN=$(gcloud auth print-access-token)
echo $ACCESS_TOKEN | sudo docker login -u oauth2accesstoken --password-stdin $GAR_LOCATION-docker.pkg.dev
移除舊容器,|| true
表示如果容器不存在也不會報錯,確保乾淨的部署環境:
sudo docker stop $SERVICE || true
sudo docker rm $SERVICE || true
拉取新的 image 並啟動:
sudo docker pull $IMAGE_URL
sudo docker run -d --name $SERVICE --restart unless-stopped -p 8080:8080 $IMAGE_URL
參數:
-d: 在背景執行
--name $SERVICE: 給容器命名
--restart unless-stopped: 自動重啟策略
-p 8080:8080: 映射端口
清理安全檔案:rm -f /tmp/sa-key.json
回顧完之後,我們也確認了這個部署計畫是可以成功 deploy 到 GCE 上的,但接著我們就要實際看看 docker 表面上部署成功,實際上裡面的 SpringBoot 專案有沒有真的運行起來呢?讓我們用 docker logs
指令查看一下 SpringBoot 的啟動日誌,結果會看到這樣一個錯誤信息:
2025-09-14T09:09:29.105Z ERROR 1 --- [playground-module] [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
Caused by: java.net.ConnectException: Connection refused
表示 SpringBoot 內部因為連不到資料庫所以暫時還無法啟動,但是這是一個好消息,代表我們的流程正常運作了,只是接下來要解決資料庫實例問題才可以實際在雲端上玩玩看我們的服務。