saip.learning notes

GitHub Actions로 push하면 자동 배포

배포 · 마지막 수정 2026-10-24

왜 자동 배포인가

손으로 배포하면 매번 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·deploy skipped, 사이트 그대로
  • 이전 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

로그인하고 댓글 달기