Part 4. AWS

Nginx로 무중단 배포하기

GitHub Actions로 자동 배포하기에서 배포는 자동화됐지만 배포하는 동안 서비스가 끊긴다. 이 장에서 그 빈틈을 없앤다.

학습 목표

  • 배포 중 서비스가 끊기는 이유를 설명할 수 있다.

  • Nginx를 리버스 프록시로 두고 요청을 보낼 대상을 바꿀 수 있다.

  • 컨테이너를 두 벌 운영하며 무중단으로 교체할 수 있다.

  • 헬스 체크를 통과한 뒤에만 전환하도록 배포 스크립트를 작성할 수 있다.

Part 3 의 무중단 배포를 이미 했다면

원리는 같고 도구가 다르다. 11장은 Nginx 도 DB 도 전부 컨테이너로 올리고 배포 스크립트를 사람이 직접 실행한다. 이 장은 호스트에 설치한 Nginx(systemd) 를 GitHub Actions 가 조작한다.

Part 3 (11장)

Part 4 (이 장)

Nginx

컨테이너

호스트 systemd

데이터베이스

MySQL 컨테이너

Supabase (관리형 PostgreSQL)

배포 실행 주체

사람이 deploy.sh 실행

GitHub Actions 가 SSH 로 실행

앱 포트

blue 8080 / green 8081

blue 8081 / green 8082

호스트

리눅스 아무 대나

EC2 t3.micro

바뀌지 않는 것이 본질이다 — 쉬는 쪽에 띄우고, 헬스 체크로 확인하고, 전환하고, 옛것을 정리한다.

1. 왜 끊기는가

GitHub Actions로 자동 배포하기의 deploy.sh 는 이렇게 동작한다.

docker rm -f visit-counter     # ← 여기서 서비스가 죽는다
docker run -d ...              # ← 여기서 다시 살아난다

그 사이에 들어온 요청은 전부 실패한다. 게다가 docker run 이 끝났다고 서비스가 바로 되는 것도 아니다. 컨테이너가 떴다는 것과 애플리케이션이 요청을 받을 준비가 됐다는 것은 다르다. Spring Boot가 기동을 마칠 때까지 수십 초가 더 걸린다.

정리하면 중단 구간은 이만큼이다.

기존 컨테이너 종료 ──────── 새 컨테이너 기동 ──────── 애플리케이션 준비 완료
        └──────────────── 이 구간 전체가 장애 ────────────────┘

2. 블루·그린 배포

해결의 열쇠는 단순하다. 끄고 켜지 말고, 켜놓고 갈아탄다.

애플리케이션을 두 벌(blue, green) 운영할 수 있게 해두고, 한 번에 한 쪽만 서비스한다. 배포할 때는 쉬고 있는 쪽에 새 버전을 띄우고, 준비가 끝난 것을 확인한 다음 요청을 그쪽으로 넘긴다.

                     ┌── blue  :8081  (구버전, 서비스 중)
사용자 ──▶ Nginx :80 ─┤
                     └── green :8082  (신버전, 기동 중)

                              ↓ 준비 완료 확인 후 전환

                     ┌── blue  :8081  (구버전, 이제 정리)
사용자 ──▶ Nginx :80 ─┤
                     └── green :8082  (신버전, 서비스 중)

전환은 Nginx가 어느 포트로 요청을 넘길지 바꾸는 것이다. 새 버전이 뜨는 동안에도 구버전이 계속 응답하므로 사용자는 아무것도 느끼지 못한다.

기동에 실패하면 전환을 하지 않는다. 구버전이 그대로 서비스하고 있으니 되돌릴 것도 없다.

3. 보안 그룹 정리 — 80을 열고 8080을 닫는다

EC2에 Docker로 배포하기에서 확인용으로 열어둔 8080을 이제 닫는다.

EC2 콘솔 → 인스턴스 → 보안 탭 → 보안 그룹 → 인바운드 규칙 편집

작업

유형

포트

소스

추가

HTTP

80

0.0.0.0/0

삭제

사용자 지정 TCP

8080

—

HTTP 80 을 추가하고 8080 규칙은 삭제한다

HTTP 80 을 추가하고 8080 규칙은 삭제한다

삭제는 행 오른쪽 끝의 [삭제] 버튼이다. 규칙을 지우는 것이지 값을 비우는 것이 아니다. 지운 뒤 [규칙 저장] 까지 눌러야 반영된다.

80번을 0.0.0.0/0 으로 여는 것에 대해 AWS 가 화면 아래에 경고를 띄운다. 웹 서버는 원래 누구나 접근해야 하는 포트이므로 여기서는 의도한 설정이 맞다.

외부에 노출하는 포트는 하나로 줄인다.

애플리케이션 포트를 인터넷에 직접 열어두면 Nginx를 우회해 접근할 수 있다. 접근 제어·로깅·TLS 종료를 Nginx 한 곳에서 하기로 했으면 입구도 하나여야 의미가 있다.

Docker Compose 장에서 ports 와 expose 를 다루며 이야기한 것과 같은 원칙이다.

4. Nginx 설치

EC2에 접속해 설치한다.

sudo dnf install -y nginx
sudo systemctl enable --now nginx
nginx -v

브라우저에서 http://<EIP> 로 접속하면 Nginx 기본 페이지가 보인다. 앞 절에서 80번을 열어 뒀으므로 포트 번호 없이 그대로 접속된다.

5. Nginx 설정 — 전환 지점을 한 줄로 분리

Amazon Linux 2023의 Nginx는 설정 구조가 이렇게 돼 있다.

/etc/nginx/nginx.conf
  └─ server { listen 80; server_name _; ...
       └─ include /etc/nginx/default.d/*.conf;   ← 이 안에 우리 설정을 넣는다
     }

기본 server 블록이 이미 80번을 잡고 있고, 그 안쪽에서 default.d/*.conf 를 읽어들인다. 그러니 server 블록을 새로 만들 필요가 없다. 여기에 얹기만 하면 된다.

5-1. 전환용 파일

배포할 때마다 바뀌는 것은 포트 번호 한 줄뿐이다. 그 한 줄만 따로 떼어 둔다.

echo 'set $service_url http://127.0.0.1:8081;' | sudo tee /etc/nginx/conf.d/service-url.inc

확장자를 .conf 로 하면 Nginx가 뜨지 않는다.

nginx.conf 는 conf.d/*.conf 를 http 블록 바로 아래에서 읽어들인다. 그런데 set 은 server 나 location 안에서만 쓸 수 있는 지시어다.

$ sudo nginx -t
nginx: [emerg] "set" directive is not allowed here in /etc/nginx/conf.d/service-url.conf:1
nginx: configuration file /etc/nginx/nginx.conf test failed

확장자를 .inc 로 두면 자동으로 읽히지 않는다. 우리가 원하는 위치(server 블록 안)에서 직접 include 할 것이기 때문이다.

5-2. 프록시 설정

/etc/nginx/default.d/app.conf

include /etc/nginx/conf.d/service-url.inc;

location / {
    proxy_pass          $service_url;
    proxy_set_header    Host $http_host;
    proxy_set_header    X-Real-IP $remote_addr;
    proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Proto $scheme;
}

# 관리용 엔드포인트는 인터넷에 내보내지 않는다
location = /actuator  { deny all; }
location /actuator/   { deny all; }

이 파일은 기본 server 블록 안에서 읽히므로 set 도 location 도 올바른 위치에 놓인다.

/actuator 를 막는 두 줄을 빼지 않는다. Nginx 가 받은 요청을 전부 앱으로 넘기면 http://<EIP>/actuator/health 도 인터넷 누구에게나 열린다. 지금은 health 하나만 노출돼 있지만, 나중에 누군가 설정을 바꿔 env·heapdump 같은 엔드포인트를 열면 환경 변수와 메모리 내용이 그대로 나간다.

배포 스크립트의 헬스 체크는 Nginx 를 거치지 않고 127.0.0.1:8081 로 직접 하므로 막아도 지장이 없다.

두 줄이 필요한 이유도 짚어 둔다. location /actuator/ 만 쓰면 끝에 슬래시가 없는 /actuator 는 걸리지 않고 앱으로 넘어간다. Spring 은 그 주소에서 열려 있는 엔드포인트 목록을 돌려준다. location = /actuator 가 정확히 그 주소 하나를 막는다.

curl -s -o /dev/null -w '%{http_code}\n' http://<EIP>/actuator/health   # 403
curl -s -o /dev/null -w '%{http_code}\n' http://<EIP>/actuator          # 403

proxy_set_header 를 넣는 이유

프록시를 거치면 애플리케이션이 보는 클라이언트 정보가 전부 Nginx의 것이 된다. 접속자 IP는 127.0.0.1 이 되고, 원래 요청한 호스트 이름도 잃는다.

원래 정보를 헤더에 담아 넘겨줘야 애플리케이션이 로그에 실제 접속자를 남길 수 있다.

설정을 검사하고 반영한다.

sudo nginx -t
sudo systemctl reload nginx

reload 전에 항상 nginx -t 를 먼저 실행한다. 설정이 잘못된 상태로 restart 하면 Nginx가 아예 뜨지 않아 서비스 전체가 죽는다. reload 는 설정이 틀리면 기존 프로세스를 유지한 채 거부하지만, 미리 확인하는 습관이 안전하다.

6. 컨테이너를 두 포트로 나눠 띄우기

아래 첫 줄 docker rm -f visit-counter 를 건너뛰면 안 된다.

EC2에 Docker로 배포하기에서 띄운 컨테이너가 남아 있으면, 배포하는 순간 애플리케이션 컨테이너가 세 개가 된다. 직접 해보면 t3.micro 는 버티지 못하고 SSH 조차 받지 못하는 상태가 된다.

$ ssh ec2-user@<EIP>
Connection timed out during banner exchange

이렇게 되면 콘솔에서 인스턴스 재부팅 말고는 방법이 없다. --restart unless-stopped 를 붙여 둔 컨테이너는 재부팅 후 알아서 살아난다.

기존 컨테이너를 정리하고 blue부터 띄운다. 애플리케이션 컨테이너 안이 8080 이라 호스트 쪽은 8081 부터 쓴다. 호스트 8080 은 EC2에 Docker로 배포하기에서 확인용으로 쓰던 자리이고, 4절에서 막 닫은 포트다.

docker rm -f visit-counter 2>/dev/null || true

docker run -d \
  --name visit-counter-blue \
  -p 127.0.0.1:8081:8080 \
  --env-file ~/app.env \
  --restart unless-stopped \
  ghcr.io/<GitHub사용자명>/visit-counter:1.0

태그가 장마다 바뀌는 이유와, 이 장을 두 번 돌릴 때 생기는 함정

장

태그

왜

Supabase

:pg

PostgreSQL 로 바꾼 판을 구분하려고

EC2에 Docker로 배포하기

:1.0

손으로 올리고 내리므로 사람이 읽기 좋은 이름

GitHub Actions로 자동 배포하기

:${{ github.sha }}

커밋마다 다른 태그여야 무엇이 배포됐는지 추적된다

이 장 6절

:1.0

처음 blue 를 세울 때의 출발점

7절 배포 스크립트는 "방금 배포한 태그만 남기고 나머지를 지운다." 그래서 커밋 해시 태그로 한 번 배포하고 나면 :1.0 은 서버에서 사라진다. 이 장을 처음부터 다시 하려고 6절로 돌아오면 manifest unknown 을 만나게 된다.

그때는 GHCR 에 남아 있는 최신 태그로 띄운다. 저장소의 Packages 탭에서 확인할 수 있다.

# :1.0 이 없다면 배포된 커밋 해시 태그를 쓴다
docker run -d \
  --name visit-counter-blue \
  -p 127.0.0.1:8081:8080 \
  --env-file ~/app.env \
  --restart unless-stopped \
  ghcr.io/<GitHub사용자명>/visit-counter:<최신태그>

-p 127.0.0.1:8081:8080 의 앞부분에 주목한다.

그냥 -p 8081:8080 으로 쓰면 모든 네트워크 인터페이스에 포트가 열린다. 보안 그룹이 막아주고는 있지만, 방어를 한 겹만 두는 셈이다.

127.0.0.1: 을 붙이면 서버 자기 자신에서만 접근할 수 있다. Nginx가 같은 서버에서 도니 이것으로 충분하고, 보안 그룹 설정이 잘못돼도 외부에서 8081로 직접 들어올 수 없다.

확인한다.

curl http://127.0.0.1:8081/actuator/health
# {"groups":["liveness","readiness"],"status":"UP"}

curl http://<EIP>
# 방문 횟수: 1        ← Nginx를 거쳐 도달한다

포트 번호 없이 접속되는 것을 브라우저로도 확인한다.

포트 번호 없이 80으로 접속된다 — Nginx가 앞에 섰다

포트 번호 없이 80으로 접속된다 — Nginx가 앞에 섰다

지금은 blue 하나만 떠 있다. 다음 절의 배포 스크립트가 돌기 시작하면 green 이 옆에 뜨고, 전환이 끝나면 blue 가 내려간다. 배포 도중에 docker ps 를 치면 그 짧은 순간 둘이 함께 보인다.

$ docker ps --filter name=visit-counter --format "table {{.Names}}\t{{.Ports}}\t{{.Status}}"
NAMES                  PORTS                      STATUS
visit-counter-green    127.0.0.1:8082->8080/tcp   Up 12 seconds     ← 배포 중에 새로 뜬 쪽
visit-counter-blue     127.0.0.1:8081->8080/tcp   Up 4 minutes      ← 아직 서비스 중인 쪽

7. 배포 스크립트 — 블루·그린

~/deploy.sh 를 다음으로 교체한다.

~/deploy.sh

#!/bin/bash
set -euo pipefail

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

# ── 0. 동시에 두 번 돌지 않게 잠근다 ───────────────────────
# 워크플로의 concurrency 는 Actions 끼리만 막는다. 사람이 서버에서 직접
# 돌린 배포와 겹치는 것까지 막으려면 서버 쪽에도 잠금이 있어야 한다.
exec 9> /tmp/deploy.lock
flock -n 9 || { echo "!!  다른 배포가 진행 중이다. 끝난 뒤 다시 실행한다."; exit 1; }

# ── 1. 지금 서비스 중인 색을 판별한다 ───────────────────────
CURRENT_PORT="$(sed -n 's#.*127\.0\.0\.1:\([0-9]\{4\}\).*#\1#p' "$INC")"
case "$CURRENT_PORT" in
  8081) CURRENT=blue;  TARGET=green; TARGET_PORT=8082 ;;
  8082) CURRENT=green; TARGET=blue;  TARGET_PORT=8081 ;;
  *)    echo "!!  $INC 에서 포트를 읽지 못했다 (읽은 값: '${CURRENT_PORT}')"
        echo "    아무것도 바꾸지 않고 중단한다."; exit 1 ;;
esac
echo "==> 현재 $CURRENT(:$CURRENT_PORT) / 새로 띄울 쪽 $TARGET(:$TARGET_PORT)"

# ── 2. 쉬고 있는 쪽에 새 버전을 띄운다 ──────────────────────
docker pull "$IMAGE"
docker rm -f "visit-counter-$TARGET" 2>/dev/null || true
docker run -d \
  --name "visit-counter-$TARGET" \
  -p "127.0.0.1:${TARGET_PORT}:8080" \
  --env-file "$HOME/app.env" \
  --restart unless-stopped \
  "$IMAGE"

# ── 3. 준비될 때까지 기다린다 ───────────────────────────────
echo "==> 헬스 체크"
READY=0
for i in $(seq 1 30); do
  if curl -fs "http://127.0.0.1:${TARGET_PORT}/actuator/health" | grep -q '"status":"UP"'; then
    echo "    준비 완료 ($(( (i-1) * 2 ))초)"
    READY=1
    break
  fi
  sleep 2
done

# ── 4. 준비되지 않았으면 전환하지 않는다 ────────────────────
if [ "$READY" -ne 1 ]; then
  echo "!!  기동 실패 - 전환하지 않는다. $CURRENT 가 계속 서비스한다."
  # 진짜 원인은 긴 스택 트레이스 위쪽에 있어 마지막 몇 줄로는 안 보인다.
  # 원인이 적히는 줄(FATAL, SQLState)을 먼저 뽑아 보여준다.
  echo "    ---- 원인 ----"
  docker logs "visit-counter-$TARGET" 2>&1 | grep -m 4 -E 'FATAL|SQLState' || true
  echo "    ---- 마지막 15줄 ----"
  docker logs --tail 15 "visit-counter-$TARGET" 2>&1 || true
  docker rm -f "visit-counter-$TARGET"
  exit 1
fi

# ── 5. 전환 — 검사에 실패하면 되돌린다 ──────────────────────
PREV="$(cat "$INC")"
echo "set \$service_url http://127.0.0.1:${TARGET_PORT};" | sudo tee "$INC" > /dev/null
if ! sudo nginx -t; then
  printf '%s\n' "$PREV" | sudo tee "$INC" > /dev/null
  echo "!!  nginx 설정 검사 실패 - 원래 설정으로 되돌렸다."
  docker rm -f "visit-counter-$TARGET"
  exit 1
fi
sudo systemctl reload nginx
echo "==> $TARGET 으로 전환 완료"

# ── 6. 이전 쪽 정리 ─────────────────────────────────────────
# reload 직후에도 옛 워커가 진행 중이던 요청을 마저 처리하고 있다.
# 곧바로 지우면 그 요청들만 502 가 된다.
sleep 5
# rm -f 는 SIGKILL 이라 처리 중인 요청을 그 자리에서 끊는다.
# stop 은 SIGTERM 을 보내 Spring Boot 가 남은 요청을 마무리하고 내려가게 한다.
docker stop -t 30 "visit-counter-$CURRENT" > /dev/null 2>&1 || true
docker rm "visit-counter-$CURRENT" > /dev/null 2>&1 || true

# 낡은 이미지를 치우지 않으면 디스크가 찬다.
# 방금 배포한 태그만 남기고 같은 저장소의 나머지 태그를 지운다.
docker images "ghcr.io/<GitHub사용자명>/visit-counter" --format '{{.Repository}}:{{.Tag}}' \
  | grep -v ":${TAG}$" | xargs -r docker rmi -f > /dev/null 2>&1 || true
docker image prune -f > /dev/null          # 태그가 떨어져 나간 <none> 이미지까지
echo "==> 정리 완료"

1번과 5번이 왜 이렇게 생겼는지 짚고 간다.

1번 — grep -q 8081 로는 부족하다. .inc 파일이 없거나 내용이 깨져 있으면 grep 이 실패하고 else 로 떨어져 TARGET=blue 가 된다. 그런데 그때 서비스 중인 쪽이 blue 였다면 2번에서 살아 있는 컨테이너를 지워 버린다. 무중단을 만들려다 중단을 만드는 셈이다. 포트를 직접 읽어서 8081·8082 둘 중 하나가 아니면 아무것도 하지 않고 멈추는 편이 안전하다.

5번 — nginx -t 를 통과하지 못하면 원래대로 돌려놓는다. 파일을 먼저 쓰고 검사하기 때문에, 검사에 실패한 채로 두면 파일과 실제 서비스 상태가 어긋난다. 그 상태로 다음 배포를 하면 1번의 판별이 뒤집힌다.

6번 — 이미지 정리를 --filter until=... 로 하지 않는다. docker image prune -af --filter "until=72h" 는 「72시간보다 오래된」 이미지만 지운다. 매일 배포하면 디스크가 차는 속도를 따라가지 못한다. 방금 배포한 태그만 남기고 나머지를 이름으로 지우는 편이 확실하다.

6번 — sleep 5 는 낭비가 아니다. systemctl reload 는 마스터 프로세스가 신호를 받은 시점에 바로 끝난다. 옛 워커 프로세스는 처리 중이던 요청을 끝낼 때까지 살아 있고, 그 워커들은 아직 옛 포트를 보고 있다. 여기서 바로 컨테이너를 지우면 그 요청들만 502 를 받는다.

6번 — 옛 컨테이너는 rm -f 가 아니라 stop 으로 내린다. docker rm -f 는 Docker 기본 명령어에서 본 kill 과 같아서 SIGKILL 을 보낸다. 앱은 정리할 틈 없이 죽고, 그 순간 처리 중이던 요청은 끊긴다. docker stop 은 SIGTERM 을 먼저 보내고, Spring Boot(3.4 이상)는 이를 받으면 새 요청을 거절하고 받아 둔 요청을 마저 처리한 뒤 내려간다(graceful shutdown). -t 30 은 그 마무리를 최대 30초 기다리라는 뜻이다. 이것이 동작하려면 PID 1 이 java 여야 한다 — 엔트리포인트 스크립트가 exec "$@" 로 끝나는 이유다.

0번 — flock 은 서버 쪽 잠금이다. 워크플로의 concurrency 는 GitHub Actions 실행끼리만 줄을 세운다. 누군가 서버에서 ~/deploy.sh 를 손으로 돌리는 동안 워크플로 배포가 들어오면 둘이 겹친다. 잠금을 쥔 실행이 끝나면 잠금은 저절로 풀린다.

3번 단계가 이 스크립트의 핵심이다.

헬스 체크 없이 곧바로 전환하면 무중단은커녕 더 나쁘다. 기존 배포는 수십 초 끊겼지만, 이 경우엔 준비도 안 된 컨테이너로 요청을 보내 502 Bad Gateway 가 쏟아진다.

docker ps 에 Up 이라고 나와도 믿으면 안 된다. 그건 프로세스가 살아 있다는 뜻이지 요청을 받을 준비가 됐다는 뜻이 아니다. Docker Compose 장에서 depends_on 만으로는 부족해 healthcheck 가 필요했던 것과 정확히 같은 문제다.

직접 확인하고 싶다면 3번 단계를 통째로 주석 처리하고 배포해 보자. 502가 어떻게 생겼는지 한 번 봐두면 좋다.

chmod +x ~/deploy.sh

8. 배포 키가 할 수 있는 일을 줄인다

스크립트가 sudo 를 쓰는데 비밀번호를 묻지 않고 돈다. 이유를 확인해 보면 생각보다 넓게 열려 있다.

sudo cat /etc/sudoers.d/90-cloud-init-users
# ec2-user ALL=(ALL) NOPASSWD:ALL

Amazon Linux 2023 은 처음부터 ec2-user 에게 비밀번호 없는 sudo 를 전부 열어 둔다. 인스턴스를 만들 때 cloud-init 이 이 파일을 넣는다. 그리고 자동 배포 장의 배포 키는 ec2-user 로 로그인한다. 즉 GitHub Secrets 에 든 배포 키가 새면 서버의 root 권한이 함께 샌다.

sudoers 에 "필요한 명령 세 개만 허용" 하는 줄을 더해도 소용이 없다. 위의 NOPASSWD:ALL 이 이미 있기 때문이다. 게다가 ec2-user 는 docker 그룹이라 sudo 없이도 root 와 같다(Docker 환경 설정 2절).

그래서 제한할 대상은 sudo 가 아니라 배포 키 그 자체다. SSH 는 authorized_keys 의 키 한 줄마다 "이 키로 들어오면 무엇을 요청하든 이 명령만 실행한다"를 정할 수 있다(강제 명령).

8-1. 관문 스크립트

배포 키로 들어온 요청을 검사해 ~/deploy.sh <40자 커밋 해시> 꼴일 때만 통과시킨다.

~/bin/deploy-gate

#!/bin/bash
# 배포 키로 들어온 접속은 무엇을 요청하든 이 스크립트만 실행된다.
# 허용하는 것은 "~/deploy.sh <40자 커밋 해시>" 하나뿐이다.
set -euo pipefail
cmd="${SSH_ORIGINAL_COMMAND:-}"
echo "$(date '+%F %T') $cmd" >> "$HOME/deploy-gate.log"
re='^~/deploy\.sh ([0-9a-f]{40})$'
if [[ "$cmd" =~ $re ]]; then
  exec "$HOME/deploy.sh" "${BASH_REMATCH[1]}"
fi
echo "허용되지 않은 명령이다: $cmd" >&2
exit 1
mkdir -p ~/bin
vi ~/bin/deploy-gate      # 위 내용
chmod 755 ~/bin/deploy-gate

SSH_ORIGINAL_COMMAND 에는 접속한 쪽이 실행해 달라고 보낸 명령이 들어온다. 워크플로의 script: 줄이 보내는 것도 정확히 이 꼴이다 — ~/deploy.sh 뒤에 40자 커밋 해시가 붙는다.

8-2. 배포 키 줄 앞에 강제 명령을 붙인다

자동 배포 3절에서 authorized_keys 에 덧붙인 배포 키 줄을 찾아, 맨 앞에 옵션을 붙인다.

cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak
vi ~/.ssh/authorized_keys
ssh-rsa AAAA... visit-counter                                          ← 사람이 쓰는 키: 그대로 둔다
restrict,command="/home/ec2-user/bin/deploy-gate" ssh-ed25519 AAAA... github-actions-deploy
  • command="..." — 이 키로 들어오면 요청과 상관없이 이 명령을 실행한다.

  • restrict — 포트 포워딩·터미널 할당 등 부가 기능을 모두 끈다.

사람이 쓰는 키(첫 줄)에는 붙이지 않는다. 그 줄까지 바꾸면 내가 서버에 셸로 들어갈 길이 막힌다. 고치기 전에 백업을 떠 두고, 고친 뒤에는 새 터미널에서 사람 키로 접속되는지부터 확인한다.

8-3. 확인

로컬에서 배포 키로 아무 명령이나 보내 본다.

ssh -i ~/.ssh/deploy_key ec2-user@<EIP> 'cat /etc/passwd'
# 허용되지 않은 명령이다: cat /etc/passwd

ssh -i ~/.ssh/deploy_key ec2-user@<EIP> '~/deploy.sh abc'
# 허용되지 않은 명령이다: ~/deploy.sh abc

셸도, 형식이 틀린 배포도 거절된다. 이제 배포 키가 새도 할 수 있는 일은 GHCR 에 이미 있는 이미지 중 하나로 다시 배포하는 것뿐이다. 워크플로가 보낸 요청은 ~/deploy-gate.log 에 남는다.

2026-10-19 21:03:39 ~/deploy.sh 6fea8e9ae315d11923af4e553d96558300f62fca

서버 안에서 사람이 sudo 를 쓰는 것은 그대로다. 이 절이 줄인 것은 "배포 키로 할 수 있는 일"이다. ec2-user 의 NOPASSWD:ALL 자체를 걷어내려면 배포 전용 사용자를 따로 만들고 sudoers·docker 그룹을 다시 설계해야 한다. 그것은 이 교안의 범위를 넘는다.

9. 워크플로는 그대로 둔다

GitHub Actions로 자동 배포하기에서 만든 워크플로는 한 글자도 고칠 필요가 없다.

      - 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 }}

워크플로는 "배포해라"라고만 말하고, 어떻게 배포할지는 서버가 정한다. 배포 방식을 통째로 바꿨는데도 파이프라인이 그대로인 것은 역할이 잘 나뉘어 있다는 뜻이다.

10. 무중단 확인

로컬 터미널에서 요청을 계속 보내면서 배포한다.

while true; do
  curl -s -o /dev/null -w "%{http_code} " http://<EIP>/
  sleep 0.3
done
# Windows PowerShell — curl 은 Invoke-WebRequest 의 별칭이므로 curl.exe 로 부른다
while ($true) {
  Write-Host -NoNewline "$(curl.exe -s -o NUL -w '%{http_code}' http://<EIP>/) "
  Start-Sleep -Milliseconds 300
}

다른 터미널에서 코드를 고쳐 push하면 워크플로가 돌면서 배포가 진행된다. 그동안 위 루프를 지켜본다.

200 200 200 200 200 200 200 200 200 200 200 200 ...

전환 순간에도 200 만 이어진다. GitHub Actions로 자동 배포하기의 배포에서 보이던 000 이 사라졌다.

실제로 재어 보면 0.2 초 간격으로 요청을 보내는 동안 push 부터 배포 완료까지 298번 모두 200 이었고, 그 밖의 코드는 한 번도 나오지 않았다. 이 배포는 8절의 강제 명령을 거쳐 실행됐다.

$ tr ' ' '\n' < codes.txt | sort | uniq -c
    298 200

전환이 실제로 일어났는지는 서버에서 확인한다.

cat /etc/nginx/conf.d/service-url.inc
# set $service_url http://127.0.0.1:8082;

docker ps --filter name=visit-counter
# green 하나만 남아 있다

10-1. 기동에 실패하면 어떻게 되는지도 확인한다

무중단보다 중요한 것은 망가진 버전이 서비스로 나가지 않는 것이다. 일부러 실패시켜 본다.

# 비밀번호를 일부러 틀리게 바꾼 뒤 배포한다
cp ~/app.env /tmp/app.env.good
sed 's/^SPRING_DATASOURCE_PASSWORD=.*/SPRING_DATASOURCE_PASSWORD=wrong-on-purpose/' \
  /tmp/app.env.good > ~/app.env

~/deploy.sh <태그>
echo "종료 코드 = $?"

cp /tmp/app.env.good ~/app.env      # 원상 복구

헬스 체크가 60초 동안 통과하지 못하고 이렇게 끝난다.

==> 현재 green(:8082) / 새로 띄울 쪽 blue(:8081)
==> 헬스 체크
!!  기동 실패 - 전환하지 않는다. green 가 계속 서비스한다.
...
종료 코드 = 1

확인할 것은 세 가지다.

  • service-url.inc 가 그대로 8082 다 — 전환되지 않았다

  • 그동안 curl 루프에는 200 만 찍혔다 — 구버전이 계속 서비스했다

  • 종료 코드가 1 이다 — GitHub Actions 에서도 빨간색으로 끝난다

왜 실패했는지는 컨테이너 로그에 남는다. 스크립트가 컨테이너를 지우기 전에 원인 줄을 뽑아 출력한다.

    ---- 원인 ----
WARN 1 --- [main] org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 0, SQLState: 28P01
WARN 1 --- [main] org.hibernate.orm.jdbc.error : FATAL: password authentication failed for user "postgres"

마지막 몇 줄만 보면 이 줄이 안 보인다. 이 실패의 로그는 155줄쯤 되고, 원인 줄은 23번째 줄 근처에 있다. 끝부분은 Hibernate 의 Unable to determine Dialect without JDBC metadata 스택 트레이스가 차지한다. 설정이 빠진 것처럼 읽히지만 「DB 에 붙지 못해 DB 종류를 알아내지 못했다」는 결과일 뿐이다. 스크립트가 FATAL·SQLState 줄을 따로 grep 하는 이유다.

28P01 은 PostgreSQL 의 잘못된 비밀번호 상태 코드다. 연결이 안 된 것(Network is unreachable)과는 다른 문제라는 것을 여기서 구분할 수 있다 — EC2에 Docker로 배포하기 장의 표와 같이 보면 좋다.

11. 이 방식의 한계

서버 한 대짜리 블루·그린이다. 서버 자체가 죽으면 대책이 없다.

배포 중에는 메모리를 두 배로 쓴다. t3.micro 에서 실제로 재어 보면 이렇다.

사용 메모리

컨테이너 한 개 (+ nginx, dockerd)

약 470MB

전환 중 두 개 동시

약 690MB

가용 메모리

913MB

들어간다. 다만 여유가 220MB 남짓이다. 애플리케이션이 지금보다 무거워지거나 JVM 힙을 더 주면 커널이 둘 중 하나를 종료(OOM kill)시킨다. 그렇게 되면 하필 구버전이 죽어 무중단이 무너질 수도 있다.

여유를 확보하는 방법은 세 가지다 — 인스턴스를 t3.small 로 올리거나, 스왑 파일을 만들거나, 컨테이너마다 --memory 와 -e JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60 으로 상한을 두는 것이다.

스왑 파일 1 GiB 만들기

스왑은 메모리가 모자랄 때 디스크 일부를 메모리처럼 빌려 쓰는 공간이다. 디스크는 메모리보다 훨씬 느리므로 평소에 쓰면 안 되고, 전환 순간처럼 잠깐 넘칠 때 OOM 대신 느려지게 만드는 안전망으로 둔다.

sudo dd if=/dev/zero of=/swapfile bs=1M count=1024   # 1 GiB 짜리 빈 파일
sudo chmod 600 /swapfile                              # root 만 읽고 쓰게
sudo mkswap /swapfile
sudo swapon /swapfile

# 재부팅해도 켜지도록 등록
echo '/swapfile none swap defaults 0 0' | sudo tee -a /etc/fstab

free -h      # Swap 줄에 1.0Gi 가 보이면 된다

EBS 를 30 GiB 로 잡아 두었으므로(EC2 인스턴스 생성 2-1 절) 1 GiB 는 부담이 없다. /etc/fstab 은 잘못 쓰면 부팅이 안 될 수 있는 파일이라 > 가 아니라 tee -a(덧붙이기)로 한 줄만 더한다.

데이터베이스 커넥션도 잠시 두 배가 된다. Supabase 장에서 커넥션 풀을 5로 제한해 둔 이유가 이것이다. 세션 풀러의 클라이언트 상한(무료 플랜 15개)에 걸리면 새 컨테이너가 EMAXCONNSESSION 으로 기동하지 못한다 — 스크립트는 전환하지 않고 멈추므로 서비스는 멀쩡하지만, 배포는 계속 실패한다.

스키마 변경은 이 방식으로 해결되지 않는다. 컬럼을 지우는 배포라면 전환 순간 구버전과 신버전이 같은 테이블을 동시에 본다. 이런 변경은 "컬럼 추가 → 양쪽 호환되는 코드 배포 → 구버전 정리 → 컬럼 삭제" 처럼 여러 번에 나눠 진행해야 한다.

규모가 커지면 서버를 여러 대 두고 앞단에 로드 밸런서(AWS라면 ALB)를 세운다. 원리는 같다 — 새 서버 그룹을 띄우고, 준비된 것을 확인하고, 트래픽을 넘긴다.

12. 실습 정리

실습을 마쳤다면 AWS 리소스를 정리한다.

  • EC2 인스턴스 종료(Terminate) — 중지(Stop)만 해두면 EBS 요금이 계속 나간다.

  • 탄력적 IP 해제(Release) — 인스턴스에서 분리된 채로 남아 있으면 요금이 부과된다. 가장 흔히 놓치는 항목이다.

  • 보안 그룹 — 인스턴스를 종료하면 함께 정리한다.

Supabase 무료 플랜과 GHCR은 그대로 둬도 요금이 나가지 않는다.

여기까지 만든 것을 정리하면 이렇다.

git push
   ↓
GitHub Actions — 이미지 빌드 → GHCR push → 서버에 배포 지시
   ↓
EC2 — 쉬고 있는 쪽에 새 버전 기동 → 헬스 체크 → Nginx 전환 → 구버전 정리
   ↓
Supabase — 데이터는 애플리케이션 바깥에 남는다

컨테이너를 통째로 갈아치우는 배포가 가능한 것은 애플리케이션이 상태를 갖지 않기 때문이다. 방문 횟수가 컨테이너 안이 아니라 데이터베이스에 있으니, 컨테이너는 언제든 버리고 새로 만들 수 있다. Docker 개요 장에서 이야기한 "컨테이너는 일회용"이 배포까지 이어진 셈이다.

마지막 수정 2026년 10월 22일