들어가며
- Vercel에 배포된 웹 앱과 별도로, 실행 시간이 긴 분석 API와 worker를 Amazon Linux 2023 EC2에 분리 배포했다.
- EC2에는 PEM SSH와 Session Manager를 함께 구성하고, Elastic IP의 80/443 포트만 외부에 열었다.
- API 컨테이너의 8080 포트는 직접 공개하지 않고 Traefik이 Docker label을 기반으로 라우팅하도록 구성했다.
- 인증서 발급과 갱신은 별도의 Certbot 없이 Traefik의 ACME resolver가 담당한다.
- Cloudflare 프록시는 origin HTTPS를 먼저 검증한 뒤 활성화하고, SSL/TLS 모드는
Full (strict)로 설정했다.
이 글에서는 이 구성을 처음부터 완성하기까지 겪었던 문제와 해결 과정을 정리한다.
왜 API를 Vercel 밖으로 분리했는가
웹 앱은 Vercel에서 잘 동작하고 있었다.
문제는 새로 붙인 분석 기능이었다.
- ZIP을 받아 압축을 해제한다.
- 여러 정적 분석 도구를 순서대로 실행한다.
- API와 worker가 작업 상태를 공유한다.
- 완료까지 수십 초 이상 걸릴 수 있다.
이런 작업을 웹 요청 하나의 생명주기에 계속 묶고 싶지는 않았다.
그래서 웹 앱은 사용자 인증, 업로드, 이력과 결과 캐시를 담당하고, EC2의 분석 서버는 작업 실행에만 집중하도록 경계를 나눴다.
최종 구조는 다음과 같다.
Browser
└─ Web application (Vercel)
├─ Auth / DB / private upload storage
└─ HTTPS + Bearer key
└─ api.example.com
└─ Cloudflare
└─ EC2 Elastic IP :80/:443
└─ Traefik
└─ API container :8080
└─ shared queue
└─ worker container
여기서 가장 중요한 원칙은 두 가지였다.
- 브라우저가 분석 API에 직접 접근할 때 사용하는 인증용 키를 알지 못한다.
- EC2의 8080 포트를 인터넷에 직접 공개하지 않는다.
1. EC2를 처음 만들 때 정한 기준
AWS 서울 리전에서 Amazon Linux 2023 인스턴스를 만들었다.
처음에는 비용을 아끼려고 t3.micro를 선택했다.
| 항목 | 선택 |
|---|
| AMI | Amazon Linux 2023 |
| 기본 사용자 | ec2-user |
| 인스턴스 유형 | 처음에는 t3.micro |
| 네트워크 | 인터넷 경로가 있는 public subnet |
| 스토리지 | Docker image와 로그 여유를 둔 gp3 |
| 로그인 | 기존 PEM SSH + Session Manager |
이미 회사에서 사용하던 PEM이 있었기 때문에 인스턴스 생성 시 그 PEM과 대응되는 EC2 Key Pair를 선택했다.
PEM 파일 자체를 서버에 업로드하는 것은 아니다.
AWS가 public key를 인스턴스에 넣고, 로컬의 PEM private key가 SSH 인증에 쓰인다.
ssh -i /path/to/company.pem ec2-user@<ELASTIC_IP>
PEM SSH만으로도 접속은 가능하지만 Session Manager도 같이 준비했다.
EC2 역할에 AmazonSSMManagedInstanceCore를 연결해두면,
Security Group이나 SSH 설정을 잘못 건드렸을 때 AWS 콘솔의 브라우저 shell을 복구 경로로 사용할 수 있다.
다만 역할만 연결한다고 끝나는 것은 아니다.
인스턴스에서 SSM Agent가 실행 중이어야 하고,
인터넷 gateway나 VPC endpoint를 통해 Systems Manager endpoint로 outbound HTTPS 통신도 가능해야 한다.
평소 접속: PEM + SSH
비상 접속: AWS Console + Session Manager
둘 중 하나를 고르는 문제가 아니라, 서로 다른 실패를 대비하는 두 통로였다.
2. Elastic IP와 Security Group
일반 public IP는 인스턴스를 중지하고 다시 시작하면 바뀔 수 있다.
API 도메인의 A record가 계속 같은 서버를 가리키게 하려면 고정 주소가 필요했다.
AWS 콘솔에서 다음 순서로 Elastic IP를 만들고 인스턴스에 연결했다.
EC2
→ Network & Security
→ Elastic IPs
→ Allocate Elastic IP address
→ Associate Elastic IP address
Elastic IP는 할당만 해두고 잊으면 안 된다.
현재 AWS는 연결 여부와 관계없이 public IPv4에 비용을 부과할 수 있으므로,
더 이상 쓰지 않는 주소는 필요 여부를 확인한 뒤 release해야 한다.
Security Group은 다음처럼 잡았다.
| 포트 | 소스 | 용도 |
|---|
| 22 | 회사 또는 관리자 IP /32 | PEM SSH |
| 80 | 0.0.0.0/0 | HTTP와 ACME HTTP-01 challenge |
| 443 | 0.0.0.0/0 | HTTPS |
| 8080 | 열지 않음 | 컨테이너 내부 API |
처음에는 “API가 8080에서 뜨니 8080도 열어야 하지 않나?” 라고 생각했다.
하지만 외부 진입점은 Traefik의 80/443이고, API는 Docker network 안에서만 접근하면 된다.
3. Amazon Linux 2023에 Docker 설치
서버에 접속한 뒤 Docker와 기본 도구를 설치했다.
sudo dnf update -y
sudo dnf install -y docker git curl
sudo systemctl enable --now docker
sudo usermod -aG docker ec2-user
exit
그룹 변경은 현재 로그인 session에 바로 반영되지 않는다.
SSH를 완전히 끊고 다시 접속한 뒤 확인했다.
Docker group은 사실상 root 수준의 권한을 줄 수 있으므로,
운영 사용자를 제한하고 해당 계정을 일반 애플리케이션 계정처럼 공유하지 않아야 한다.
docker version
docker compose version
docker buildx version
docker ps
여기서 첫 번째 삽질이 시작됐다.
Docker Engine을 설치했다고 Docker Compose와 Buildx까지 원하는 버전으로 준비되는 것은 아니었다.
Compose plugin이 없었다
요즘 기준 명령은 standalone docker-compose가 아니라 Docker CLI plugin인 docker compose다.
Amazon Linux 2023에서 Docker Engine을 설치한 직후에는 docker compose version이 동작하지 않았다.
배포 당시 package 경로로 바로 해결되지 않아,
Docker CLI가 사용자 plugin을 찾는 ~/.docker/cli-plugins에 공식 release binary를 직접 설치했다.
당시 실제로 검증한 Compose 버전은 v5.1.4였다.
CPU architecture를 먼저 확인하고 release asset 이름에 맞춰 변환했다.
case "$(uname -m)" in
x86_64)
COMPOSE_ARCH="x86_64"
;;
aarch64)
COMPOSE_ARCH="aarch64"
;;
*)
echo "지원하지 않는 CPU 아키텍처: $(uname -m)"
exit 1
;;
esac
COMPOSE_VERSION="v5.1.4"
mkdir -p "${HOME}/.docker/cli-plugins"
curl --proto '=https' --tlsv1.2 -fL \
"https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-${COMPOSE_ARCH}" \
-o "${HOME}/.docker/cli-plugins/docker-compose"
chmod 755 "${HOME}/.docker/cli-plugins/docker-compose"
docker compose version
이 방식은 package manager의 자동 업데이트를 받지 않는다.
이미 같은 버전 이상이 정상 동작한다면 재설치하거나 낮추지 않고,
운영 문서에 설치한 버전과 갱신 시점을 따로 남겼다.
Compose는 최신인데 Buildx가 오래됐다
다음 오류도 만났다.
compose build requires buildx 0.17.0 or later
처음에는 Docker image나 cache를 지워야 하나 싶었다.
하지만 Compose는 새 버전이었고 Buildx client만 0.12.1이었다.
Amazon Linux repository에서 plugin 설치도 시도했지만 package를 찾지 못했다.
sudo dnf install -y docker-buildx-plugin
No match for argument: docker-buildx-plugin
원인은 Docker Engine도, image도, volume도 아닌 Buildx client 버전이었다.
docker compose version
docker buildx version
docker buildx ls
그래서 Engine과 기존 image·volume은 건드리지 않고 Buildx plugin만 교체했다.
당시 실제 image build를 통과한 버전은 v0.34.1이었다.
case "$(uname -m)" in
x86_64)
BUILDX_ARCH="amd64"
;;
aarch64)
BUILDX_ARCH="arm64"
;;
*)
echo "지원하지 않는 CPU 아키텍처: $(uname -m)"
exit 1
;;
esac
BUILDX_VERSION="v0.34.1"
mkdir -p "${HOME}/.docker/cli-plugins"
curl --proto '=https' --tlsv1.2 -fL \
"https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.linux-${BUILDX_ARCH}" \
-o "${HOME}/.docker/cli-plugins/docker-buildx"
chmod 755 "${HOME}/.docker/cli-plugins/docker-buildx"
docker buildx version
docker buildx ls
여기서도 architecture 이름이 같지 않았다.
Compose asset은 x86_64/aarch64, Buildx asset은 amd64/arm64를 사용했다.
설치 뒤에는 Buildx client가 0.17.0 이상인지,
default builder가 running인지,
대상 platform이 EC2 CPU와 일치하는지 확인하고 이미지를 다시 빌드했다.
이때 얻은 교훈은 단순했다.
docker라는 한 단어로 부르더라도 Engine, Compose, Buildx, BuildKit은 서로 다른 구성요소다.
수동 plugin은 필요한 구성요소만 교체하고, 설치 버전과 자동 업데이트 부재까지 운영 기록에 남겨야 한다.
4. Traefik은 애플리케이션 밖에 두었다
처음에는 API 저장소의 Compose 안에 Traefik까지 넣는 방안도 생각했다.
하지만 같은 EC2에 다른 API가 추가될 수 있고,
애플리케이션을 down할 때 ingress와 인증서까지 함께 내려가는 구조는 피하고 싶었다.
그래서 호스트 구성을 분리했다.
/home/ec2-user/
├── traefik/
│ ├── compose.yaml
│ ├── .env
│ └── letsencrypt/acme.json
└── analysis-api/
├── .env.production
├── deploy/
└── source code
두 Compose 프로젝트는 외부 Docker network 하나를 공유한다.
docker network inspect edge_proxy >/dev/null 2>&1 \
|| docker network create edge_proxy
여기서 network를 공유한다는 것은 Traefik과 API 서비스가 각각의 Compose 파일에서
같은 edge_proxy 외부 network에 참여한다는 의미다.
외부 요청 → EC2 80/443 → Traefik → edge_proxy → API 컨테이너 8080
Traefik은 80/443을 소유하고,
Docker provider로 컨테이너 label을 읽는다.
exposedByDefault=false를 사용해 traefik.enable=true를 명시한 서비스만 외부에 노출했다.
services:
traefik:
image: traefik:v3.7.1
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=edge_proxy
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --ping=true
- --api.dashboard=true
- --api.insecure=false
ports:
- '80:80'
- '443:443'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- edge_proxy
networks:
edge_proxy:
external: true
Dashboard 기능은 켰지만 public router나 dashboard port는 열지 않았다.
Docker socket은 read-only mount여도 호스트 제어에 민감한 인터페이스다.
접근 가능한 컨테이너와 운영 계정을 최소화하고,
더 강한 격리가 필요하면 socket proxy를 별도로 두는 편이 낫다.
애플리케이션 컨테이너도 edge_proxy에 연결하고,
Host rule과 내부 port를 label로 선언했다.
호스트에서 컨테이너 자체를 진단할 수 있도록 8080은 public interface가 아닌 loopback에만 연결했다.
도메인을 붙이기 전 HTTP smoke에서는 scheme과 Host를 Elastic IP에 맞춰 생성했다.
services:
analysis-api:
environment:
API_SCHEME: http
API_DOMAIN: <ELASTIC_IP>
ports:
- '127.0.0.1:8080:8080'
networks:
- edge_proxy
labels:
- traefik.enable=true
- traefik.http.routers.analysis-api.rule=Host(`<ELASTIC_IP>`)
- traefik.http.routers.analysis-api.entrypoints=web
- traefik.http.services.analysis-api.loadbalancer.server.port=8080
networks:
edge_proxy:
external: true
Traefik은 127.0.0.1:8080을 경유하지 않는다.
edge_proxy 안에서 API 컨테이너의 8080 port로 직접 요청을 전달한다.
loopback port mapping은 호스트에서 API 자체를 진단하기 위한 별도 통로다.
5. HTTP부터 계층별로 확인했다
처음부터 DNS, Cloudflare, 인증서, Vercel까지 한 번에 연결하면 어디서 실패했는지 알기 어렵다.
그래서 아래 순서로 한 계층씩 확인했다.
curl -fsS http://127.0.0.1:8080/v1/health
curl -i http://<ELASTIC_IP>/v1/health
curl -i \
--resolve api.example.com:443:<ELASTIC_IP> \
https://api.example.com/v1/health
판단 기준도 단순해졌다.
127.0.0.1:8080 실패
→ API / worker / queue 문제
127.0.0.1 성공, Elastic IP 요청 실패
→ Traefik / router / Docker network / Security Group 문제
외부 health 성공, 웹 앱 실패
→ 배포 환경변수 / API key / allowlist 문제
Moved Permanently가 나온 이유
임시 HTTP 모드인데도 응답이 HTTPS로 이동했다.
Traefik의 web entrypoint에 전역 HTTP→HTTPS redirect를 미리 넣어둔 것이 원인이었다.
이 설정은 호스트의 모든 애플리케이션에 적용된다.
HTTP와 HTTPS를 단계적으로 전환할 계획이라면 전역 redirect보다 앱 router의 middleware로 범위를 좁히는 편이 안전했다.
호스트 전역 redirect
→ 모든 서비스에 영향
Host별 router redirect
→ 해당 API 도메인에만 영향
404 page not found는 Traefik이 죽었다는 뜻이 아니었다
Traefik의 404는 오히려 80번 포트와 Traefik까지는 요청이 도착했다는 신호였다.
대부분은 요청 Host와 일치하는 router가 없거나, 컨테이너가 공용 network에 연결되지 않은 문제였다.
docker network inspect edge_proxy
docker compose config | grep -E 'rule|entrypoints|loadbalancer'
docker logs traefik --tail 100
또 down → up 직후에는 API와 worker가 아직 starting 상태라 짧게 404가 보일 수 있었다.
API와 worker에 Compose healthcheck를 정의하고,
재배포 명령에 health 대기를 붙여 준비 완료 전에 검증 요청을 보내는 실수를 줄였다.
docker compose up -d --build --wait --wait-timeout 120
--wait는 실제 재생성 중단 시간을 없애는 옵션이 아니다.
정의된 healthcheck가 모두 성공할 때까지 명령을 반환하지 않으므로,
-d로 띄운 직후 너무 일찍 확인 요청을 보내는 문제를 막아준다.
터미널 붙여넣기가 YAML을 망가뜨렸다
원격 터미널에서 Vim에 Compose 파일을 붙여 넣은 뒤 다음 오류가 발생했다.
yaml: offset 0: control characters are not allowed
파일을 확인해보니 첫 줄과 마지막 줄에 bracketed paste 제어문자가 들어가 있었다.
head -n 3 compose.yaml | cat -vET
tail -n 3 compose.yaml | cat -vET
^[[200~, ^[[201~가 보였다.
YAML 문법을 아무리 다시 읽어도 해결되지 않았던 이유다.
원격 터미널에서 붙여 넣은 설정 파일은 반드시 docker compose config로 먼저 검증하게 됐다.
6. Certbot 대신 Traefik ACME를 선택했다
팀원에게 “Traefik과 Certbot을 설정해보라”는 이야기를 들었을 때,
둘을 같이 설치하는 것이 정석인 줄 알았다.
하지만 Traefik에는 ACME certificate resolver가 내장되어 있다.
Traefik이 Let's Encrypt 인증서 발급과 갱신을 맡는다면 Certbot을 별도로 둘 이유가 없다.
오히려 두 도구가 같은 인증서의 생명주기를 관리하면 운영 주체만 늘어난다.
내 구성에서는 인증서 관리자는 Traefik 하나로 정했다.
services:
traefik:
command:
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
volumes:
- ./letsencrypt:/letsencrypt
인증서 저장 파일은 권한을 제한했다.
mkdir -p ~/traefik/letsencrypt
touch ~/traefik/letsencrypt/acme.json
chmod 600 ~/traefik/letsencrypt/acme.json
HTTP-01 challenge에는 공개된 80번 포트와 API 도메인이 EC2를 가리키는 DNS record가 필요하다.
인증서 발급을 반복해서 시도하기 전에 DNS와 포트를 먼저 확인해야 Let's Encrypt rate limit도 피할 수 있다.
7. 서브도메인과 Cloudflare HTTPS 전환 순서
루트 도메인은 이미 Vercel의 웹 앱을 렌더링하고 있었다.
기존 root와 www record를 건드리지 않고 API 전용 서브도메인만 추가했다.
웹 앱: example.com
API: api.example.com
example.com은 문서 예시에 쓰도록 예약된 도메인이다.
실제 작업에서는 이미 운영 중인 루트 도메인의 API 전용 서브도메인을 사용했다.
Cloudflare에 A record를 만들었다.
| 항목 | 값 |
|---|
| Type | A |
| Name | api |
| Content | EC2 Elastic IP |
| Proxy status | 처음에는 DNS only |
이름에는 https://, /v1, port를 붙이지 않고 api만 입력했다.
기존 root와 www의 Vercel record도 수정하지 않았다.
먼저 DNS-only로 origin HTTPS를 완성했다
처음부터 주황색 구름인 Proxied로 두면 origin 인증서 문제와 Cloudflare 프록시 문제를 구분하기 어렵다.
그래서 회색 구름인 DNS only로 시작했다.
진행 순서는 다음과 같았다.
- A record를 Elastic IP로 연결하고
DNS only로 둔다.
dig +short api.example.com A가 Elastic IP를 반환하는지 확인한다.
- EC2의 80/443 listener와 Traefik ACME 인자를 확인한다.
- 애플리케이션을 IP 기반 HTTP 모드에서 도메인 기반 HTTPS 모드로 바꾼다.
- Traefik의 HTTPS router와
certresolver=letsencrypt를 활성화한다.
--resolve로 origin HTTPS의 인증서와 응답을 직접 확인한다.
- 인증서가 발급되고 origin HTTPS가 성공한 뒤에만 다음 단계로 이동한다.
dig +short api.example.com A
sudo ss -ltnp | grep -E ':(80|443)\b'
docker inspect traefik \
--format '{{range .Args}}{{println .}}{{end}}' \
| grep -E 'entrypoints.web|entrypoints.websecure|certificatesresolvers.letsencrypt'
애플리케이션 설정은 개념적으로 다음 두 값이 바뀌었다.
API_SCHEME="https"
API_DOMAIN="api.example.com"
Compose 결과에서 HTTP/HTTPS router, Host rule, entrypoint와 certificate resolver가
실제로 만들어졌는지 확인한 다음 API와 worker를 재배포했다.
docker compose config \
| grep -E 'analysis-api.*(rule|entrypoints|certresolver)'
docker compose up -d --build --wait --wait-timeout 120
docker compose ps
HTTPS router와 API 도메인 전용 redirect middleware의 핵심 label은 다음과 같다.
labels:
- traefik.enable=true
- traefik.http.routers.analysis-api.rule=Host(`api.example.com`)
- traefik.http.routers.analysis-api.entrypoints=web
- traefik.http.routers.analysis-api.middlewares=analysis-api-https
- traefik.http.middlewares.analysis-api-https.redirectscheme.scheme=https
- traefik.http.middlewares.analysis-api-https.redirectscheme.permanent=true
- traefik.http.routers.analysis-api-secure.rule=Host(`api.example.com`)
- traefik.http.routers.analysis-api-secure.entrypoints=websecure
- traefik.http.routers.analysis-api-secure.tls=true
- traefik.http.routers.analysis-api-secure.tls.certresolver=letsencrypt
- traefik.http.services.analysis-api.loadbalancer.server.port=8080
Traefik의 ACME 로그와 인증서 저장 파일도 같이 확인했다.
docker logs traefik --since 10m 2>&1 \
| grep -iE 'acme|certificate|letsencrypt|error'
ls -lh "${HOME}/traefik/letsencrypt/acme.json"
인증서 오류가 났다고 acme.json을 지우거나 Traefik을 반복 재시작하지 않았다.
DNS와 80/443, ACME 로그를 먼저 확인해야 재시도로 인한 Let's Encrypt rate limit도 피할 수 있다.
검증은 redirect와 HTTPS를 분리했다.
curl -i \
--resolve api.example.com:443:<ELASTIC_IP> \
https://api.example.com/v1/health
curl -i http://api.example.com/v1/health
curl -i https://api.example.com/v1/health
첫 번째 요청은 DNS나 Cloudflare와 무관하게 Host와 TLS SNI를 유지한 채 origin을 직접 확인한다.
그다음 일반 HTTP가 HTTPS로 영구 이동하고,
일반 HTTPS health가 200 OK를 반환하는지 확인했다.
그다음 Cloudflare Proxied와 Full (strict)를 켰다
Origin HTTPS가 정상인 뒤에만 다음 순서로 전환했다.
- A record를
DNS only에서 Proxied로 바꾼다.
dig가 Elastic IP 대신 Cloudflare Anycast IP를 반환하는지 확인한다.
- Cloudflare SSL/TLS mode를
Full (strict)로 설정한다.
- 응답의
server: cloudflare 또는 cf-ray header를 확인한다.
dig +short api.example.com A
curl -sS -D - -o /dev/null \
https://api.example.com/v1/health \
| grep -iE '^(HTTP/|server:|cf-ray:)'
Cloudflare에서는 Flexible을 사용하지 않았다.
Flexible은 Cloudflare와 origin 사이를 HTTP로 연결하기 때문에,
origin의 HTTPS redirect와 맞물려 ERR_TOO_MANY_REDIRECTS를 만들 수 있다.
Full (strict)로 사용자→Cloudflare와 Cloudflare→Traefik 구간을 모두 HTTPS로 유지하고,
origin 인증서의 유효기간과 Host까지 검증하게 했다.
Cloudflare의 Always Use HTTPS도 필수로 켜지 않았다.
이미 애플리케이션 Host 전용 Traefik middleware가 redirect를 소유하고 있었기 때문이다.
API hostname 전체를 cache에서 제외했다
진단 상태와 결과는 매번 최신 origin 응답이어야 한다.
Cloudflare Cache Rule은 경로별로 복잡하게 만들지 않고 API hostname 전체에 하나만 만들었다.
규칙 이름: bypass-api-cache
조건: (http.host eq "api.example.com")
동작: Bypass cache
이 health endpoint는 HEAD를 구현하지 않았으므로 curl -I가 아니라 실제 GET의 응답 header를 봤다.
for attempt in 1 2 3; do
curl -sS -D - -o /dev/null \
https://api.example.com/v1/health \
| grep -iE '^(HTTP/|cf-cache-status:|server:)'
done
CF-Cache-Status: BYPASS 또는 DYNAMIC은 허용했지만,
반복 요청 중 한 번이라도 HIT가 나오면 rule 조건과 배포 상태를 다시 확인했다.
이 cache rule은 응답 cache만 우회하며 Bearer 인증을 대신하지 않는다.
전환이 실패했을 때는 가장 바깥 계층부터 되돌렸다
DNS-only에서는 성공했는데 Proxied 뒤에만 실패하면 Traefik을 먼저 뜯지 않았다.
같은 Host와 SNI로 origin을 직접 호출해 Cloudflare 문제인지 분리했다.
curl --resolve api.example.com:443:<ELASTIC_IP> \
-i https://api.example.com/v1/health
- 직접 origin은 성공하고 프록시만 실패: Cloudflare SSL mode, WAF와 proxy 상태 확인
526 Invalid SSL certificate: Traefik ACME 로그와 인증서 SAN·만료일 확인
ERR_TOO_MANY_REDIRECTS: Flexible과 중복 redirect rule 확인
404 page not found: API health, Host router와 공용 Docker network 확인
CF-Cache-Status: HIT: cache bypass rule의 Host 조건과 우선순위 확인
가장 안전한 롤백은 A record만 다시 DNS only로 바꾸는 것이었다.
Origin HTTPS와 애플리케이션의 HTTPS URL은 그대로 유지하면서 Cloudflare proxy 계층만 우회할 수 있다.
롤백 중에도 acme.json, Traefik network, queue volume을 먼저 삭제하지 않았다.
8. t3.micro와 swap에서 배운 것
배포는 됐지만 실제 분석이 굉장히 느렸다.
처음에는 네트워크나 API polling을 의심했지만, 서버 사양을 보면 t3.micro가 먼저 눈에 들어왔다.
- 2 vCPU처럼 보이지만 burstable CPU다.
- 메모리는 1GiB다.
- 같은 호스트에서 OS, Docker, Traefik, API, worker가 함께 실행된다.
- worker는 압축 해제와 여러 분석 도구를 순차 실행한다.
2GiB swap을 추가하면 OOM과 갑작스러운 container restart를 줄이는 안전망은 만들 수 있다.
if [[ ! -e /swapfile ]]; then
sudo dd if=/dev/zero of=/swapfile bs=128M count=16 status=progress
sudo chmod 600 /swapfile
sudo mkswap /swapfile
fi
sudo swapon --show | grep -q '^/swapfile ' \
|| sudo swapon /swapfile
if ! grep -qE '^/swapfile[[:space:]]' /etc/fstab; then
echo '/swapfile swap swap defaults 0 0' \
| sudo tee -a /etc/fstab
fi
echo 'vm.swappiness=10' \
| sudo tee /etc/sysctl.d/99-swap.conf
sudo sysctl --system
하지만 swap은 RAM보다 느리다.
진단 속도를 높이는 설정이 아니라, 메모리가 잠깐 부족할 때 죽지 않게 버티는 설정이다.
진단 중에는 다음을 함께 봤다.
free -h
swapon --show
vmstat 1
docker stats
- CPU가 계속 높고 swap 사용이 거의 없다면 CPU 병목이다.
vmstat의 si, so가 계속 증가하면 swap thrashing이다.
- worker memory가 제한에 붙거나 OOMKilled가 보이면 RAM이 부족하다.
- 뒤 작업이
queued에 머물면 단일 worker가 앞 작업을 처리 중일 수 있다.
결론적으로 t3.micro + swap은 저렴한 smoke 환경에는 쓸 수 있지만,
실제 분석 workload의 성능 해결책은 아니었다.
같은 ZIP으로 시간을 기록하고 t3.medium 같은 더 큰 인스턴스와 A/B 비교하는 편이 가장 빠른 판단법이다.
9. 운영할 때 남긴 최소 체크리스트
배포 전
- EC2에 Elastic IP가 연결되어 있는가
- SSH 22는 관리자 IP에만 열려 있는가
- 80/443은 열려 있고 8080은 닫혀 있는가
- Session Manager로도 접속 가능한가
docker compose version, docker buildx version이 정상인가
- Traefik과 API가 같은 external network에 연결되어 있는가
- 비밀키와 실제
.env.production 권한이 제한되어 있는가
배포 후
docker compose config
docker compose up -d --build --wait --wait-timeout 120
docker compose ps
docker compose logs --tail 100
127.0.0.1:8080 health가 성공하는가
- HTTP 요청이 의도한 HTTPS URL로 이동하는가
- HTTPS health가 200인가
- 인증서의 Host와 만료일이 올바른가
- Cloudflare가
Full (strict)인가
- API 응답이 Cloudflare cache
HIT가 아닌가
- 웹 앱의 API base URL을 HTTPS로 바꾼 뒤 다시 배포했는가
- 로그에 Bearer key, signed URL, 업로드 원본이 남지 않는가
마치며
- EC2 배포는 “서버 한 대를 만드는 일”보다 네트워크와 책임 경계를 하나씩 연결하는 일에 가까웠다.
- PEM SSH와 Session Manager는 경쟁 관계가 아니라 평상시 통로와 복구 통로다.
- Elastic IP, DNS, Traefik router, API health를 한꺼번에 보지 말고 계층별로 확인해야 장애 범위가 줄어든다.
- Traefik이 ACME를 맡는다면 Certbot을 또 붙이지 않고 인증서 관리 주체를 하나로 두는 편이 단순하다.
- Cloudflare는 DNS-only origin 검증 뒤 프록시를 켜야 문제를 분리하기 쉽다.
404 page not found, Moved Permanently, YAML 제어문자는 모두 “무언가 망가졌다”가 아니라 현재 요청이 도달한 계층을 알려주는 단서였다.
- swap은 서버를 빠르게 하는 마법이 아니다. 지속적으로 swap을 쓴다면 RAM이 더 큰 인스턴스가 필요하다는 신호다.
처음에는 AWS 콘솔의 선택지 하나하나가 서로 독립된 설정처럼 보였다.
하지만 마지막에는 아래 한 줄로 연결됐다.
DNS → Elastic IP → Security Group → Traefik entrypoint → Host router → API container → worker
다음에 같은 배포를 한다면 가장 먼저 이 경로를 그려놓고,
각 화살표마다 하나의 health check를 정할 것 같다.
참고 자료
배포 과정을 이해할 때 참고한 글
공식 문서
들어가며
Full (strict)로 설정했다.이 글에서는 이 구성을 처음부터 완성하기까지 겪었던 문제와 해결 과정을 정리한다.
왜 API를 Vercel 밖으로 분리했는가
웹 앱은 Vercel에서 잘 동작하고 있었다. 문제는 새로 붙인 분석 기능이었다.
이런 작업을 웹 요청 하나의 생명주기에 계속 묶고 싶지는 않았다.
그래서 웹 앱은 사용자 인증, 업로드, 이력과 결과 캐시를 담당하고, EC2의 분석 서버는 작업 실행에만 집중하도록 경계를 나눴다.
최종 구조는 다음과 같다.
여기서 가장 중요한 원칙은 두 가지였다.
1. EC2를 처음 만들 때 정한 기준
AWS 서울 리전에서 Amazon Linux 2023 인스턴스를 만들었다.
처음에는 비용을 아끼려고
t3.micro를 선택했다.ec2-usert3.micro이미 회사에서 사용하던 PEM이 있었기 때문에 인스턴스 생성 시 그 PEM과 대응되는 EC2 Key Pair를 선택했다.
PEM 파일 자체를 서버에 업로드하는 것은 아니다.
AWS가 public key를 인스턴스에 넣고, 로컬의 PEM private key가 SSH 인증에 쓰인다.
PEM SSH만으로도 접속은 가능하지만 Session Manager도 같이 준비했다.
EC2 역할에
AmazonSSMManagedInstanceCore를 연결해두면,Security Group이나 SSH 설정을 잘못 건드렸을 때 AWS 콘솔의 브라우저 shell을 복구 경로로 사용할 수 있다.
다만 역할만 연결한다고 끝나는 것은 아니다.
인스턴스에서 SSM Agent가 실행 중이어야 하고,
인터넷 gateway나 VPC endpoint를 통해 Systems Manager endpoint로 outbound HTTPS 통신도 가능해야 한다.
둘 중 하나를 고르는 문제가 아니라, 서로 다른 실패를 대비하는 두 통로였다.
2. Elastic IP와 Security Group
일반 public IP는 인스턴스를 중지하고 다시 시작하면 바뀔 수 있다.
API 도메인의 A record가 계속 같은 서버를 가리키게 하려면 고정 주소가 필요했다.
AWS 콘솔에서 다음 순서로 Elastic IP를 만들고 인스턴스에 연결했다.
Elastic IP는 할당만 해두고 잊으면 안 된다.
현재 AWS는 연결 여부와 관계없이 public IPv4에 비용을 부과할 수 있으므로,
더 이상 쓰지 않는 주소는 필요 여부를 확인한 뒤 release해야 한다.
Security Group은 다음처럼 잡았다.
/320.0.0.0/00.0.0.0/0처음에는 “API가 8080에서 뜨니 8080도 열어야 하지 않나?” 라고 생각했다.
하지만 외부 진입점은 Traefik의 80/443이고, API는 Docker network 안에서만 접근하면 된다.
3. Amazon Linux 2023에 Docker 설치
서버에 접속한 뒤 Docker와 기본 도구를 설치했다.
그룹 변경은 현재 로그인 session에 바로 반영되지 않는다.
SSH를 완전히 끊고 다시 접속한 뒤 확인했다.
Docker group은 사실상 root 수준의 권한을 줄 수 있으므로,
운영 사용자를 제한하고 해당 계정을 일반 애플리케이션 계정처럼 공유하지 않아야 한다.
여기서 첫 번째 삽질이 시작됐다.
Docker Engine을 설치했다고 Docker Compose와 Buildx까지 원하는 버전으로 준비되는 것은 아니었다.
Compose plugin이 없었다
요즘 기준 명령은 standalone
docker-compose가 아니라 Docker CLI plugin인docker compose다.Amazon Linux 2023에서 Docker Engine을 설치한 직후에는
docker compose version이 동작하지 않았다.배포 당시 package 경로로 바로 해결되지 않아,
Docker CLI가 사용자 plugin을 찾는
~/.docker/cli-plugins에 공식 release binary를 직접 설치했다.당시 실제로 검증한 Compose 버전은
v5.1.4였다.CPU architecture를 먼저 확인하고 release asset 이름에 맞춰 변환했다.
이 방식은 package manager의 자동 업데이트를 받지 않는다.
이미 같은 버전 이상이 정상 동작한다면 재설치하거나 낮추지 않고,
운영 문서에 설치한 버전과 갱신 시점을 따로 남겼다.
Compose는 최신인데 Buildx가 오래됐다
다음 오류도 만났다.
처음에는 Docker image나 cache를 지워야 하나 싶었다.
하지만 Compose는 새 버전이었고 Buildx client만
0.12.1이었다.Amazon Linux repository에서 plugin 설치도 시도했지만 package를 찾지 못했다.
원인은 Docker Engine도, image도, volume도 아닌 Buildx client 버전이었다.
그래서 Engine과 기존 image·volume은 건드리지 않고 Buildx plugin만 교체했다.
당시 실제 image build를 통과한 버전은
v0.34.1이었다.여기서도 architecture 이름이 같지 않았다.
Compose asset은
x86_64/aarch64, Buildx asset은amd64/arm64를 사용했다.설치 뒤에는 Buildx client가
0.17.0이상인지,defaultbuilder가running인지,대상 platform이 EC2 CPU와 일치하는지 확인하고 이미지를 다시 빌드했다.
이때 얻은 교훈은 단순했다.
4. Traefik은 애플리케이션 밖에 두었다
처음에는 API 저장소의 Compose 안에 Traefik까지 넣는 방안도 생각했다.
하지만 같은 EC2에 다른 API가 추가될 수 있고,
애플리케이션을
down할 때 ingress와 인증서까지 함께 내려가는 구조는 피하고 싶었다.그래서 호스트 구성을 분리했다.
두 Compose 프로젝트는 외부 Docker network 하나를 공유한다.
여기서 network를 공유한다는 것은 Traefik과 API 서비스가 각각의 Compose 파일에서
같은
edge_proxy외부 network에 참여한다는 의미다.Traefik은 80/443을 소유하고,
Docker provider로 컨테이너 label을 읽는다.
exposedByDefault=false를 사용해traefik.enable=true를 명시한 서비스만 외부에 노출했다.Dashboard 기능은 켰지만 public router나 dashboard port는 열지 않았다.
Docker socket은 read-only mount여도 호스트 제어에 민감한 인터페이스다.
접근 가능한 컨테이너와 운영 계정을 최소화하고,
더 강한 격리가 필요하면 socket proxy를 별도로 두는 편이 낫다.
애플리케이션 컨테이너도
edge_proxy에 연결하고,Host rule과 내부 port를 label로 선언했다.
호스트에서 컨테이너 자체를 진단할 수 있도록 8080은 public interface가 아닌 loopback에만 연결했다.
도메인을 붙이기 전 HTTP smoke에서는 scheme과 Host를 Elastic IP에 맞춰 생성했다.
Traefik은
127.0.0.1:8080을 경유하지 않는다.edge_proxy안에서 API 컨테이너의 8080 port로 직접 요청을 전달한다.loopback port mapping은 호스트에서 API 자체를 진단하기 위한 별도 통로다.
5. HTTP부터 계층별로 확인했다
처음부터 DNS, Cloudflare, 인증서, Vercel까지 한 번에 연결하면 어디서 실패했는지 알기 어렵다.
그래서 아래 순서로 한 계층씩 확인했다.
판단 기준도 단순해졌다.
Moved Permanently가 나온 이유임시 HTTP 모드인데도 응답이 HTTPS로 이동했다.
Traefik의
webentrypoint에 전역 HTTP→HTTPS redirect를 미리 넣어둔 것이 원인이었다.이 설정은 호스트의 모든 애플리케이션에 적용된다.
HTTP와 HTTPS를 단계적으로 전환할 계획이라면 전역 redirect보다 앱 router의 middleware로 범위를 좁히는 편이 안전했다.
404 page not found는 Traefik이 죽었다는 뜻이 아니었다Traefik의 404는 오히려 80번 포트와 Traefik까지는 요청이 도착했다는 신호였다.
대부분은 요청 Host와 일치하는 router가 없거나, 컨테이너가 공용 network에 연결되지 않은 문제였다.
또
down → up직후에는 API와 worker가 아직 starting 상태라 짧게 404가 보일 수 있었다.API와 worker에 Compose
healthcheck를 정의하고,재배포 명령에 health 대기를 붙여 준비 완료 전에 검증 요청을 보내는 실수를 줄였다.
--wait는 실제 재생성 중단 시간을 없애는 옵션이 아니다.정의된 healthcheck가 모두 성공할 때까지 명령을 반환하지 않으므로,
-d로 띄운 직후 너무 일찍 확인 요청을 보내는 문제를 막아준다.터미널 붙여넣기가 YAML을 망가뜨렸다
원격 터미널에서 Vim에 Compose 파일을 붙여 넣은 뒤 다음 오류가 발생했다.
파일을 확인해보니 첫 줄과 마지막 줄에 bracketed paste 제어문자가 들어가 있었다.
^[[200~,^[[201~가 보였다. YAML 문법을 아무리 다시 읽어도 해결되지 않았던 이유다. 원격 터미널에서 붙여 넣은 설정 파일은 반드시docker compose config로 먼저 검증하게 됐다.6. Certbot 대신 Traefik ACME를 선택했다
팀원에게 “Traefik과 Certbot을 설정해보라”는 이야기를 들었을 때,
둘을 같이 설치하는 것이 정석인 줄 알았다.
하지만 Traefik에는 ACME certificate resolver가 내장되어 있다.
Traefik이 Let's Encrypt 인증서 발급과 갱신을 맡는다면 Certbot을 별도로 둘 이유가 없다.
오히려 두 도구가 같은 인증서의 생명주기를 관리하면 운영 주체만 늘어난다.
내 구성에서는 인증서 관리자는 Traefik 하나로 정했다.
인증서 저장 파일은 권한을 제한했다.
HTTP-01 challenge에는 공개된 80번 포트와 API 도메인이 EC2를 가리키는 DNS record가 필요하다.
인증서 발급을 반복해서 시도하기 전에 DNS와 포트를 먼저 확인해야 Let's Encrypt rate limit도 피할 수 있다.
7. 서브도메인과 Cloudflare HTTPS 전환 순서
루트 도메인은 이미 Vercel의 웹 앱을 렌더링하고 있었다.
기존 root와
wwwrecord를 건드리지 않고 API 전용 서브도메인만 추가했다.example.com은 문서 예시에 쓰도록 예약된 도메인이다.실제 작업에서는 이미 운영 중인 루트 도메인의 API 전용 서브도메인을 사용했다.
Cloudflare에 A record를 만들었다.
AapiDNS only이름에는
https://,/v1, port를 붙이지 않고api만 입력했다. 기존 root와www의 Vercel record도 수정하지 않았다.먼저 DNS-only로 origin HTTPS를 완성했다
처음부터 주황색 구름인 Proxied로 두면 origin 인증서 문제와 Cloudflare 프록시 문제를 구분하기 어렵다.
그래서 회색 구름인
DNS only로 시작했다.진행 순서는 다음과 같았다.
DNS only로 둔다.dig +short api.example.com A가 Elastic IP를 반환하는지 확인한다.certresolver=letsencrypt를 활성화한다.--resolve로 origin HTTPS의 인증서와 응답을 직접 확인한다.애플리케이션 설정은 개념적으로 다음 두 값이 바뀌었다.
Compose 결과에서 HTTP/HTTPS router, Host rule, entrypoint와 certificate resolver가
실제로 만들어졌는지 확인한 다음 API와 worker를 재배포했다.
HTTPS router와 API 도메인 전용 redirect middleware의 핵심 label은 다음과 같다.
Traefik의 ACME 로그와 인증서 저장 파일도 같이 확인했다.
인증서 오류가 났다고
acme.json을 지우거나 Traefik을 반복 재시작하지 않았다.DNS와 80/443, ACME 로그를 먼저 확인해야 재시도로 인한 Let's Encrypt rate limit도 피할 수 있다.
검증은 redirect와 HTTPS를 분리했다.
첫 번째 요청은 DNS나 Cloudflare와 무관하게 Host와 TLS SNI를 유지한 채 origin을 직접 확인한다.
그다음 일반 HTTP가 HTTPS로 영구 이동하고,
일반 HTTPS health가
200 OK를 반환하는지 확인했다.그다음 Cloudflare Proxied와 Full (strict)를 켰다
Origin HTTPS가 정상인 뒤에만 다음 순서로 전환했다.
DNS only에서Proxied로 바꾼다.dig가 Elastic IP 대신 Cloudflare Anycast IP를 반환하는지 확인한다.Full (strict)로 설정한다.server: cloudflare또는cf-rayheader를 확인한다.Cloudflare에서는
Flexible을 사용하지 않았다.Flexible은 Cloudflare와 origin 사이를 HTTP로 연결하기 때문에,
origin의 HTTPS redirect와 맞물려
ERR_TOO_MANY_REDIRECTS를 만들 수 있다.Full (strict)로 사용자→Cloudflare와 Cloudflare→Traefik 구간을 모두 HTTPS로 유지하고,origin 인증서의 유효기간과 Host까지 검증하게 했다.
Cloudflare의
Always Use HTTPS도 필수로 켜지 않았다.이미 애플리케이션 Host 전용 Traefik middleware가 redirect를 소유하고 있었기 때문이다.
API hostname 전체를 cache에서 제외했다
진단 상태와 결과는 매번 최신 origin 응답이어야 한다.
Cloudflare Cache Rule은 경로별로 복잡하게 만들지 않고 API hostname 전체에 하나만 만들었다.
이 health endpoint는
HEAD를 구현하지 않았으므로curl -I가 아니라 실제GET의 응답 header를 봤다.CF-Cache-Status: BYPASS또는DYNAMIC은 허용했지만,반복 요청 중 한 번이라도
HIT가 나오면 rule 조건과 배포 상태를 다시 확인했다.이 cache rule은 응답 cache만 우회하며 Bearer 인증을 대신하지 않는다.
전환이 실패했을 때는 가장 바깥 계층부터 되돌렸다
DNS-only에서는 성공했는데 Proxied 뒤에만 실패하면 Traefik을 먼저 뜯지 않았다.
같은 Host와 SNI로 origin을 직접 호출해 Cloudflare 문제인지 분리했다.
526 Invalid SSL certificate: Traefik ACME 로그와 인증서 SAN·만료일 확인ERR_TOO_MANY_REDIRECTS:Flexible과 중복 redirect rule 확인404 page not found: API health, Host router와 공용 Docker network 확인CF-Cache-Status: HIT: cache bypass rule의 Host 조건과 우선순위 확인가장 안전한 롤백은 A record만 다시
DNS only로 바꾸는 것이었다.Origin HTTPS와 애플리케이션의 HTTPS URL은 그대로 유지하면서 Cloudflare proxy 계층만 우회할 수 있다.
롤백 중에도
acme.json, Traefik network, queue volume을 먼저 삭제하지 않았다.8. t3.micro와 swap에서 배운 것
배포는 됐지만 실제 분석이 굉장히 느렸다.
처음에는 네트워크나 API polling을 의심했지만, 서버 사양을 보면
t3.micro가 먼저 눈에 들어왔다.2GiB swap을 추가하면 OOM과 갑작스러운 container restart를 줄이는 안전망은 만들 수 있다.
하지만 swap은 RAM보다 느리다.
진단 속도를 높이는 설정이 아니라, 메모리가 잠깐 부족할 때 죽지 않게 버티는 설정이다.
진단 중에는 다음을 함께 봤다.
vmstat의si,so가 계속 증가하면 swap thrashing이다.queued에 머물면 단일 worker가 앞 작업을 처리 중일 수 있다.결론적으로
t3.micro + swap은 저렴한 smoke 환경에는 쓸 수 있지만,실제 분석 workload의 성능 해결책은 아니었다.
같은 ZIP으로 시간을 기록하고
t3.medium같은 더 큰 인스턴스와 A/B 비교하는 편이 가장 빠른 판단법이다.9. 운영할 때 남긴 최소 체크리스트
배포 전
docker compose version,docker buildx version이 정상인가.env.production권한이 제한되어 있는가배포 후
127.0.0.1:8080health가 성공하는가Full (strict)인가HIT가 아닌가마치며
404 page not found,Moved Permanently, YAML 제어문자는 모두 “무언가 망가졌다”가 아니라 현재 요청이 도달한 계층을 알려주는 단서였다.처음에는 AWS 콘솔의 선택지 하나하나가 서로 독립된 설정처럼 보였다.
하지만 마지막에는 아래 한 줄로 연결됐다.
다음에 같은 배포를 한다면 가장 먼저 이 경로를 그려놓고,
각 화살표마다 하나의 health check를 정할 것 같다.
참고 자료
배포 과정을 이해할 때 참고한 글
공식 문서