로컬에서 만든 이미지를 EC2로 옮겨 실행한다. 이 과정을 손으로 한 번 해봐야 GitHub Actions로 자동 배포하기에서 무엇이 자동화되는지 보인다.
학습 목표
Amazon Linux 2023에 Docker를 설치하고 실행 권한을 설정할 수 있다.
이미지를 레지스트리에 올리고 서버에서 내려받아 실행할 수 있다.
데이터베이스 접속 정보를 이미지에 굽지 않고 환경 변수로 주입할 수 있다.
보안 그룹으로 애플리케이션 포트를 제어할 수 있다.
1. EC2에 Docker 설치
EC2 인스턴스에 SSH로 접속한 뒤 진행한다.
# Docker 설치
sudo dnf install -y docker
# 부팅 시 자동 시작 + 지금 바로 시작
sudo systemctl enable --now docker
# ec2-user 가 sudo 없이 docker 를 쓸 수 있게 한다
sudo usermod -aG docker ec2-userusermod 직후에는 아직 권한이 없다.
$ docker ps
permission denied while trying to connect to the Docker daemon socket그룹 변경은 새로 로그인할 때 적용된다. exit 로 나갔다가 다시 SSH 접속하면 정상 동작한다.
설치가 잘못된 줄 알고 되돌리기 쉬운 지점이다. 권한 오류가 나면 먼저 재접속부터 해본다.
재접속 후 확인한다.
$ docker version --format 'Client {{.Client.Version}} / Server {{.Server.Version}}'
Client 25.0.14 / Server 25.0.16
$ docker info | grep -E "Server Version|Storage Driver|Total Memory"
Server Version: 25.0.16
Storage Driver: overlay2
Total Memory: 913.2MiB버전이 최신이 아니어도 정상이다. dnf 가 설치하는 것은 Amazon Linux 저장소가 검증한 버전이라 docker.com 의 최신 버전보다 뒤처져 있다. 실습에 필요한 기능은 모두 들어 있다.
Total Memory: 913.2MiB 를 기억해 둔다. t3.micro 의 1GiB 중 커널이 쓰는 몫을 뺀 실제 가용량이다. 무중단 배포 장에서 컨테이너를 두 개 동시에 띄울 때 이 숫자가 문제가 된다.
1-1. 컨테이너 로그 크기 제한
컨테이너를 띄우기 전에 한 번 해 둔다. 기본 설정에서는 컨테이너 로그가 디스크에 끝없이 쌓인다. 서버는 몇 달씩 켜 두는 것이라 결국 디스크를 채운다 — Docker 환경 설정 3절에서 본 문제다.
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
EOF
sudo systemctl restart docker
docker info --format '{{.LoggingDriver}}'
# json-file이제 컨테이너 하나의 로그는 최대 30 MB 로 묶인다. 이 설정은 이후에 만드는 컨테이너부터 적용되므로, 아직 아무것도 띄우지 않은 지금 해 두는 것이 좋다.
daemon.json 의 JSON 이 틀리면 Docker 가 아예 뜨지 않는다. systemctl restart docker 가 실패하면 sudo journalctl -u docker -n 20 으로 원인을 본다. 쉼표 하나가 빠져도 그렇다. 파일을 지우고 다시 시작하면 원래대로 돌아온다.
2. 이미지를 서버로 어떻게 옮기는가
여기서 한 가지 정하고 가야 한다. EC2에서 소스를 받아 빌드할 것인가, 로컬에서 빌드한 이미지를 옮길 것인가?
이 교안은 로컬(나중에는 CI)에서 빌드하고 서버는 실행만 한다. 이유는 두 가지다.
빌드 환경이 서버마다 달라지면 안 된다. 서버에서 빌드하면 그 서버에 깔린 JDK·Gradle 버전에 결과가 좌우된다. 서버가 여러 대면 서로 다른 결과물이 돌아갈 수도 있다. 이미지를 한 번 만들어 그대로 옮기면 어느 서버에서든 똑같은 것이 돈다. 이것이 Docker 개요 장에서 말한 "빌드 한 번, 어디서나 실행"이다.
t3.micro는 빌드하기에 작다. 메모리 1GB에서 Gradle 빌드는 버겁고, 빌드하는 동안 서버 자원을 그만큼 쓴다. 운영 중인 서버라면 서비스에 영향이 간다.
그래서 흐름은 이렇게 된다.
로컬 레지스트리(GHCR) EC2
──── ───────────── ────
docker build ──push──▶ 이미지 저장 ──pull──▶ docker run레지스트리는 이미지를 올려두고 내려받는 창고다. Docker Hub가 가장 유명하고, 이 교안은 GHCR(GitHub Container Registry)을 쓴다. GitHub 저장소를 이미 쓰고 있으니 별도 가입이 필요 없고, GitHub Actions로 자동 배포하기에서 GitHub Actions와 연결할 때 이점이 크다.
3. 이미지 빌드와 GHCR 업로드
3-0. GitHub 저장소 준비
여기까지는 로컬 폴더만으로 실습했다. 이 장부터는 GitHub 저장소가 필요하다 — GHCR 패키지가 저장소에 딸려 붙고, GitHub Actions로 자동 배포하기의 자동 배포는 push 를 신호로 돈다.
GitHub 에서 visit-counter 라는 이름으로 저장소를 만든다. 공개·비공개 어느 쪽이든 된다.
저장소에 넣을 것은 Docker Compose 장에서 만든 app/ 디렉터리의 내용이다. app/ 을 통째로 올리는 것이 아니라 그 안의 파일들이 저장소 루트에 오도록 넣는다.
visit-counter/ ← 저장소 루트
├── Dockerfile
├── docker-entrypoint.sh
├── build.gradle
├── settings.gradle
└── src/Dockerfile 이 저장소 루트에 있어야 한다. GitHub Actions로 자동 배포하기의 워크플로가 저장소 루트를 빌드 컨텍스트로 삼기 때문이다. app/Dockerfile 처럼 한 단계 안에 두면 failed to read dockerfile 로 실패한다.
docker-compose.yml, secrets/, nginx/ 는 올리지 않는다. 로컬 실습용이고, secrets/ 에는 비밀번호가 들어 있다.
cd visit-counter
git init
git add .
git commit -m "feat: 방문자 카운터"
git branch -M main
git remote add origin https://github.com/<GitHub사용자명>/visit-counter.git
git push -u origin main3-1. 이미지 빌드
Supabase 장의 PostgreSQL 전환까지 마친 상태로, 저장소 루트에서 빌드한다.
GHCR에 올릴 이미지는 이름 규칙이 정해져 있다.
ghcr.io/<GitHub사용자명>/<이미지이름>:<태그>docker build --platform linux/amd64 -t ghcr.io/<GitHub사용자명>/visit-counter:1.0 .--platform linux/amd64 는 Apple Silicon(M 시리즈) Mac 에서 반드시 필요하다.
docker build 는 기본적으로 빌드하는 PC 의 CPU 구조로 이미지를 만든다. M 시리즈 Mac 에서 그냥 빌드하면 arm64 이미지가 나오고, x86 인 t3.micro 에서 실행하는 순간 이렇게 끝난다.
exec /docker-entrypoint.sh: exec format error빌드도 push 도 pull 도 성공하고 실행할 때만 실패하므로 원인을 찾기 어렵다. 내 이미지가 어느 쪽인지는 이렇게 확인한다.
docker image inspect ghcr.io/<GitHub사용자명>/visit-counter:1.0 --format '{{.Architecture}}'
# amd64 여야 한다Windows·Intel Mac 은 원래 amd64 라서 옵션을 붙여도 결과가 같다. 그래서 이 교안은 누구나 붙이도록 적어 둔다. M 시리즈에서는 에뮬레이션으로 빌드하므로 평소보다 느리다. GitHub Actions로 자동 배포하기부터는 x86 러너가 빌드하므로 이 문제가 없다.
이미지 이름은 전부 소문자여야 한다.
GitHub 사용자명에 대문자가 있어도 이미지 이름에는 소문자로 적는다.
$ docker build -t ghcr.io/MyName/visit-counter:1.0 .
ERROR: failed to build: invalid tag "ghcr.io/MyName/visit-counter:1.0": repository name must be lowercase빌드 단계에서 바로 걸리므로 알아채기는 쉽다. GitHub Actions로 자동 배포하기의 워크플로는 사용자명을 자동으로 소문자로 바꿔 주므로 같은 문제가 생기지 않는다.
3-2. GHCR 로그인
GHCR에 올리려면 write:packages 권한을 가진 토큰이 필요하다.
GitHub → 우상단 프로필 → Settings → 좌측 맨 아래 Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic)
Developer settings → Personal access tokens → Tokens (classic)
Developer settings 는 좌측 사이드바 맨 아래에 있다. 계정 설정 항목이 길어서 스크롤해야 보인다. 처음 가는 사람이 가장 많이 헤매는 지점이다.
주소를 바로 여는 편이 빠르다 — https://github.com/settings/tokens
체크할 것은 write:packages 하나다. 만료 기간도 실습 기간에 맞춰 짧게 잡는다.
write:packages 를 체크하면 repo 까지 함께 켜지고 해제할 수 없다
write:packages 를 체크하면 repo 가 같이 켜지고, 끌 수 없다.
화면을 보면 repo 와 그 하위 항목들이 회색으로 바뀌면서 체크된다. 해제하려고 눌러도 반응하지 않는다. GHCR 이 비공개 패키지 권한을 저장소 권한에 얹어 두었기 때문이다.
repo 는 "Full control of private repositories" 다. 즉 이 토큰 하나로 내 모든 비공개 저장소를 읽고 쓸 수 있다. 패키지 하나 올리려고 만든 토큰치고는 범위가 너무 넓다.
그래서 이렇게 다룬다.
만료를 짧게 잡는다. 실습이 끝나면 저절로 죽는다.
다 쓰고 나면 직접 삭제한다 — Tokens (classic) 목록에서 Delete.
토큰을 서버나 저장소에 두지 않는다.
docker login한 번에만 쓰고 만다.
Fine-grained token 으로는 대신할 수 없다. 저장소 하나만 고르는 Fine-grained token 이 범위가 더 좁아 보이지만, GitHub 공식 문서는 Container registry(GHCR) 인증에 classic 토큰만 지원한다고 적고 있다. 범위를 좁히는 방법은 토큰 종류가 아니라 짧은 만료와 즉시 삭제다.
GitHub Actions로 자동 배포하기부터는 이 토큰이 필요 없다. GitHub Actions 가 실행할 때마다 발급하는 GITHUB_TOKEN 을 쓰는데, 그 토큰은 해당 저장소 범위로 제한되고 워크플로가 끝나면 폐기된다. 이 장에서 손으로 한 번 해보는 이유는 자동화가 무엇을 대신해 주는지 보기 위해서다.
토큰을 만들려고 하면 비밀번호나 패스키를 다시 묻는다. GitHub 의 sudo 모드다. 로그인이 풀린 것이 아니므로 그대로 인증하면 된다.
만들어진 토큰은 이 화면에서만 보인다. 페이지를 벗어나면 다시 볼 수 없다. 복사해서 바로 다음 단계로 넘어간다.
# macOS / Linux / Git Bash — 토큰을 변수에 담아 로그인한다 (셸 기록에 토큰이 남지 않도록)
read -s GHCR_TOKEN
echo "$GHCR_TOKEN" | docker login ghcr.io -u <GitHub사용자명> --password-stdin
unset GHCR_TOKEN# Windows PowerShell — 화면에 보이지 않게 입력받아 넘긴다
$t = Read-Host -AsSecureString "GHCR token"
[System.Net.NetworkCredential]::new("", $t).Password | docker login ghcr.io -u <GitHub사용자명> --password-stdin
Remove-Variable tGitHub CLI를 쓰고 있다면 토큰을 따로 만들지 않아도 된다.
gh auth refresh -s write:packages
echo $(gh auth token) | docker login ghcr.io -u <GitHub사용자명> --password-stdin토큰을 명령어에 직접 적지 않는다.
docker login ghcr.io -u 이름 -p ghp_xxxxx # 하지 말 것셸 기록(~/.bash_history)에 토큰이 그대로 남는다. 서버를 누군가와 공유하거나 화면을 공유하는 상황이라면 그대로 노출된다.
3-3. push
docker push ghcr.io/<GitHub사용자명>/visit-counter:1.0올라간 이미지는 GitHub 프로필의 Packages 탭에서 볼 수 있다.
GitHub Packages 에 올라간 이미지 — Private 배지를 확인한다
3-4. 패키지를 공개로 바꾸기
처음 올린 패키지는 비공개다. 그대로 두면 EC2에서 내려받을 수 없다.
$ docker pull ghcr.io/이름/visit-counter:1.0
Error response from daemon: Head "https://ghcr.io/v2/이름/visit-counter/manifests/1.0": unauthorizedunauthorized 라고만 나온다. 로그인 문제처럼 보이지만 아니다. 이미지가 없는 것도, 오타도 아니고 패키지가 비공개라 익명 사용자에게 보이지 않는 것이다.
구분하는 요령: 이름이 틀렸으면 manifest unknown 또는 name unknown 이 나온다. unauthorized 는 「있긴 한데 너에게는 안 보인다」 는 뜻이다.
패키지 페이지 오른쪽의 Package settings → 맨 아래 Danger Zone → Change visibility
Package settings → Danger Zone → Change visibility
Public 을 고르고, 확인란에 패키지 이름을 직접 입력해야 버튼이 활성화된다.
패키지 이름을 직접 입력해야 전환된다
이렇게 하면 EC2에서 로그인 없이 docker pull 할 수 있다.
공개해도 되는가? 이미지 안에는 애플리케이션 코드만 들어 있고 접속 정보는 들어 있지 않다. 비밀번호는 실행할 때 환경 변수로 주입하기 때문이다. 이미지를 굽는 시점에 비밀번호를 넣었다면 공개해서는 안 된다.
비공개로 두고 싶다면 EC2에서도 read:packages 토큰으로 docker login 하면 된다. 다만 서버에 토큰을 두게 되므로 관리 대상이 하나 늘어난다.
4. EC2에서 내려받아 실행
4-1. 접속 정보를 파일로 준비
Supabase 장에서 정리해 둔 세 값을 EC2에 파일로 만든다.
cat > ~/app.env <<'EOF'
SPRING_DATASOURCE_URL=jdbc:postgresql://<풀러호스트>:5432/postgres
SPRING_DATASOURCE_USERNAME=postgres.<프로젝트REF>
SPRING_DATASOURCE_PASSWORD=<비밀번호>
EOF
# 소유자만 읽을 수 있게 한다
chmod 600 ~/app.env--env-file 은 따옴표를 값의 일부로 본다.
SPRING_DATASOURCE_PASSWORD='mypassword'이렇게 적으면 비밀번호가 'mypassword' 가 되어 인증에 실패한다. 셸이 아니라 Docker가 파일을 읽기 때문이다. 따옴표 없이 적는다.
4-2. 실행
docker pull ghcr.io/<GitHub사용자명>/visit-counter:1.0
docker run -d \
--name visit-counter \
-p 8080:8080 \
--env-file ~/app.env \
--restart unless-stopped \
ghcr.io/<GitHub사용자명>/visit-counter:1.0--restart unless-stopped 는 서버가 재부팅돼도 컨테이너가 다시 뜨게 한다. 이게 없으면 EC2를 재시작할 때마다 손으로 올려야 한다.
상태를 확인한다.
docker ps
docker logs -f visit-counter같은 이미지가 로컬에서도, EC2에서도 그대로 돈다.
로컬 Compose에서는 Docker Secrets 파일로 비밀번호를 넘겼고, 여기서는 환경 변수로 넘겼다. 이미지는 한 글자도 바꾸지 않았다. 엔트리포인트 스크립트가 secrets 파일이 있으면 읽고 없으면 그냥 넘어가도록 돼 있기 때문이다.
설정이 바뀌어도 이미지는 그대로인 것 — 이것이 이미지를 환경별로 따로 만들지 않는 이유다.
5. 보안 그룹에 8080 열기
컨테이너는 떴지만 아직 브라우저로는 접속되지 않는다. EC2 보안 그룹이 막고 있기 때문이다.
EC2 콘솔 → 인스턴스 선택 → 보안 탭 → 보안 그룹 링크 클릭
인스턴스 [보안] 탭 — 보안 그룹 링크와 현재 인바운드 규칙
열린 화면에서 인바운드 규칙 편집 → 규칙 추가
사용자 지정 TCP 8080 을 내 IP 로만 연다
보안 그룹은 인스턴스 화면 안에 링크로만 있다. 왼쪽 메뉴의 [보안 그룹] 으로 바로 가도 되지만, 인스턴스가 여러 개면 어느 것이 이 인스턴스의 보안 그룹인지 헷갈린다. 인스턴스 → 보안 탭 을 거쳐 들어가는 습관을 들이면 틀릴 일이 없다.
유형 | 포트 | 소스 |
|---|---|---|
사용자 지정 TCP | 8080 | 내 IP |
소스를 0.0.0.0/0 으로 열지 않는다. 확인용으로 잠깐 여는 포트다. 인터넷 전체에 열어두면 누구나 접속할 수 있다.
이 8080은 임시다. 무중단 배포 장에서 Nginx를 앞에 세우고 나면 8080은 닫고 80만 남긴다. 외부에 노출할 포트는 하나로 줄이는 것이 원칙이다 — Docker Compose 장에서 ports 와 expose 를 다루며 이야기한 것과 같다.
6. 동작 확인
브라우저에서 http://<EIP>:8080 으로 접속한다.
브라우저에서 http://:8080 접속
새로고침할 때마다 숫자가 올라가면 성공이다. 이 숫자는 EC2가 아니라 Supabase에 저장된다. 컨테이너를 지웠다 다시 띄워도 숫자가 유지되는지 확인해 보자.
docker rm -f visit-counter
docker run -d --name visit-counter -p 8080:8080 --env-file ~/app.env \
--restart unless-stopped ghcr.io/<GitHub사용자명>/visit-counter:1.0
curl http://localhost:8080숫자가 1로 돌아가지 않고 이어진다면, 애플리케이션은 상태를 갖지 않고 데이터는 데이터베이스에 있다는 구조가 제대로 된 것이다. 이 성질 덕분에 GitHub Actions로 자동 배포하기에서 컨테이너를 통째로 갈아치우는 배포가 가능해진다.
7. 직접 연결로 바꿔보면 어떻게 되는가
Supabase 장에서 직접 연결(Direct connection)은 EC2에서 쓸 수 없다고 했다. 말로만 듣지 말고 직접 확인해 보자. 실패를 한 번 봐두면 나중에 같은 증상을 만났을 때 훨씬 빨리 알아챈다.
7-1. EC2 에 IPv6 주소가 있는가
가장 먼저 볼 것은 상대가 아니라 내 쪽이다.
$ ip -6 addr show scope global
(아무것도 출력되지 않는다)출력이 비어 있다. 이 인스턴스에는 글로벌 IPv6 주소가 없다. IPv6 로만 갈 수 있는 곳에는 애초에 패킷을 보낼 수 없다.
7-2. 상대 주소는 어느 쪽인가
$ getent ahostsv4 db.<프로젝트REF>.supabase.co
(출력 없음)
$ getent ahostsv6 db.<프로젝트REF>.supabase.co
2406:da12:5ca:b702:28a6:58b9:f8f4:a76b STREAM db.<프로젝트REF>.supabase.co직접 연결 주소에는 IPv4 주소가 아예 없다. 풀러는 반대다.
$ getent ahostsv4 <풀러호스트>
15.164.120.176 STREAM pool-tcp-ap-northeast-2-....elb.ap-northeast-2.amazonaws.com
13.124.111.232 STREAM
15.165.245.138 STREAMgetent ahostsv6 <풀러호스트> 는 헷갈리게 나온다.
::ffff:15.165.245.138 STREAM pool-tcp-....elb.ap-northeast-2.amazonaws.com::ffff: 로 시작하는 것은 IPv4 주소를 IPv6 표기로 감싼 것(IPv4-mapped)이지 진짜 IPv6 주소가 아니다. 「IPv6 가 있네」 하고 오해하기 쉽다. 앞이 ::ffff: 면 IPv4 다.
7-3. 실제로 열어 본다
DNS 만 봐도 결론은 났지만, 연결을 직접 시도해 보면 오류 문구가 손에 남는다.
$ timeout 8 bash -c "exec 3<>/dev/tcp/db.<프로젝트REF>.supabase.co/5432"
bash: connect: Network is unreachable
bash: line 1: /dev/tcp/db.<프로젝트REF>.supabase.co/5432: Network is unreachable
$ timeout 8 bash -c "exec 3<>/dev/tcp/<풀러호스트>/5432"
(아무 출력 없이 끝난다 = 연결 성공)Network is unreachable — 커널이 경로가 없다고 즉시 거절한 것이다. 방화벽에 막혔다면 이렇게 빨리 끝나지 않고 한참 기다리다 시간 초과가 난다.
7-4. 애플리케이션으로 확인
같은 일을 애플리케이션으로 겪어 보면 오류가 어떻게 포장돼 나오는지 알 수 있다.
docker run --rm \
-e SPRING_DATASOURCE_URL='jdbc:postgresql://db.<프로젝트REF>.supabase.co:5432/postgres' \
-e SPRING_DATASOURCE_USERNAME='postgres' \
-e SPRING_DATASOURCE_PASSWORD='<비밀번호>' \
ghcr.io/<GitHub사용자명>/visit-counter:1.0비밀번호를 의심하게 되는 실패다.
사용자명도 비밀번호도 맞는데 연결이 되지 않는다. 드라이버가 뱉는 메시지는 버전마다 다르지만 바닥에는 Network is unreachable(ENETUNREACH) 이 깔려 있다. 패킷이 나가지도 못했다는 뜻이고, 인증까지 가보지도 못한 것이다.
비밀번호를 다시 입력하고, 재설정하고, 보안 그룹을 들여다봐도 해결되지 않는다.
중요한 것은 문구가 아니라 「인증 실패가 아니라 연결 자체가 안 됐다」 를 구분하는 눈이다.
메시지 | 어디가 문제인가 |
|---|---|
| 자격 증명 |
| 풀러 호스트나 사용자명 형식 |
| 경로가 없다 — IPv4/IPv6 불일치 |
| 방화벽·보안 그룹 |
| 주소는 닿는데 그 포트에 아무도 없다 |
확인이 끝나면 다시 풀러 주소로 돌아간다. --rm 을 붙였으므로 이 컨테이너는 종료와 함께 사라진다.
8. 실습 정리
GitHub Actions로 자동 배포하기에서 이어서 쓴다. 컨테이너는 그대로 둬도 된다.
EC2 인스턴스를 당분간 쓰지 않는다면 중지(Stop) 한다. 중지 상태에서는 인스턴스 요금이 발생하지 않는다. 다만 EC2 장에서 다룬 대로 EBS 스토리지와 공인 IPv4 요금은 계속 나간다.
~/app.env 에는 데이터베이스 비밀번호가 평문으로 들어 있다. chmod 600 으로 소유자만 읽도록 했는지 확인한다. 그리고 이 파일은 절대 Git 저장소에 넣지 않는다.
GitHub Actions로 자동 배포하기에서 자동 배포를 붙여도 이 파일은 서버에 그대로 둔다. 비밀은 적게 돌아다닐수록 좋기 때문이다. 자세한 이유는 그 장에서 다룬다.
다음 장 — GitHub Actions로 자동 배포하기