使用 AWS EC2 Instance 當你的 GitHub Action Runner
前陣子都在研究怎麼將 Laravel 專案容器化,也學習到了不少知識,也有寫一篇文章說明容器化的方式。
雖然容器化確實方便,但是只要程式碼一更新,就需要重新建立容器的映像檔案並上傳到映像檔案儲存庫,這個過程會比較花時間。
Dockerfile 的
COPY
會根據檔案的 Checksum 與 Metadata 是否改變來決定要不要使用 Build Cache,如果偵測到檔案被修改,Docker 就會重新建立 Layer。需要注意的是,
COPY
步驟如果不使用 Build Cache 而是重新建立Layer,則後續所有步驟也都會重新建立,不會使用 Build Cache,因此應該盡可能的將COPY
步驟往後移。
但如果能自動化,多花些時間其實也沒差,放著等他跑完就好。之前都是使用 GitHub Action CI/CD 來更新程式,所以這次當然也順手將建立映像檔案的流程交給 GitHub Action 去處理了。
找到建立映像檔的 Action,快速的寫好流程的 yaml 檔案之後立刻開始啟動流程,然後 …
花了將近一個半小時
這時我的表情 …
主要原因是 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,也才有了這篇文章。
接下來簡單說明怎麼使用這個 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。
建立 GitHub Personal Access Token
點選 GitHub 頁面上右上角的會員大頭貼,選擇 Settings → Developer settings → Personal access tokens → Tokens (classic)。新增一個 Token,權限只需要 public_repo 即可。
注意這邊目前只支援 Token (classic),最新的 Fine-grained tokens 還不支援,詳細可以看這篇討論。
更多設定可以參考官方文件。
同樣將取得的 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 中。
準備 AMI
可以參考我之前寫的文章。準備一個已經安裝好 Docker 與 Git 的 AMI。
並將 AMI 的 ID 放到 GitHub Action 的 Secrets 中。
準備建立映像檔案的工作流程
前置作業都準備好之後,就可以開始寫工作流程。在專案的 .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 分鐘。
資安問題
雖然 GitHub 允許使用自建的 Runner,但官方建議將自建 Runner 應用於私有專案,主要考慮到資安問題。攻擊者可能通過 fork 專案並發起請求,來執行你的自建 Runner。
本文中涉及操作 AWS 資源的敏感資訊都放在 Secrets 裡。僅獲取工作流程的設定文件並不能操作我的 AWS 帳戶中的資源。
還有一點需要注意的是,萬一程式依賴到惡意套件。就有可能在工作流程中使用自建的 Runner 執行惡意程式。
因此我這裡只使用 Docker 來建立映像檔案,並沒有執行專案程式的環節。
有關於自建 Runner 的資安資訊,詳細可以參考官方文件。