使用 AWS EC2 Instance 當你的 GitHub Action Runner

程式技術
sharkHead

前陣子都在研究怎麼將 Laravel 專案容器化,也學習到了不少知識,也有寫一篇文章說明容器化的方式。

使用 Docker 把自己的 Laravel 專案容器化

雖然容器化確實方便,但是只要程式碼一更新,就需要重新建立容器的映像檔案並上傳到映像檔案儲存庫,這個過程會比較花時間。

Dockerfile 的 COPY 會根據檔案的 Checksum 與 Metadata 是否改變來決定要不要使用 Build Cache,如果偵測到檔案被修改,Docker 就會重新建立 Layer。

需要注意的是,COPY 步驟如果不使用 Build Cache 而是重新建立Layer,則後續所有步驟也都會重新建立,不會使用 Build Cache,因此應該盡可能的將 COPY 步驟往後移。

但如果能自動化,多花些時間其實也沒差,放著等他跑完就好。之前都是使用 GitHub Action CI/CD 來更新程式,所以這次當然也順手將建立映像檔案的流程交給 GitHub Action 去處理了。

找到建立映像檔的 Action,快速的寫好流程的 yaml 檔案之後立刻開始啟動流程,然後 …

花了將近一個半小時

2023_04_30_18_33_14_94cf3d11c29f.png

這時我的表情 …

b95f7050e5dca1c871b2b46144294b1d.png
???

主要原因是 GitHub Action 的 Runner 是使用 x86 的架構,而我是建立 ARM 架構的映像檔案。就好比不搭飛機選擇游泳去日本一樣,這當然會花更多時間了 …

會用 ARM 架構的主因是 AWS 的機器。如果用他們家的 ARM CPU,費用會稍微便宜一些。

使用 AWS EC2 Instance 當作 Runner

GitHub Action 其實是可以使用自己的機器當作 Runner 的,但我找到一個 Action,只要你在 AWS 上面有預先裝好的 Docker 與 Git 的 AMI (Amazon Machine Images),就可以在流程啟動時,將 AWS EC2 Instance 當作 GitHub Action 的 Runner。

因此我去研究怎麼怎麼用 Packer 建立 AMI,也才有了這篇文章。

簡單介紹如何用 Packer 建立 AWS AMI

接下來簡單說明怎麼使用這個 Action。

建立 IAM User

建立一個 AWS IAM User,讓 Action 可以使用這個 IAM User 啟動與關閉 EC2 Instance。

在這個步驟,我使用 Terraform 來建立 IAM Group 和 User,並將 Action 所需的 Policy 綁定到 Group 上。只要是屬於此 Group 的 User,都將擁有相應的權限。建立完成後,可以從 State 文件中獲取 Access Key。

下面文中的註解是 tfsec 的忽略規則請求,可以避免 tfsec 因為程式碼違反安全規則而發出警告。

tfsec 是一個用於檢查 Terraform 檔案安全性的工具。如果經過確認後,相應的資安警告無重大問題,可以選擇忽略。

#tfsec:ignore:aws-iam-enforce-group-mfa
resource "aws_iam_group" "github_action_runner_group" {
  name = "github-action-runner"
}

resource "aws_iam_user" "github_action_runner_manipulate" {
  name = "github-action-runner"
}

resource "aws_iam_group_membership" "github_action_runner_team" {
  name = "github-action-runner-team"

  users = [
    aws_iam_user.github_action_runner_manipulate.name,
  ]

  group = aws_iam_group.github_action_runner_group.name
}

resource "aws_iam_access_key" "github_action_runner_manipulate" {
  user = aws_iam_user.github_action_runner_manipulate.name
}

resource "aws_iam_group_policy" "github_action_runner_manipulate_group_policy" {
  name = "github-action-runner-group-policy"
  group = aws_iam_group.github_action_runner_group.name

  policy = jsonencode({
    #tfsec:ignore:aws-iam-no-policy-wildcards
    Version = "2012-10-17",
    Statement = [
      {
        Sid    = "GitHubActionRunnerManipulate",
        Effect = "Allow",
        Action = [
          "ec2:RunInstances",
          "ec2:TerminateInstances",
          "ec2:DescribeInstances",
          "ec2:DescribeInstanceStatus"
        ],
        Resource = "*"
      }
    ]
  })
}

建立完成之後,就可以將 Access Key 與地區相關資訊放到 GitHub Action 的 Secrets 中。可以在你的 GitHub 專案頁面,至 Settings → Secrets and variables → Actions 中設定 Secrets。

2023_04_30_19_05_07_1efcf6870999.png

建立 GitHub Personal Access Token

點選 GitHub 頁面上右上角的會員大頭貼,選擇 Settings → Developer settings → Personal access tokens → Tokens (classic)。新增一個 Token,權限只需要 public_repo 即可。

注意這邊目前只支援 Token (classic),最新的 Fine-grained tokens 還不支援,詳細可以看這篇討論。

更多設定可以參考官方文件

2023_04_30_20_12_59_aa7c74152979.png

同樣將取得的 Token 的放到 GitHub Action 的 Secrets 中。

準備 VPC 、 Subnet 與 Security Group

選擇要將機器開在哪個 VPC 與 Subnet 底下。AWS 帳號預設就會給一組 VPC 與 Subnet 與 Security Group,可以直接使用。注意 Security Group 需要在 Outbound 開啟 443 port (但預設的 Security Group 其實沒有限制 Outbound)。

將 Subnet ID 與 Security Group ID 放在 GitHub Action Secrets 中。

2023_04_30_20_38_55_4aca507ecee3.png

準備 AMI

可以參考我之前寫的文章。準備一個已經安裝好 Docker 與 Git 的 AMI。

並將 AMI 的 ID 放到 GitHub Action 的 Secrets 中。

2023_04_30_20_43_55_1ad6964e95cb.png

準備建立映像檔案的工作流程

前置作業都準備好之後,就可以開始寫工作流程。在專案的 .github 資料夾底下,新增一個 build-arm-image.yml

首先設定工作流程的觸發條件,我會希望完成測試環節之後在執行建立映像檔案的工作流程。

name: build-arm-image

# 在測試執行後才執行此流程
on:
  workflow_run:
    workflows:
      - tests
    types:
      - completed

# ...

接下來開始流程中的第一個任務,也就是啟動在 AWS EC2 上的 Runner。這裡有加上一個判斷條件,只有在測試流程結束且成功無錯誤情況下才會開始這個任務。

# ...

jobs:
  start-runner:
    name: Start self-hosted EC2 runner
    runs-on: ubuntu-latest

    # 如果測試流程成功才啟動 Runner
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    outputs:
      label: ${{ steps.start-ec2-runner.outputs.label }}
      ec2-instance-id: ${{ steps.start-ec2-runner.outputs.ec2-instance-id }}
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Start EC2 runner
        id: start-ec2-runner
        uses: machulav/ec2-github-runner@v2
        with:
          mode: start
          github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
          ec2-image-id: ${{ secrets.EC2_IMAGE_ID }}
          ec2-instance-type: t4g.small
          subnet-id: ${{ secrets.SUBNET_ID }}
          security-group-id: ${{ secrets.SECURITY_GROUP_ID }}

# ...

接下來開始在我們的 Runner 上進行建立映像檔的任務。

# ...

  # 建立映像檔案並上傳到 Docker Hub
  build-arm-image:
    name: Start to build arm image and publish to docker hub
    needs: start-runner
    runs-on: ${{ needs.start-runner.outputs.label }}

    steps:
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push laravel app image
        uses: docker/build-push-action@v4
        with:
          file: dockerfiles/Dockerfile.app
          platforms: linux/arm64
          push: true
          # 這裡請填上自己映像檔案名稱
          tags: <username>/<image>:<tag>

# ...

最後最重要的環節,將 Runner 給關閉,避免產生多餘的費用。

# ...

  stop-runner:
    name: Stop self-hosted EC2 runner
    needs:
      - start-runner
      - build-arm-image
    runs-on: ubuntu-latest

    # 不管前面的流程有沒有發生錯誤,必定執行此流程將機器給關閉,避免費用產生
    if: ${{ always() }}

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Stop EC2 runner
        uses: machulav/ec2-github-runner@v2
        with:
          mode: stop
          github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
          label: ${{ needs.start-runner.outputs.label }}
          ec2-instance-id: ${{ needs.start-runner.outputs.ec2-instance-id }}

準備完成之後就可以開始啟動流程來建立映像檔案了。

時間從 1 小時 30 分大幅減少到 15 分鐘。

2023_04_30_20_55_20_9a699083d28b.png
Nice !

資安問題

雖然 GitHub 允許使用自建的 Runner,但官方建議將自建 Runner 應用於私有專案,主要考慮到資安問題。攻擊者可能通過 fork 專案並發起請求,來執行你的自建 Runner。

本文中涉及操作 AWS 資源的敏感資訊都放在 Secrets 裡。僅獲取工作流程的設定文件並不能操作我的 AWS 帳戶中的資源。

還有一點需要注意的是,萬一程式依賴到惡意套件。就有可能在工作流程中使用自建的 Runner 執行惡意程式。

因此我這裡只使用 Docker 來建立映像檔案,並沒有執行專案程式的環節。

有關於自建 Runner 的資安資訊,詳細可以參考官方文件

參考資料

sharkHead
written by
sharkHead

後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

1 則留言
sharkHead sharkHead 2023 年 07 月 22 日 (已編輯)

基本上有加上 cache 的話,就能大幅度減少 build 的時間