GitHub Actions로 push하면 자동 배포
왜 자동 배포인가
손으로 배포하면 매번 docker build → docker push → 서버 SSH → compose pull → up -d → 확인을 반복한다. 귀찮아서 테스트를 건너뛰기 쉽고, 순서를 하나 빼먹으면 사고가 난다.
| 용어 | 뜻 |
|---|---|
| CI(지속적 통합) | push할 때마다 자동으로 빌드·테스트. 깨진 코드를 바로 안다 |
| CD(지속적 배포) | 테스트를 통과하면 자동으로 서버에 반영 |
| 워크플로 | .github/workflows/*.yml 하나. 언제(on) 무엇을 할지 |
| 잡(job) | 워크플로 안의 작업 단위. 잡마다 새 가상 머신에서 돈다 |
| 스텝(step) | 잡 안에서 순서대로 실행하는 명령이나 Action |
| 러너 | 잡을 실행하는 GitHub의 임시 리눅스 머신. 공개 저장소는 무료 |
| Action | 남이 만들어 둔 스텝(actions/checkout, docker/build-push-action 등) |
전체 흐름
flowchart LR
push([main에 push]) --> test["`**test**
gradlew test
blog.py selftest`"]
test -- 통과 --> image["`**image**
docker build
ghcr에 :SHA, :latest`"]
image --> deploy["`**deploy**
SSH로 서버 접속
APP_TAG=SHA, pull, up -d`"]
deploy --> check{"`**확인**
https 200?`"}
test -- 실패 --> stop([멈춤, 서버는 그대로])
image -.-> ghcr@{ shape: cyl, label: "ghcr.io" }
deploy -.-> server@{ shape: cyl, label: "Lightsail 서버" }
class ghcr,server ext
classDef ext stroke-dasharray: 4 3
needs:로 잡 순서를 묶으면 앞 잡이 실패했을 때 뒤 잡은 건너뛴다(skipped). 그래서 테스트가 깨진 코드는 서버에 가지 않는다.
워크플로 파일
on:
push:
branches: [main]
workflow_dispatch: # Actions 화면에서 수동 실행
concurrency: # 배포가 겹치지 않게 하나씩
group: deploy
cancel-in-progress: false
permissions:
contents: read # 기본 권한은 최소로
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-java@v6.1.0
with: { distribution: temurin, java-version: "21", cache: gradle }
- run: ./gradlew test # 러너에 Docker가 있어 Testcontainers가 그대로 돈다
- run: python3 claude-skill/blog/blog.py selftest
image:
needs: test
if: github.ref == 'refs/heads/main' # 다른 브랜치 수동 실행은 테스트만
permissions: { contents: read, packages: write }
steps:
- uses: docker/login-action@v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7.4.0
with:
push: true
tags: |
ghcr.io/<owner>/saip:${{ github.sha }}
ghcr.io/<owner>/saip:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: image
steps:
- run: | # Secrets로 키와 known_hosts 파일을 만든다
scp deploy/compose.yaml deploy/Caddyfile "$TARGET:saip/"
ssh "$TARGET" "cd ~/saip && sed -i 's/^APP_TAG=.*/APP_TAG=${{ github.sha }}/' .env \
&& docker compose pull app && docker compose up -d"
- run: curl -fsS --retry 10 --retry-delay 6 --retry-all-errors "https://<도메인>/"
Tip
Action은 @v7이나 @v7.0.1 같은 태그가 아니라 커밋 SHA로 고정한다. 태그는 Action 주인(또는 그 계정을 턴 공격자)이 다른 커밋으로 옮길 수 있어서, 내 배포 잡이 packages: write 권한과 SSH 키를 든 채 바뀐 코드를 실행할 수 있다. SHA는 바꿀 수 없다. 사람이 읽을 수 있게 태그는 주석으로 남긴다.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
태그의 SHA 찾기: gh api repos/actions/checkout/commits/v7.0.1 --jq .sha. 처음엔 태그로 올렸다가 자동 보안 검토에서 지적받고 바꿨다.
Note
ghcr 이미지 이름은 소문자만 된다. 저장소 주인이 Limsangkeun이면 ${GITHUB_REPOSITORY_OWNER,,}(bash 소문자 변환)로 바꿔 쓴다.
비밀값: GitHub Secrets와 GITHUB_TOKEN
| 이름 | 무엇 |
|---|---|
GITHUB_TOKEN |
GitHub이 실행마다 자동으로 만드는 임시 토큰. 등록할 필요 없음. permissions: packages: write를 줘야 ghcr에 push 가능 |
SSH_HOST, SSH_USER |
서버 주소와 사용자 |
SSH_KEY |
배포 전용 SSH 개인키 |
SSH_KNOWN_HOSTS |
서버 공개 지문 |
Secrets는 저장소 설정의 암호화된 금고다. 로그에 찍혀도 ***로 가려진다. 등록은 값이 화면에 안 나오게 파일이나 파이프로 넣는다.
ssh-keygen -q -t ed25519 -f ~/.ssh/saip-deploy -N "" -C "github-actions-deploy"
cat ~/.ssh/saip-deploy.pub | ssh 관리자키로 서버 'cat >> ~/.ssh/authorized_keys'
printf '<도메인>' | gh secret set SSH_HOST
printf 'ubuntu' | gh secret set SSH_USER
gh secret set SSH_KEY < ~/.ssh/saip-deploy
ssh-keyscan -t ed25519,ecdsa,rsa <도메인> > kh && gh secret set SSH_KNOWN_HOSTS < kh && rm kh
- 배포 전용 키를 따로 만드는 이유: 관리자 키를 GitHub에 맡기지 않는다. 유출되면 서버
authorized_keys에서 그 한 줄만 지우면 끝이다. - known_hosts가 필요한 이유: SSH는 처음 보는 서버면 "이 서버 믿을래?"라고 묻는다. 러너는 대답할 수 없고, 무조건 믿게(
StrictHostKeyChecking=no) 하면 가짜 서버에 키로 접속할 수 있다. 미리 받아 둔 지문과 다르면 접속을 거부한다. 서버를 새로 만들면 지문이 바뀌므로 이 Secret도 갱신한다.
이미지 태그를 커밋 SHA로 남기는 이유
latest만 덮어쓰면 어제 버전이 사라진다. 커밋 SHA로도 태그를 남기면 ghcr에 버전이 쌓이고, 되돌리기가 한 줄이다.
배포할 때 서버 .env의 APP_TAG를 SHA로 바꿔 둔다. 그래서 서버 재부팅이나 수동 docker compose up -d도 같은 버전으로 뜬다.
# 서버에서: 이전 버전으로
cd ~/saip && sed -i 's/^APP_TAG=.*/APP_TAG=<이전 SHA>/' .env && docker compose up -d app
# 확인
docker compose ps app --format '{{.Image}}'
다음 push가 다시 최신 SHA로 바꾼다.
확인한 것
- 첫 push: test 1분 32초 → image 2분 38초 → deploy 45초, 모두 초록
- 서버
docker compose ps의 이미지 태그가 그 커밋 SHA, 노트 9개 그대로 - 일부러 실패하는 테스트를 넣은 브랜치에서 수동 실행 →
test실패,image·deployskipped, 사이트 그대로 - 이전 SHA로 내렸다가 최신으로 다시 올리기 → 둘 다 200
겪은 문제
gradlew 실행 권한
Windows에서 만든 저장소라 git에 gradlew가 100644(실행 권한 없음)로 들어가 있었다. PC의 Docker Desktop 빌드에서는 문제가 없었지만, 리눅스 러너에서는 ./gradlew: Permission denied가 난다. git에 실행 권한을 기록해서 미리 막았다.
git ls-files -s gradlew # 100644 → 실행 권한 없음
git update-index --chmod=+x gradlew # 100755로 커밋
예전 이미지는 되돌릴 수 없었다
자동 배포 전에는 :latest로만 올렸다. 새 latest가 덮어쓰자 예전 이미지는 태그 없는 버전으로 남아 이름으로 고를 수 없었다. SHA 태그가 생긴 뒤부터 되돌리기가 가능하다.
배포 중 15~20초 502
up -d는 기존 앱 컨테이너를 멈추고 새로 띄운다. Spring Boot가 뜨는 동안 Caddy는 연결할 곳이 없어 502를 돌려준다. curl --retry-all-errors가 그 사이를 기다려 준다.
Note
개인 블로그라 이 정도 중단은 받아들였다. 무중단이 필요하면 새 컨테이너를 먼저 띄우고 상태 확인 후 트래픽을 넘기는 블루-그린 배포를 쓴다(컨테이너 두 개만큼 메모리가 필요).
댓글 0
로그인하고 댓글 달기