GitHub Actions로 자동 배포 파이프라인 구축하기 — 개념과 설계
CI/CD 개념부터 GitHub Actions Workflow 구조, pnpm 모노레포 test·deploy 잡 설계까지 전 과정을 정리합니다.
이전 글에서 EC2에 수동으로 Docker 컨테이너를 올렸습니다.
소스 코드가 바뀔 때마다 SSH로 접속해 직접 docker pull && docker run을 치는 과정은 번거롭습니다.
배포를 잊으면 서버에는 구버전이 돌고 있습니다.
코드를 push할 때 자동으로 테스트하고 배포할 수 없을까요?
이 글에서는 GitHub Actions로 CI/CD 파이프라인을 구성하는 개념과 전체 설계를 정리합니다.
실제 구성 과정에서 겪은 삽질은 다음 글에서 다룹니다.
CI/CD란 무엇인가
CI — Continuous Integration (지속적 통합)
코드를 push하거나 PR을 올릴 때마다 자동으로 빌드하고 테스트를 실행합니다.
“내 코드가 기존 코드베이스와 충돌 없이 통합되는가”를 자동으로 검증합니다.
CI가 없다면 각자 로컬에서만 테스트하고 merge해서, 나중에 가서야 충돌을 발견합니다.
CI가 있다면 merge 전에 자동으로 오류를 잡아냅니다.
CD — Continuous Deployment (지속적 배포)
CI를 통과한 코드를 자동으로 서버에 배포합니다.
수동으로 SSH 접속해서 명령을 치는 과정이 자동화됩니다.
CI/CD를 합치면 다음 흐름이 만들어집니다.
개발자가 코드 push
↓
CI: 자동 빌드 + 테스트
실패하면 알림 전송, 배포 중단
↓
CD: 자동 Docker 이미지 빌드 & Docker Hub push
↓
CD: SSH로 EC2 접속 → docker pull & docker run
GitHub Actions 기본 개념
Workflow
.github/workflows/ 디렉터리 안의 YAML 파일로 정의합니다.
특정 이벤트(push, PR, 스케줄 등)가 발생하면 워크플로가 자동으로 실행됩니다.
name: Server CI/CD
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
Job
워크플로 안에서 독립적으로 실행되는 단위입니다.
여러 Job이 있을 때 기본적으로 병렬 실행됩니다.
needs: [다른-잡]으로 실행 순서를 강제할 수 있습니다.
Step
Job 안에서 순서대로 실행되는 단계입니다.
각 Step은 shell 명령(run:) 또는 Action(uses:)을 실행합니다.
steps:
- name: Checkout
uses: actions/checkout@v4 # 미리 만들어진 재사용 가능한 Action
- name: Install dependencies
run: pnpm install # shell 명령
Runner
Job이 실행되는 가상 머신입니다.
runs-on: ubuntu-latest처럼 지정합니다.
GitHub에서 제공하는 호스티드 runner와 자체 서버에 설치하는 self-hosted runner가 있습니다.
Secrets
API 키, 비밀번호 같은 민감 정보를 워크플로에서 안전하게 사용하는 방법입니다.
GitHub 저장소 → Settings → Secrets and variables → Actions에서 등록합니다.
${{ secrets.MY_SECRET }} 형식으로 참조하며, 로그에서 자동으로 마스킹됩니다.
이번 프로젝트에서 사용한 Secrets 목록입니다.
| Secret 이름 | 내용 |
|---|---|
DOCKER_USERNAME | Docker Hub 아이디 |
DOCKER_PASSWORD | Docker Hub 비밀번호 또는 Access Token |
HOST | EC2 탄력적 IP 주소 |
PRIVATE_KEY | EC2 키 페어 .pem 파일 전체 내용 |
APPLICATION | apps/server/.env 파일 전체 내용 |
파이프라인 설계
test 잡과 deploy 잡
이번 프로젝트의 워크플로는 두 잡으로 분리했습니다.
push to main (또는 PR 생성)
↓
test 잡 — 항상 실행 (ubuntu-latest, amd64)
pnpm install
schemas 빌드
server 빌드
유닛 테스트
E2E 테스트 (PostgreSQL 서비스 컨테이너 사용)
↓ test 성공 시에만
deploy 잡 — push/수동 실행 시만 실행 (ubuntu-24.04-arm, arm64)
pnpm install + 빌드
Docker 이미지 빌드 & Docker Hub push (linux/arm64)
SSH → EC2에서 docker pull & docker run
PR에서는 test 잡만 실행됩니다.
main에 merge되거나 수동 트리거( workflow_dispatch ) 시에만 deploy 잡이 실행됩니다.
deploy 잡이 ubuntu-24.04-arm 을 선택한 이유는 아래에서 설명합니다.
ARM64 빌드 환경과 QEMU
amd64와 ARM64
CPU 아키텍처에는 두 가지 주요 계열이 있습니다.
- amd64(x86_64): Intel, AMD CPU를 쓰는 일반 PC와 서버에서 사용합니다. GitHub Actions의 기본 runner
ubuntu-latest가 이 아키텍처입니다. - ARM64(aarch64): AWS Graviton, Apple M1/M2, 스마트폰 대부분이 이 계열입니다. 동일한 성능 대비 전력 효율이 높아 클라우드 서버에서 점유율이 높아지고 있습니다.
두 아키텍처는 CPU 명령어 집합이 달라서, 한쪽에서 컴파일한 바이너리는 다른 쪽에서 실행할 수 없습니다.
EC2 t4g 인스턴스(AWS Graviton)는 ARM64이므로, Docker 이미지도 ARM64용으로 빌드해야 합니다.
QEMU란 무엇인가
QEMU(Quick EMUlator)는 하드웨어 에뮬레이터입니다.
한 아키텍처용 바이너리를 다른 아키텍처에서 실행할 수 있도록 CPU 명령어를 소프트웨어로 실시간 변환합니다.
Docker의 buildx와 함께 사용하면 amd64 머신에서 arm64 이미지를 빌드할 수 있습니다.
- uses: docker/setup-qemu-action@v3 # QEMU 설치
- uses: docker/build-push-action@v6
with:
platforms: linux/arm64 # arm64 이미지 빌드 지시
처음에는 이 방식으로 시도했습니다.
QEMU의 한계 — SIGILL
QEMU는 소프트웨어 에뮬레이션이므로 속도가 느립니다.
더 심각한 문제는 C++ 네이티브 모듈을 컴파일할 때 발생합니다.
bcrypt는 설치 과정에서 C++ 바이너리를 직접 컴파일합니다.
QEMU가 이 과정에서 처리하지 못하는 명령어를 만나면 SIGILL(Illegal Instruction) 신호를 발생시키며 크래시가 납니다.
qemu: uncaught target signal 4 (Illegal instruction) - core dumped
빌드가 15분 넘게 멈췄다가 위 에러와 함께 종료됩니다.
해결 — ubuntu-24.04-arm
QEMU 대신 GitHub Actions의 ARM64 네이티브 runner를 사용합니다.
deploy:
runs-on: ubuntu-24.04-arm
ubuntu-24.04-arm은 EC2 Graviton과 동일한 aarch64 아키텍처로 동작하는 호스티드 runner입니다.
QEMU 없이 ARM64 명령어를 직접 실행하므로, 네이티브 모듈 빌드도 정상적으로 완료됩니다.
test 잡 전체 코드
jobs:
test:
name: "CI — Build & Test"
runs-on: ubuntu-latest
services:
# E2E 테스트용 임시 PostgreSQL 컨테이너
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: todo_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --config.frozen-lockfile=true --config.network-timeout=100000
- name: Build shared schemas
run: pnpm --filter @repo/schemas build
- name: Build server
run: pnpm --filter server build
- name: Run unit tests
run: pnpm --filter server test --passWithNoTests
# health-cmd 이후에도 race condition이 있을 수 있어 한 번 더 확인
- name: Wait for PostgreSQL
run: until pg_isready -h localhost -p 5432 -U postgres; do sleep 1; done
- name: Run e2e tests
run: pnpm --filter server test:e2e
env:
NODE_ENV: test
DB_HOST: localhost
DB_PORT: 5432
DB_USERNAME: postgres
DB_PASSWORD: postgres
DB_NAME: todo_test
JWT_SECRET: ci-test-secret
JWT_EXPIRES_IN: 1h
services 블록 으로 E2E 테스트용 PostgreSQL 컨테이너를 자동으로 띄웁니다.
Job이 끝나면 컨테이너도 자동으로 삭제됩니다.
pg_isready 로 DB가 준비될 때까지 대기합니다.
deploy 잡 전체 코드
deploy:
name: "CD — Docker Build & Deploy"
needs: test
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# EC2 Graviton(arm64)과 동일 아키텍처 — QEMU 에뮬레이션 없이 네이티브 빌드
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --config.frozen-lockfile=true --config.network-timeout=100000
# GitHub Secrets의 APPLICATION 값을 .env 파일로 생성
- name: Inject .env file
run: echo "${{ secrets.APPLICATION }}" > apps/server/.env
- name: Build shared schemas
run: pnpm --filter @repo/schemas build
- name: Build server
run: pnpm --filter server build
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/server/Dockerfile
push: true
platforms: linux/arm64
tags: ${{ secrets.DOCKER_USERNAME }}/todo-server:latest
- name: Deploy to EC2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.PRIVATE_KEY }}
script: |
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
# 비대화형 SSH는 docker가 PATH에 없을 수 있음
resolve_docker() {
if command -v docker >/dev/null 2>&1; then
echo docker
elif [ -x /usr/bin/docker ]; then
echo /usr/bin/docker
elif [ -x /snap/bin/docker ]; then
echo /snap/bin/docker
else
echo "ERROR: docker CLI not found." >&2
echo "Install: curl -fsSL https://get.docker.com | sudo sh" >&2
exit 127
fi
}
DOCKER_BIN="$(resolve_docker)"
if ! "$DOCKER_BIN" info >/dev/null 2>&1; then
DOCKER_BIN="sudo $DOCKER_BIN"
fi
IMAGE="${{ secrets.DOCKER_USERNAME }}/todo-server:latest"
# Docker Hub 로그인 및 arm64 이미지 pull
echo "${{ secrets.DOCKER_PASSWORD }}" | \
$DOCKER_BIN login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
$DOCKER_BIN stop todo-server || true
$DOCKER_BIN rm todo-server || true
$DOCKER_BIN rmi "$IMAGE" || true
$DOCKER_BIN pull --platform linux/arm64 "$IMAGE"
# 아키텍처 검증 (amd64 이미지가 올라오면 즉시 실패)
ARCH="$($DOCKER_BIN image inspect --format '{{.Architecture}}' "$IMAGE")"
if [ "$ARCH" != "arm64" ]; then
echo "ERROR: image is $ARCH, expected arm64." >&2
exit 1
fi
# DB(Compose)가 todo-network에 붙어 있어야 DB_HOST=postgresql 해석 가능
if [ -f /home/ubuntu/apps/docker-compose.yml ]; then
cd /home/ubuntu/apps
$DOCKER_BIN compose up -d
elif ! $DOCKER_BIN network inspect todo-network >/dev/null 2>&1; then
echo "ERROR: todo-network not found." >&2
exit 1
fi
$DOCKER_BIN run -d \
--platform linux/arm64 \
--name todo-server \
--network todo-network \
-p 8080:3000 \
--env-file /home/ubuntu/apps/.env \
-e NODE_ENV=production \
-e TZ=Asia/Seoul \
--restart unless-stopped \
"$IMAGE"
$DOCKER_BIN image prune -f
echo "Deploy completed"
- name: Check container logs
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.PRIVATE_KEY }}
script: |
set -euo pipefail
DOCKER_BIN=$(command -v docker || echo "sudo docker")
sleep 10
$DOCKER_BIN logs todo-server --tail 50
다음 글에서는 이 파이프라인을 실제로 구성하는 과정에서 마주친 11가지 삽질을 다룹니다.