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로 배포하기에서는 배포할 때마다 이 순서를 손으로 밟는다.
손으로 하던 것 | 자동화 후 |
|---|---|
| 워크플로가 실행 |
|
|
| 워크플로가 실행 |
EC2에 SSH 접속 | 워크플로가 접속 |
| 서버의 배포 스크립트가 실행 |
사람이 하는 일은 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-counterchmod +x ~/deploy.shset -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 |
|
기존 SSH 규칙의 소스만 내 IP 에서 Anywhere-IPv4 로 바꾼다. 규칙을 새로 추가하는 것이 아니다.
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의 EIP 주소 |
|
|
| 서버 호스트 키의 지문 — 아래에서 구한다 |
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 mismatchNew repository secret — 이름과 값을 넣는다
등록하고 나면 목록에는 이름만 남는다.
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-ClipboardSecrets는 등록한 뒤 다시 볼 수 없다. 값이 틀렸다면 수정이 아니라 새로 덮어쓰는 방식으로 고친다. 워크플로 로그에도 값이 *** 로 가려져 출력된다.
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 SUCCESSFUL6-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 가 돈다 — 선이 needs 다
보안 그룹을 아직 열지 않았다면 여기서 4절의 함정을 만난다. 빌드와 push 는 초록색으로 성공하고 마지막 단계에서만 실패한다.
빌드와 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>:80808. 아직 남은 문제 — 배포 중 서비스가 끊긴다
배포는 자동화됐지만 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.
기본값은 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 mismatchEC2_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-counter10. 실습 정리
워크플로와 컨테이너는 그대로 둔다. Nginx로 무중단 배포하기에서 deploy.sh 를 무중단 방식으로 고쳐 쓴다.
보안 그룹의 22번을 열어둔 상태다. 실습을 한동안 쉰다면 소스를 내 IP로 되돌리거나 EC2를 중지해 둔다.
다음 장 — Nginx로 무중단 배포하기