昨天讓 GitHub Actions 換得到 AWS 的身分了,但 gha-deploy-role 還沒有任何權限,什麼事都不能做。
今天就來寫 deploy.yml:只要 push 到 main,GitHub Actions 就會自動 build Image、推上 ECR,再通知 EC2 換上新版本。過程中用到哪些權限,就幫 Role 加哪些ㄅ!
Day23 整理過手動時進版的步驟,今天就把它們一個一個交給 workflow:
| 步驟 | 手動的時候 | 交給 workflow 之後 |
|---|---|---|
| build Image | 在 Mac 上 build,要加 --platform linux/amd64 |
在 GitHub 的 Runner 上 build,它本來就是 x86_64,不用加 |
| push 到 ECR | 用自己的 IAM 身分登入 ECR | 用 Day24 設定的 OIDC,換到 gha-deploy-role 的身分 |
| pull Image、換 container | 開 Session Manager,一行一行打指令 | 用 SSM 的 Run Command,把同樣的指令送到 EC2 上執行 |
| 確認網站正常 | 自己開瀏覽器或 curl | 最後自動 curl 一次 |
整個 workflow 跑起來的順序是這樣:
gha-deploy-role 的短期權限。Day13 推上 ECR 的 Image,tag 是用 v1。那之後每次部署,都推成 v1 可以嗎?
我們在 Day13 建的 ECR repo 是用 Mutable,所以同一個 tag 的 image 可以重複推。而再推一次 v1 時,ECR 會把 v1 這個名字移到新的 Image 上,舊的那個就變成沒有 tag 的 Image(untagged)。
但這樣會出現兩個兩個麻煩:
v1 也不知道裡面是哪一版的程式碼。所以 workflow 我們改用 commit 的 SHA 當 tag。SHA 是 git 替每個 commit 算出來的一串編號,每個 commit 都不一樣,在 GitHub 上也查得到那次 commit 改了什麼。之後在 EC2 上看到 my-app:<SHA>,就知道網站跑的是哪一版。
workflow 要做的事,對應到 AWS 需要這幾個權限:
| 要做的事 | 需要的權限 |
|---|---|
| 登入 ECR | ecr:GetAuthorizationToken |
push Image 到 my-app |
6 個上傳用的權限,只限 my-app 這個 repo |
| 通知 EC2 執行指令 | ssm:SendCommand,只限這台 EC2 和 AWS-RunShellScript |
| 查詢指令的執行結果 | ssm:GetCommandInvocation |
先到 EC2 的 Console 點進你建的 Instance,複製 Instance ID(i- 開頭的那串),等一下會用到。接著到 IAM 的 Console:
gha-deploy-role。123456789012 換成自己的帳號 ID、i-0123456789abcdef0 換成剛剛複製的 Instance ID。deploy-permissions,按 Create policy(建立政策)。{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "LoginToECR",
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Sid": "PushToMyAppRepo",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage",
"ecr:BatchGetImage"
],
"Resource": "arn:aws:ecr:ap-east-2:123456789012:repository/my-app"
},
{
"Sid": "SendDeployCommand",
"Effect": "Allow",
"Action": "ssm:SendCommand",
"Resource": [
"arn:aws:ec2:ap-east-2:123456789012:instance/i-0123456789abcdef0",
"arn:aws:ssm:ap-east-2::document/AWS-RunShellScript"
]
},
{
"Sid": "ReadCommandResult",
"Effect": "Allow",
"Action": "ssm:GetCommandInvocation",
"Resource": "*"
}
]
}
逐段來看:
*。BatchGetImage 是讀取清單用的,AWS 文件列出的 push 權限也包含它。這 6 個權限只對 my-app 這個 repo 有效,就算 workflow 被改壞,也推不到別的 repo。AWS-RunShellScript 這份文件(Document),也就是「執行 shell 指令」。這份文件是 AWS 提供的,所以 ARN 裡沒有帳號 ID。*,但它只能讀結果,不能下指令。為什麼不直接掛 AWS 提供的現成政策,例如 AmazonEC2ContainerRegistryPowerUser?因為這類政策的範圍是整個帳號:所有 ECR repo 都推得上去。自己寫雖然麻煩一點,但可以把權限範圍限制在這個 repo 和這台 EC2。不過 AWS-RunShellScript 仍能在這台主機執行 root 指令,所以能修改 workflow 的權限也要顧好。
在專案裡新增 .github/workflows/deploy.yml,env 底下的四個值換成自己的:
name: Deploy to EC2
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: deploy
cancel-in-progress: false
env:
AWS_REGION: ap-east-2
ECR_REPOSITORY: my-app
EC2_INSTANCE_ID: i-0123456789abcdef0
SITE_URL: https://example.com
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
env:
IMAGE: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
run: |
docker build -t "$IMAGE" .
docker push "$IMAGE"
- name: Deploy to EC2
env:
REGISTRY: ${{ steps.ecr.outputs.registry }}
IMAGE: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
run: |
cat > deploy-commands.json <<EOF
{
"commands": [
"set -e",
"aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $REGISTRY",
"docker pull $IMAGE",
"docker rm -f my-app || true",
"docker run -d --name my-app --restart unless-stopped -p 127.0.0.1:3000:3000 $IMAGE",
"docker image prune -a -f"
]
}
EOF
COMMAND_ID=$(aws ssm send-command \
--instance-ids "$EC2_INSTANCE_ID" \
--document-name AWS-RunShellScript \
--comment "Deploy $GITHUB_SHA" \
--parameters file://deploy-commands.json \
--query Command.CommandId \
--output text)
echo "Command ID: $COMMAND_ID"
aws ssm wait command-executed --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" || true
STATUS=$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" --query Status --output text)
echo "--- EC2 output ---"
aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" --query StandardOutputContent --output text
echo "--- EC2 errors ---"
aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$EC2_INSTANCE_ID" --query StandardErrorContent --output text
echo "Status: $STATUS"
test "$STATUS" = "Success"
- name: Smoke test
run: |
curl --fail --silent --show-error --output /dev/null \
--retry 5 --retry-delay 3 --retry-all-errors \
--write-out "%{http_code}\n" "$SITE_URL"
看起來很長,但大部分在 Day24 的 oidc-test.yml 和 Day13 的手動指令都看過了。所以我們大致逐段來看一下:
on:push 到 main 時自動執行;workflow_dispatch 則是保留手動執行的按鈕,之後想重新部署同一版時可以用。permissions:id-token: write 是 OIDC 要用的。Day24 的 workflow 不用讀程式碼,所以沒有寫 contents;今天要 checkout 程式碼來 build,所以多了 contents: read。concurrency:同一個 group 同時只會跑一個。如果短時間內 push 兩次的話,第二次會等第一次跑完才開始,不會有兩個部署同時去換 container。env:整個 workflow 共用的設定。Instance ID 和 Domain 都不是密碼,直接寫在檔案裡就好。接著是每個步驟:
gha-deploy-role 的短期權限。aws ecr get-login-password | docker login。它會把 registry 的位址(123456789012.dkr.ecr.ap-east-2.amazonaws.com)放在 steps.ecr.outputs.registry,後面的步驟拿來組出完整的 Image 名稱。github.sha 就是這次 commit 的 SHA。Runner 是 x86_64,跟 EC2 一樣,所以就不用加 --platform。aws ssm send-command 送過去。最後用 wait 等它跑完,把 EC2 上印出的內容和結果印出來,結果不是 Success 的話就讓這一步失敗。送到 EC2 上的那幾行指令,就是我們之前在 Session Manager 裡打過的:
| 指令 | 做什麼 |
|---|---|
set -e |
任何一行失敗就停下來,不會繼續往下執行 |
aws ecr get-login-password ... | docker login ... |
EC2 用自己的 Role 登入 ECR(Day13 加的拉取權限) |
docker pull |
先把新的 Image 拉下來,這時網站還是舊版,照常運作 |
docker rm -f my-app || true |
停掉並刪除舊的 container;第一次部署時如果沒有這個 container,也不會因此失敗 |
docker run ... |
用新的 Image 跑 container,參數跟 Day22 一樣:綁 127.0.0.1:3000,讓 Caddy 轉過來 |
docker image prune -a -f |
刪掉沒有 container 在用的 Image,避免舊版本一直堆在 EC2 上 |
先 pull 再刪舊的 container,是為了讓網站中斷的時間只有「新 container 啟動」那一兩秒;如果 pull 失敗(例如登入過期),set -e 會讓指令停在這裡,舊的 container 還在跑,所以網站不會受影響。
另外,SSM 是用 root 身分執行指令,所以這些 docker 指令都不用加 sudo。
但有三個地方特別注意一下:
run 遇到失敗的指令就會直接結束這一步。wait 在 EC2 執行失敗時也會回傳錯誤,所以後面加上 || true,讓它繼續往下,把 EC2 上的錯誤訊息印出來,最後再用 test 決定這一步成功或失敗。deploy-commands.json 用的是 <<EOF(沒有加引號),$IMAGE 這些變數才會在寫進檔案時換成真正的值。wait 最多只等約 100 秒。如果 EC2 拉 Image 比較久,Actions 可能先顯示失敗,但 EC2 其實還在部署。遇到這種情況,先到 Systems Manager 的 Run Command → Command history,用 logs 裡的 Command ID 查看進度。確認那次指令已經結束,再決定要不要重新部署,避免兩次部署同時進行。為了看得出是不是新版,先改一下首頁。打開 app/page.tsx(有 src 資料夾的話是 src/app/page.tsx),在 <main ...> 的下一行加上:
<p>Deployed by GitHub Actions</p>
接著把 deploy.yml 和首頁的修改一起推上 main:
git add .github/workflows/deploy.yml app/page.tsx
git commit -m "ci: push 到 main 自動部署到 EC2"
git push origin main
PS. Day24 的 oidc-test.yml 已經用不到了,想要的話可以一起刪掉。
push 完到 repo 的 Actions Tab,就會看到 Deploy to EC2 正在執行。點進去可以看到每個步驟的 logs,第一次大約兩三分鐘會跑完:
sha256: 開頭)。Login Succeeded、pull 的進度、新 container 的 ID,以及 prune 清掉了多少空間,最後是 Status: Success。EC2 errors 那段如果出現 WARNING! Your password will be stored unencrypted,是 docker login 的提醒,不是錯誤。200。全部綠燈之後,用瀏覽器打開網站,就會看到剛剛加的 Deployed by GitHub Actions 了!
想確認 EC2 上跑的是哪一版,可以用 Session Manager 連進去看:
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'

IMAGE 那一欄的結尾,就是剛剛那次 commit 的 SHA,跟 GitHub 上 commit 列表的編號對得起來。如果 Day22 留下的 my-app-old 還在,現在也可以用 sudo docker rm my-app-old 刪掉了。
另外,每次送到 EC2 的指令都會留下紀錄。到 Systems Manager 的 Console,左邊選單點 Run Command,切到 Command history Tab,就能看到每一次部署(Comment 是 Deploy 加上 commit SHA),點進去也看得到 EC2 上的輸出。
如果之後改了程式,上線才發現有問題,可以用 git revert 撤銷修改,再 push,讓 workflow 重新部署。下面假設最新一筆 commit 就是要撤銷的程式修改,而且沒有改到 deploy.yml。
今天這筆 commit 同時新增了 deploy.yml,不要直接拿它來測試,否則連自動部署的設定都會一起刪掉。如果只是想拿掉首頁的測試文字,刪掉那行文字,再 commit、push 就好。
git revert HEAD
git push origin main
git revert 不會刪掉歷史,而是新增一個「把那次修改反過來」的 commit。push 之後,workflow 就會照平常的流程,把撤銷後的版本部署上去,程式碼和網站也會保持一致。
我自己的 side project 一開始接上自動部署時,前後踩了好幾個坑:
deploy.yml 放在開發用的 branch,觸發條件寫的卻是 push 到 main。GitHub Actions 讀的是「被 push 的那個 branch 上的 workflow 檔」,main 上沒有這個檔案,所以 merge 進 main 之後什麼事都沒發生。手動執行的按鈕也一樣,只認預設 branch(通常是 main)上的 workflow 檔。deploy.yml 補進 main 之後,馬上連續紅燈兩次。原因是 main 落後開發 branch 太多:第一次是 main 上的 Dockerfile 還是舊版,npm ci 時出現 sh: 1: husky: not found;第二次是程式碼裡還有地方 import 一個已經被刪掉的東西,build 時被 ESLint 擋下來。這些問題在我自己的電腦上都沒出現過,後來我另外加了一個在開 PR 時就先 build、跑測試的 workflow,問題在 merge 之前就會先現形。docker image prune -f 清不掉舊版本:我一開始寫的是不加 -a 的 prune -f,它只會刪掉沒有 tag 的 Image,但每個舊版本都有自己的 SHA tag,所以一個都沒刪到。前陣子上去看,EC2 上有 6 個版本的 Image,只有 1 個在用,docker system df 顯示有 638MB(85%)可以回收。加上 -a 才會連沒在用的舊版本一起刪掉,要退回舊版時再從 ECR 拉回來就好。到這裡,只要 push 到 main,新版本就會自動上線了!EC2 這條路也走到尾聲,下一篇來整理網站上線之後還要顧的事:logs 去哪裡看、掛掉時怎麼收到通知、安全設定和費用的檢查清單ㄅ!
資安小提醒:inline policy 和 Actions 的 logs 裡都有 12 碼的帳號 ID(ECR 的位址開頭就是它),deploy.yml 裡也有 Instance ID 和網域,截圖前記得遮,repo 也請保持 Private。
aws ecr get-login-password印出來的是 ECR 的登入密碼,只用|交給docker login,不要單獨執行,更不要印在 logs 裡。