iT邦幫忙

2025 iThome 鐵人賽

DAY 24
1
Software Development

系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記系列 第 24

Day 24 | 第三階段系統優化 | 繼續 GitHub Actions + GCE 部署:回頭把某些概念 solidify 一下

  • 分享至 

  • xImage
  •  

前言

昨天在把我的本地專案部署到 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 容器:

  1. docker stop 會優雅停止容器,先發送 SIGTERM 給容器時間清理資源
  2. docker rm 會完全移除容器及其相關資源

workflows 回顧

在這之前先釐清一個概念,也就是這些 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
          "
  1. 加了兩個新的 GCE 的環境變數:

    GCE_INSTANCE: ${{ secrets.GCE_INSTANCE_NAME }}
    GCE_ZONE: ${{ secrets.GCE_ZONE }}
    
  2. 擴展區塊用於部署 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,避免並行處理。
    • 讓這個 GitHub Actions Runner 本地 VM 綁定一個特定的 SA,後續所有的 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 內部因為連不到資料庫所以暫時還無法啟動,但是這是一個好消息,代表我們的流程正常運作了,只是接下來要解決資料庫實例問題才可以實際在雲端上玩玩看我們的服務。


上一篇
Day 23 | 第三階段系統優化 | GitHub Actions + GCP 部署血淚史:踩雷大會
下一篇
Day 25 | 第三階段系統優化 | 為了省錢把 MySQL 部署在 GCE
系列文
系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言