Part 4. AWS

GitHub Actions로 자동 배포하기

EC2에 Docker로 배포하기에서 손으로 한 배포를 자동화한다. 코드를 push하면 이미지가 만들어지고 서버에 반영되는 것까지 손을 대지 않는다.

학습 목표

  • CI/CD가 무엇을 해결하는지 설명할 수 있다.

  • GitHub Actions 워크플로를 작성해 이미지를 빌드하고 GHCR에 올릴 수 있다.

  • 배포용 SSH 키를 따로 만들어 Secrets로 관리할 수 있다.

  • 배포 스크립트를 서버에 두고 워크플로가 호출하게 만들 수 있다.

GitHub Actions 와 GHCR 은 이 실습 범위에서 무료다.

퍼블릭 저장소

프라이빗 저장소 (Free 플랜)

Actions 실행 시간

무제한 무료

월 2,000분까지 무료

GHCR 스토리지

무료

500 MB 까지 무료

GHCR 다운로드

무료

GitHub Actions 에서 받는 것은 무료

이 교안은 저장소를 퍼블릭으로 두고 패키지도 공개로 바꾸므로, 실행 시간과 저장 용량 모두 요금이 발생하지 않는다.

프라이빗으로 진행한다면 월 2,000분이 한도다. 이 워크플로 한 번이 몇 분이므로 실습에는 충분하지만, 실패한 워크플로를 반복 실행하면 생각보다 빨리 줄어든다. 사용량은 GitHub Settings → Billing 에서 확인한다. 한도를 넘으면 결제 수단을 등록하지 않는 한 실행이 멈출 뿐 과금되지는 않는다.

정확한 한도와 요금은 바뀔 수 있으니 GitHub 공식 요금 문서에서 확인한다.

1. 무엇이 자동화되는가

EC2에 Docker로 배포하기에서는 배포할 때마다 이 순서를 손으로 밟는다.

손으로 하던 것

자동화 후

docker build

워크플로가 실행

docker login ghcr.io

GITHUB_TOKEN 으로 자동

docker push

워크플로가 실행

EC2에 SSH 접속

워크플로가 접속

docker pull + docker run

서버의 배포 스크립트가 실행

사람이 하는 일은 git push 하나만 남는다.

자동화의 핵심 가치는 시간 절약이 아니다. 매번 똑같은 순서로 실행된다는 점이다. 손으로 하면 급할 때 한 단계를 빠뜨리고, 어제와 다른 태그를 붙이고, 누군가는 자기 노트북에서 빌드해 올린다. 그런 차이가 장애로 이어진다.

CI와 CD

  • CI (지속적 통합) — push할 때마다 자동으로 빌드하고 테스트해서 "이 코드가 문제없는가"를 검증한다. 여러 사람이 동시에 작업해도 망가진 코드를 빨리 발견한다.

  • CD (지속적 배포) — 검증을 통과한 결과물을 자동으로 서버에 반영한다.

이 장에서 만드는 워크플로는 빌드(CI)와 배포(CD)를 한 번에 한다. 테스트 단계를 넣으면 더 온전한 CI가 된다.

2. 배포 스크립트를 서버에 두기

워크플로가 서버에서 실행할 명령을 스크립트로 만들어 둔다. 워크플로에 명령을 길게 늘어놓는 것보다 낫다 — 서버에서 직접 실행해 볼 수 있어서 문제가 생겼을 때 배포 과정 탓인지 스크립트 탓인지 가를 수 있다.

EC2에 접속해 만든다.

~/deploy.sh

#!/bin/bash
set -euo pipefail

TAG="${1:?이미지 태그를 인자로 넘겨야 한다}"
IMAGE="ghcr.io/<GitHub사용자명>/visit-counter:${TAG}"

echo "==> 이미지 내려받기: ${IMAGE}"
docker pull "${IMAGE}"

echo "==> 기존 컨테이너 정리"
docker rm -f visit-counter 2>/dev/null || true

echo "==> 새 컨테이너 실행"
docker run -d \
  --name visit-counter \
  -p 8080:8080 \
  --env-file "${HOME}/app.env" \
  --restart unless-stopped \
  "${IMAGE}"

echo "==> 배포 완료"
docker ps --filter name=visit-counter
chmod +x ~/deploy.sh

set -euo pipefail 을 붙이는 이유

  • -e — 명령 하나라도 실패하면 즉시 중단한다. 이게 없으면 docker pull 이 실패해도 다음 줄로 넘어가 낡은 이미지로 재시작해 놓고 "배포 성공"이라고 보고한다.

  • -u — 정의되지 않은 변수를 쓰면 중단한다.

  • -o pipefail — 파이프 중간이 실패해도 잡아낸다.

배포 스크립트에서 가장 위험한 것은 실패했는데 성공한 척하는 것이다.

접속 정보 파일(~/app.env)은 EC2에 Docker로 배포하기에서 만든 것을 그대로 쓴다.

접속 정보를 GitHub에 두지 않는 이유

데이터베이스 비밀번호를 GitHub Secrets에 넣고 배포할 때마다 서버로 내려보내는 방법도 있다. 이 교안은 서버에 두는 쪽을 택했다.

비밀은 적게 돌아다닐수록 좋다. 서버에 한 번 두면 그곳에만 있지만, GitHub Secrets에 두면 GitHub도 갖게 되고 배포할 때마다 네트워크를 탄다.

서버가 여러 대로 늘어나면 이 방식은 한계에 부딪힌다. 그때는 AWS Systems Manager Parameter Store나 Secrets Manager처럼 설정을 한곳에서 관리하고 서버가 받아 가는 구조로 옮긴다.

3. 배포용 SSH 키 만들기

워크플로가 EC2에 접속하려면 개인 키가 필요하다. 인스턴스를 만들 때 받은 키를 그대로 쓰지 않는다.

그 키는 사람이 서버에 들어갈 때 쓰는 키다. GitHub에 넣는 순간 저장소 설정에 접근할 수 있는 사람이라면 누구든 서버에 들어올 수 있게 된다. 배포 전용 키를 따로 만들어 용도를 나눈다.

로컬에서 새 키를 만든다.

ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "github-actions-deploy" -N ""

Windows PowerShell 에서는 -N "" 이 먹지 않는다. PowerShell 이 빈 문자열 인자를 지워 버려서 암호를 묻거나 인자 개수 오류가 난다.

ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\deploy_key" -C github-actions-deploy -N '""'

또는 Git Bash 에서 위의 bash 명령을 그대로 쓴다.

개인 키에 암호를 걸면 안 된다. 워크플로는 암호를 입력해 줄 사람이 없다. 이 키는 배포 전용이고 GitHub Secrets 안에만 있으므로 암호 없이 만든다.

공개 키를 EC2에 등록한다.

# 공개 키 내용을 확인해서
cat ~/.ssh/deploy_key.pub

# EC2에 접속한 뒤 authorized_keys 에 덧붙인다
echo "<복사한 공개 키 내용>" >> ~/.ssh/authorized_keys

>> 를 > 로 쓰면 기존 키가 지워진다. 원래 쓰던 키까지 날아가면 서버에 다시 들어갈 수 없다. 덧붙이기(>>)인지 확인하고 실행한다.

불안하면 실행 전에 백업해 둔다.

cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak

등록이 끝나면 로컬에서 새 키로 접속되는지 먼저 확인한다. 워크플로를 돌리기 전에 여기서 검증해 두면 나중에 실패 원인을 좁히기 쉽다.

ssh -i ~/.ssh/deploy_key ec2-user@<EIP>

4. 보안 그룹 — 여기서 한 번 막힌다

EC2 장에서 SSH(22)의 소스를 내 IP로 제한했다. 그런데 워크플로는 내 노트북이 아니라 GitHub이 빌려주는 가상 머신에서 돈다.

GitHub Actions 러너의 IP는 고정이 아니다.

실행할 때마다 달라진다. 보안 그룹이 내 IP만 허용하고 있으면 워크플로의 SSH 단계가 연결되지 않고 멈춰 있다가 시간 초과로 실패한다.

빌드와 push는 멀쩡히 성공하고 마지막 단계에서만 실패하기 때문에, 워크플로가 잘못된 줄 알고 YAML을 들여다보게 되는 지점이다. 원인은 네트워크다.

GitHub이 쓰는 IP 대역은 공개돼 있다.

curl -s https://api.github.com/meta | head -40

다만 actions 대역은 매우 넓고 수시로 바뀐다. 보안 그룹 규칙으로 넣어 관리하는 것은 현실적이지 않다.

실습에서는 22번을 열되 키 인증만 허용하는 선에서 타협한다. Amazon Linux 2023은 기본적으로 비밀번호 인증이 꺼져 있으므로, 키가 없으면 접속할 수 없다.

유형

포트

소스

SSH

22

0.0.0.0/0

기존 SSH 규칙의 소스만 내 IP 에서 Anywhere-IPv4 로 바꾼다. 규칙을 새로 추가하는 것이 아니다.

SSH 22 의 소스를 0.0.0.0/0 으로 바꾼다

SSH 22 의 소스를 0.0.0.0/0 으로 바꾼다

타협이라는 점을 분명히 해둔다. 22번을 인터넷에 열면 자동화된 접속 시도가 끊임없이 들어온다. 키 인증만 허용하므로 뚫리지는 않지만, 로그가 지저분해지고 공격 표면이 늘어나는 것은 사실이다.

실무에서 쓰는 방법은 이렇다.

  • AWS Systems Manager Session Manager — 인바운드 포트를 아예 열지 않고 접속한다. 가장 깔끔하지만 IAM 역할 설정이 필요하다.

  • self-hosted runner — 서버 쪽에서 GitHub으로 연결을 맺으므로 인바운드가 필요 없다.

  • 배포 직전에 러너 IP를 보안 그룹에 추가하고 끝나면 제거 — AWS 자격 증명이 필요하다.

실습이 끝나면 22번 소스를 다시 내 IP로 되돌려 두는 것이 좋다.

5. Secrets 등록

저장소 → Settings → Secrets and variables → Actions → New repository secret

이름

값

EC2_HOST

EC2의 EIP 주소

EC2_SSH_KEY

~/.ssh/deploy_key 파일의 전체 내용

EC2_HOST_FINGERPRINT

서버 호스트 키의 지문 — 아래에서 구한다

EC2_HOST_FINGERPRINT — 접속하려는 서버가 진짜 내 서버인지 확인하는 값이다.

사람이 처음 ssh 로 접속할 때 "Are you sure you want to continue connecting?" 하고 묻는 그 지문이다. 사람은 yes 를 치지만 워크플로는 물어볼 사람이 없다. 지문을 주지 않으면 appleboy/ssh-action 은 확인 없이 아무 서버에나 붙는다. 누군가 중간에서 서버인 척하면 개인 키로 인증을 시도하고 배포 명령까지 보내 버린다.

지문은 서버 안에서 구하는 것이 가장 확실하다.

# EC2 에 접속한 상태에서 — ECDSA 키의 지문이다
ssh-keygen -lf /etc/ssh/ssh_host_ecdsa_key.pub
# 256 SHA256:AbCd...xyz root@ip-172-31-... (ECDSA)

가운데의 SHA256:AbCd...xyz 부분만 Secret 에 넣는다.

ED25519 가 아니라 ECDSA 키다. Amazon Linux 2023 서버에는 호스트 키가 ECDSA·ED25519 두 개 있다. 내 PC 의 ssh 는 ED25519 를 먼저 고르지만, appleboy/ssh-action 이 쓰는 Go 언어의 SSH 라이브러리는 ECDSA 를 먼저 고른다. ED25519 지문을 넣으면 키가 맞는데도 이렇게 실패한다 — 이 교안을 쓰며 실제로 겪었다.

ssh: handshake failed: ssh: host key fingerprint mismatch
New repository secret — 이름과 값을 넣는다

New repository secret — 이름과 값을 넣는다

등록하고 나면 목록에는 이름만 남는다.

Settings → Secrets — 값은 보이지 않고 이름만 남는다

Settings → Secrets — 값은 보이지 않고 이름만 남는다

개인 키는 첫 줄과 마지막 줄까지 전부 붙여넣는다.

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

-----BEGIN 줄을 빼먹거나 마지막 줄바꿈이 잘리면 형식 오류로 실패한다. 가장 흔한 실수다.

# 클립보드로 정확히 복사
cat ~/.ssh/deploy_key | clip        # Git Bash
# PowerShell 에서는 리다이렉션(<) 이 예약어라 쓸 수 없다
Get-Content "$env:USERPROFILE\.ssh\deploy_key" -Raw | Set-Clipboard

Secrets는 등록한 뒤 다시 볼 수 없다. 값이 틀렸다면 수정이 아니라 새로 덮어쓰는 방식으로 고친다. 워크플로 로그에도 값이 *** 로 가려져 출력된다.

6. 워크플로 작성

테스트를 하나 더하고, 저장소에 .github/workflows/deploy.yml 을 만든다.

6-1. 테스트 먼저 — CI 의 C

워크플로가 빌드만 하면 "컴파일이 되는가"까지만 확인한다. 테스트가 통과해야 배포로 넘어가게 만들면 망가진 코드가 서버까지 가는 것을 한 단계 앞에서 막는다.

build.gradle 의 dependencies 에 테스트 라이브러리를 더하고, 맨 아래에 JUnit 설정을 둔다.

dependencies {
    // ... 기존 의존성 ...

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

test { useJUnitPlatform() }

테스트는 데이터베이스 없이 도는 것으로 만든다. GitHub 의 러너에는 Supabase 접속 정보가 없고, 있어서도 안 된다(2절). 저장소(VisitCountRepository)를 가짜(mock)로 바꿔 끼우면 컨트롤러의 계산만 검사할 수 있다.

src/test/java/com/example/VisitCounterApplicationTest.java

package com.example;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

// DB 없이 도는 단위 테스트 — 저장소(repo)를 가짜(mock)로 바꿔 끼운다
class VisitCounterApplicationTest {

    @Test
    void 기록이_없으면_현재_카운트는_0이다() {
        VisitCountRepository repo = mock(VisitCountRepository.class);
        when(repo.findAll()).thenReturn(List.of());

        assertEquals("현재 카운트: 0\n", new VisitCounterApplication(repo).count());
    }

    @Test
    void 방문하면_카운트가_1_오른다() {
        VisitCount saved = new VisitCount();
        saved.setCount(41);
        VisitCountRepository repo = mock(VisitCountRepository.class);
        when(repo.findAll()).thenReturn(List.of(saved));
        when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));

        assertEquals("방문 횟수: 42\n", new VisitCounterApplication(repo).visit());
    }
}

로컬에서 먼저 돌려 본다. 호스트에 JDK 가 없어도 Gradle 이미지로 된다.

docker run --rm -v "$PWD":/build -w /build gradle:8.14-jdk21 gradle test --no-daemon
# BUILD SUCCESSFUL

6-2. 워크플로

name: deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write        # GHCR 에 올리려면 필요하다

# 배포는 한 번에 하나만 돈다. 앞 배포가 끝날 때까지 다음 실행은 기다린다.
concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  # ── CI: 테스트가 통과해야 다음 job 이 돈다 ───────────────────
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'

      # Gradle 을 설치하고, 내려받은 의존성을 실행 사이에 캐시한다
      - uses: gradle/actions/setup-gradle@v6
        with:
          gradle-version: '8.14'

      - name: 테스트
        run: gradle test --no-daemon

  # ── CD: 이미지 빌드 → GHCR → EC2 배포 ───────────────────────
  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      # GHCR 은 이미지 이름이 전부 소문자여야 한다.
      # 사용자명에 대문자가 있으면 그대로 쓸 수 없어 여기서 변환한다.
      - name: 이미지 이름 결정
        run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/visit-counter" >> $GITHUB_ENV

      - name: GHCR 로그인
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # 따로 발급하지 않아도 된다

      # 빌드 캐시를 GitHub 에 저장하려면 buildx 빌더가 필요하다
      - uses: docker/setup-buildx-action@v4

      - name: 이미지 빌드 및 push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: EC2 에 배포
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.EC2_SSH_KEY }}
          fingerprint: ${{ secrets.EC2_HOST_FINGERPRINT }}   # 진짜 내 서버인지 확인
          script: ~/deploy.sh ${{ github.sha }}

짚어볼 점

job 이 둘이고, needs: test 가 순서를 정한다. test 가 실패하면 build-and-deploy 는 아예 시작하지 않는다. 이미지도 만들어지지 않고 서버도 건드리지 않는다. Actions 화면에서도 두 job 이 따로 표시되므로 테스트에서 막혔는지, 빌드·배포에서 막혔는지 바로 보인다.

빌드 캐시를 GitHub 에 둔다. 러너는 매번 새 가상 머신이라 그냥 두면 Gradle 의존성과 Docker 레이어를 처음부터 받는다. setup-gradle 은 Gradle 캐시를, cache-from/cache-to: type=gha 는 Docker 레이어 캐시를 실행 사이에 보관한다. type=gha 는 기본 Docker 빌더가 지원하지 않아 setup-buildx-action 이 필요하다. 이 교안에서 잰 값으로는 캐시가 없는 첫 build-and-deploy 가 2분 48초, 캐시가 채워진 다음 실행이 42초였다. 이때 이미지 빌드 단계는 전부 CACHED 였다.

secrets.GITHUB_TOKEN 은 만들지 않아도 있다. GitHub이 워크플로 실행마다 자동으로 발급하고 끝나면 폐기한다. 이것이 AWS ECR 대신 GHCR을 쓰는 실질적인 이유다. ECR로 올리려면 AWS 자격 증명을 따로 마련해야 한다.

태그를 latest 가 아니라 커밋 해시(github.sha)로 붙인다. latest 만 쓰면 지금 서버에서 도는 이미지가 어느 코드인지 알 수 없다. 해시를 붙이면 장애가 났을 때 이전 해시로 되돌리면 끝이다.

# 롤백 — 서버에서 이전 커밋의 「전체」 해시(40자)를 넘긴다
~/deploy.sh 3f2a1b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6

짧은 해시(3f2a1b8)로는 롤백되지 않는다. github.sha 는 40자 전체 해시이고, 이미지 태그도 그 40자 그대로 붙는다. 짧은 해시를 넘기면 그런 태그가 없어 docker pull 이 manifest unknown 으로 실패한다.

전체 해시는 로컬에서 git log --format=%H -n 5 로 보거나, 저장소 Packages 탭의 태그 목록에서 복사한다.

permissions: packages: write 를 빠뜨리면 push 단계에서 실패한다. GITHUB_TOKEN 은 기본적으로 읽기 권한만 갖기 때문이다. 오류 메시지가 권한 문제라고 곧바로 알려주지 않아 헤매기 쉽다.

concurrency 는 연달아 push 할 때를 위한 것이다. 이게 없으면 push 를 두 번 빠르게 하는 순간 워크플로가 두 개 동시에 돌고, 서버의 deploy.sh 도 동시에 두 번 실행된다. 지금 장의 스크립트라면 같은 이름의 컨테이너를 서로 지우는 정도지만, 무중단 배포 장의 스크립트는 두 실행이 같은 "현재 색"을 읽고 같은 쪽에 배포해 버려 전환이 꼬인다.

group 이름이 같은 실행은 하나씩 차례로 돈다. cancel-in-progress: false 는 진행 중인 배포를 중간에 끊지 않고 끝까지 기다리게 한다. 배포를 반쯤 하다 끊는 것이 가장 위험하기 때문이다.

push 를 6초 간격으로 두 번 해 보면 두 번째 실행은 첫 실행이 끝날 때까지 pending 으로 서 있다가 그 뒤에 돈다. 단, 기다리는 자리는 하나뿐이다. 세 번 연달아 push 하면 가운데 실행은 취소되고 가장 최근 것만 기다린다. 어차피 최신 코드가 배포되므로 문제는 없다.

7. 실행과 확인

코드를 수정하고 push한다.

git add .
git commit -m "test: 자동 배포 확인"
git push origin main

저장소의 Actions 탭에서 진행 상황을 볼 수 있다.

test 와 build-and-deploy 가 모두 통과한 실행

test 가 통과한 뒤에 build-and-deploy 가 돈다 — 선이 needs 다

보안 그룹을 아직 열지 않았다면 여기서 4절의 함정을 만난다. 빌드와 push 는 초록색으로 성공하고 마지막 단계에서만 실패한다.

빌드와 push 는 성공하고 SSH 단계에서만 실패한다

빌드와 push 는 성공하고 SSH 단계에서만 실패한다

Drone SSH version 1.8.2
2026/09/17 23:14:03 dial tcp ***:22: i/o timeout
##[error]Process completed with exit code 1.

읽을 점이 두 가지 있다.

  • i/o timeout — 거부(refused)가 아니라 무응답이다. 보안 그룹이 패킷을 조용히 버리면 이렇게 된다. 30초를 기다린 뒤 끝났다.

  • *** — EIP 주소가 별표로 가려져 있다. EC2_HOST 가 Secret 이라 GitHub 이 로그에서 자동으로 지운 것이다. Secrets 가 로그에 새지 않는다는 말이 이렇게 확인된다.

보안 그룹 22번을 열고 Re-run jobs 를 누르면 통과한다.

각 단계를 펼치면 로그가 나온다. 실패하면 어느 단계에서 멈췄는지부터 본다. 빌드에서 멈췄는지, push에서 멈췄는지, SSH에서 멈췄는지에 따라 원인이 완전히 다르다.

성공하면 브라우저에서 확인한다.

http://<EIP>:8080

8. 아직 남은 문제 — 배포 중 서비스가 끊긴다

배포는 자동화됐지만 deploy.sh 를 다시 보자.

docker rm -f visit-counter     # 서비스 중단 시작
docker run -d ...              # 새 컨테이너 기동

기존 컨테이너를 지우고 새로 띄운다. 그 사이에 들어온 요청은 전부 실패한다. Spring Boot가 기동을 마칠 때까지 수십 초가 걸리므로, 사용자에게는 그동안 서비스가 죽은 것으로 보인다.

배포 중에 확인해 보면 바로 드러난다.

while true; do curl -s -o /dev/null -w "%{http_code} " http://<EIP>:8080/; sleep 0.3; done
# Windows PowerShell — curl.exe 로 불러야 진짜 curl 이 실행된다
while ($true) { Write-Host -NoNewline "$(curl.exe -s -o NUL -w '%{http_code}' http://<EIP>:8080/) "; Start-Sleep -Milliseconds 300 }

루프를 돌려 둔 채로 git push 를 하고 화면을 지켜본다.

200 이 이어지다가 000 이 한동안 찍힌 뒤 다시 200 으로 돌아온다. 000 은 응답 코드를 하나도 받지 못했다는 뜻이다 — 8080 에서 듣는 프로세스가 없어 연결 자체가 거부된 것이다. (앞에 Nginx 가 있다면 Nginx 는 살아 있으므로 000 대신 502 가 찍힌다.)

Nginx로 무중단 배포하기에서 이 빈틈을 없앤다.

9. 문제 해결

push 단계에서 denied: permission_denied 가 뜬다

permissions: packages: write 가 워크플로에 있는지 확인한다. 저장소 설정에서 Actions의 기본 권한이 읽기 전용으로 제한돼 있을 수도 있다 — Settings → Actions → General → Workflow permissions.

Settings → Actions → General → Workflow permissions

기본값은 Read repository contents and packages permissions — 읽기 전용이다

기본값이 읽기 전용이라는 점이 중요하다. 이 화면을 Read and write permissions 로 바꾸는 것보다, 워크플로에 permissions: 블록을 적어 필요한 권한만 주는 쪽이 낫다. 이 화면을 열어두면 저장소의 모든 워크플로가 쓰기 권한을 갖게 된다.

EC2 에서 docker pull 이 unauthorized 로 실패한다

GHCR 패키지가 비공개다. EC2에 Docker로 배포하기에서 공개로 바꾼 것과 같은 이름을 쓰고 있는지 확인한다. 이름이 다르면 새 패키지가 만들어지고, 새 패키지는 다시 비공개로 시작한다.

push 단계가 denied 인데 permissions 는 제대로 넣었다

EC2에 Docker로 배포하기에서 개인 토큰(PAT)으로 먼저 올린 패키지라면 이것이 원인이다.

PAT 로 만든 패키지는 계정에 딸리고 저장소와 연결돼 있지 않다. 그런데 GITHUB_TOKEN 은 「이 저장소」 의 권한으로만 움직이므로, 연결되지 않은 패키지에는 permissions: packages: write 가 있어도 쓸 수 없다.

패키지 페이지 → Package settings → Manage Actions access → Add Repository 로 저장소를 추가하고 역할을 Write 이상으로 준다.

반대로 워크플로가 먼저 만든 패키지는 처음부터 저장소에 연결돼 나오므로 이 문제가 없다.

SSH 단계에서 멈춰 있다가 시간 초과로 실패한다

보안 그룹이 22번을 막고 있을 가능성이 가장 높다(4절). 러너 IP는 매번 달라진다는 점을 기억한다.

키 형식 문제라면 시간 초과가 아니라 즉시 인증 실패로 끝나므로, 멈췄다가 실패하는지 바로 실패하는지로 원인을 가를 수 있다.

SSH 단계가 바로 실패하고 host key fingerprint mismatch 가 뜬다

ssh: handshake failed: ssh: host key fingerprint mismatch

EC2_HOST_FINGERPRINT 가 서버가 내미는 호스트 키와 다르다. 흔한 원인은 세 가지다.

  • ED25519 키의 지문을 넣었다. 가장 흔하다. ssh_host_ecdsa_key.pub 의 지문이어야 한다(5절).

  • 값을 통째로 넣었다. 256 SHA256:... root@... (ECDSA) 전체가 아니라 가운데 SHA256:... 부분만 넣는다.

  • 인스턴스를 새로 만들었다. 인스턴스를 지우고 다시 만들면 호스트 키도 새로 생긴다. 같은 EIP 를 붙였어도 다른 서버다. 5절 방법으로 다시 구해 덮어쓴다.

반대로 아무것도 바꾸지 않았는데 이 오류가 났다면, 그것이 바로 이 값이 막으려는 상황일 수 있다. 원인을 확인하기 전에 Secret 을 고쳐 넣지 않는다.

빌드는 성공했는데 서버의 애플리케이션이 그대로다

deploy.sh 안에서 실패했는데 워크플로는 성공으로 본 경우다. set -e 가 빠져 있으면 이런 일이 생긴다.

서버에서 직접 실행해 확인한다.

~/deploy.sh <커밋해시>
echo $?          # 0 이 아니면 실패
docker logs visit-counter

10. 실습 정리

워크플로와 컨테이너는 그대로 둔다. Nginx로 무중단 배포하기에서 deploy.sh 를 무중단 방식으로 고쳐 쓴다.

보안 그룹의 22번을 열어둔 상태다. 실습을 한동안 쉰다면 소스를 내 IP로 되돌리거나 EC2를 중지해 둔다.


다음 장 — Nginx로 무중단 배포하기

마지막 수정 2026년 10월 22일