EC2를 띄우기 전에 AWS CLI로 먼저 잰 것들 — 계획서가 틀려 있던 8가지
기존 운영 AWS 계정(ap-east-1)에 레거시 스택이 돌고 있고, 그 옆에 신규 스택을 하나 더 올려야 했습니다. NestJS로 만든 api와 worker, Next.js로 만든 web까지 컨테이너 세 개를 Coolify로 배포할 계획입니다. DB는 이미 있는 RDS(MySQL, non-public)를 그대로 쓰고, 같은 VPC의 public 서브넷에 EC2 한 대를 세워 git push 웹훅으로 빌드합니다. 기존 EKS·ALB 경로와는 일부러 분리했습니다.
질문은 하나였습니다. EC2를 띄우기 전에 AWS CLI로 무엇을 먼저 준비해야 하는가. 답을 추측으로 채우는 대신 읽기 전용 describe/list/get만으로 계정을 먼저 쟀습니다. 그러고 나니 제가 써둔 프로비저닝 계획서가 여덟 군데 틀려 있었습니다.
계획서가 아니라 describe가 진실입니다
프로비저닝 계획서는 쓰는 순간 스냅샷이 됩니다. 쓸 때는 맞았어도 실행 시점의 계정 상태와 같다는 보장이 없고, 무엇보다 계획서를 쓰는 시점에는 아직 계정을 자세히 보지 않은 상태라 사실이 아니라 짐작이 섞여 들어갑니다. 읽기 전용 호출은 비용도 없고 되돌릴 것도 없으니 순서를 이렇게 잡았습니다. 먼저 전부 재고, 그다음에 판단하고, 마지막에 비용이 0인 것만 만든다.
실측이 뒤집은 것들
1순위 단계가 애초에 불필요했습니다
계획서의 첫 단계는 "RDS 보안그룹에 EC2용 3306 인바운드를 추가한다"였습니다. 그런데 실제 보안그룹을 열어보니 이미 VPC 대역 전체를 허용하고 있었습니다. 같은 VPC 안에 세우면 아무것도 하지 않아도 닿습니다.
aws ec2 describe-security-groups --group-ids <sg-id> \
--query 'SecurityGroups[].IpPermissions[?FromPort==`3306`].IpRanges[].CidrIp'
가장 먼저 하기로 적어둔 단계가 실측하니 no-op였습니다. 운영 RDS의 보안그룹을 괜히 건드릴 뻔했다는 뜻이기도 합니다.
EIP 슬롯이 하나 남아 있었습니다
public 서브넷의 EC2에는 고정 IP가 필요합니다. 그런데 세어보니 NAT 두 개와 ALB 두 개가 이미 네 개를 쓰고 있었고, 리전 기본 한도는 다섯 개였습니다. 남은 슬롯은 하나입니다.
aws service-quotas get-service-quota --service-code ec2 --quota-code L-0263D0A3
aws ec2 describe-addresses --query 'length(Addresses)'
한도 증설 요청은 무료이고 비동기라, 실측에서 나온 항목 중 이것을 가장 먼저 넣었습니다. 기다려야 하는 일은 앞으로 당기는 편이 낫다는 생각이 들었습니다.
존이 있다고 권위 DNS가 아닙니다
Route53에 호스티드존이 있으면 그 도메인을 Route53이 맡고 있다고 생각하기 쉽습니다. 저도 그렇게 적어뒀는데, dig로 NS를 확인해보니 한 도메인은 실제 위임이 다른 DNS 업체로 가 있었습니다. Route53 쪽 존은 레코드를 넣어도 효력이 없는 그림자였습니다.
aws route53 list-hosted-zones --query 'HostedZones[].Name'
dig +short NS example.kr
반대 방향의 착각도 있었습니다. 새로 등록한 도메인은 등록기관 NS에 정상적으로 있고 레코드만 비어 있었는데, 저는 이걸 "해석이 안 되는 도메인"으로 읽었습니다. 증상은 비슷해도 해야 할 일은 전혀 다릅니다.
여기에 와일드카드 함정이 붙습니다. *.example.kr이 기존 로드밸런서를 가리키고 있으면 새 서브도메인은 A 레코드를 넣기 전까지 엉뚱한 곳으로 풀립니다. 구체 레코드가 와일드카드를 이기므로 추가하면 해결되지만, 모르면 "왜 남의 서비스가 뜨지"로 한참 헤맵니다.
컨테이너에서 인스턴스 역할이 잡히지 않는 자리
앱 컨테이너가 액세스 키 없이 EC2 인스턴스 역할로 S3를 쓰게 할 계획이었습니다. 그런데 IMDSv2의 기본 hop limit은 1입니다. 브리지 네트워크의 컨테이너에서 메타데이터를 부르면 홉이 하나 더 들어가 응답이 드롭됩니다. 계정 수준 기본값도 설정돼 있지 않았습니다.
aws ec2 get-instance-metadata-defaults
그래서 run-instances에 명시하기로 했습니다.
--metadata-options HttpTokens=required,HttpPutResponseHopLimit=2,HttpEndpoint=enabled
증상이 자격 증명 오류로만 보이고 네트워크 홉 문제로는 보이지 않아, 띄운 뒤에 만나면 IAM 정책부터 한참 뒤지게 되는 종류입니다.
나머지 넷
| 실측 항목 | 계획서의 가정 | 실제 |
|---|---|---|
| 비용 알람 | 있겠지 | Budgets·빌링 알람 0개 |
| EBS 기본 암호화 | 켜져 있겠지 | OFF → 기동 시 Encrypted=true 명시 필요 |
| Coolify 내장 S3 백업 | 인스턴스 역할로 되겠지 | 액세스 키만 받음(공식 문서) |
| CPU 아키텍처 | x86 | arm64 가능 → 약 20% 저렴 |
aws budgets describe-budgets --account-id <account-id>
aws ec2 get-ebs-encryption-by-default
세 번째 항목은 층위를 헷갈리기 쉬운 자리였습니다. "인스턴스 역할이 있으니 액세스 키는 필요 없다"는 앱 코드에만 해당하고, Coolify 자체의 백업 기능은 별개 주체라 키를 따로 받습니다.
마지막 항목은 근거가 필요했습니다. Coolify는 arm64를 공식 지원하고, 이 스택의 컴포즈에는 platform 고정이 없으며 베이스 이미지도 멀티아치입니다. 네이티브 의존성은 sharp와 esbuild 정도인데 둘 다 arm64 prebuilt가 있고, 무엇보다 로컬 Apple Silicon에서 이미 arm64로 빌드해 돌리고 있었으니 사실상 한 번 검증된 셈이었습니다.
| 인스턴스 | 시간당 | 월 환산 |
|---|---|---|
t4g.medium (arm64) |
$0.0464 | 약 $42.8 |
t3.medium |
$0.0584 | 약 $51.6 |
t4g.large (arm64) |
$0.0928 | 약 $76.7 |
t3.large |
$0.1168 | 약 $94.2 |
ap-east-1 리눅스 온디맨드 퍼블릭 단가 기준. 월 환산은 730시간 + gp3 50GB($0.1056/GB·월) + 공인 IPv4($0.005/시간)를 합산한 값입니다.
사이즈는 large로 잡았습니다. Next.js 빌드를 같은 호스트에서 돌리면 4GB에서 OOM이 났다는 보고가 커뮤니티에 여럿 있습니다. 공식 수치가 아니라 단정할 수는 없지만 빌드가 죽는 쪽이 인스턴스 값보다 비쌉니다. 예전에 빌드 RAM 피크가 진짜 병목이었던 경험이 있어 이번에는 처음부터 여유를 뒀습니다. 빌드를 아예 밖으로 빼는 pull-only 구조도 대안이지만, 이번에는 CI 분(minute) 소비 0을 우선했습니다.
비용이 0인 것만 먼저 만들었습니다
판단이 끝난 뒤에도 EC2를 바로 띄우지는 않았습니다. 선을 규모가 아니라 비용과 되돌림에 그었습니다. 삭제 한 줄로 원복되고 유휴 비용이 0인 것만 먼저 만들었습니다.
- IAM 역할 + 인스턴스 프로파일 — SSM 관리 정책 + 버킷 하나로 좁힌 S3 인라인 정책. 키페어는 만들지 않았습니다.
- 보안그룹 — 인바운드 80/443만. 22번은 열지 않습니다.
- S3 버킷 — 퍼블릭 액세스 4종 차단, SSE-S3, BucketOwnerEnforced. 객체가 0이면 비용도 0입니다.
- EIP 한도 증설 요청 — 무료·비동기.
- 월 예산 + SNS 토픽 — 실적 90/100%, 예측 100% 임계.
버킷 생성에는 리전 함정이 있습니다. us-east-1이 아닌 리전에서는 LocationConstraint를 빼먹으면 만들어지지 않습니다.
aws s3api create-bucket --bucket <bucket> --region ap-east-1 \
--create-bucket-configuration LocationConstraint=ap-east-1
대시보드 포트(8000)를 열지 않고도 접속할 수 있는 것은 SSM 포트 포워딩 덕분입니다. 이게 되기 때문에 보안그룹을 80/443만으로 유지할 수 있었습니다.
aws ssm start-session --target <instance-id> \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["8000"],"localPortNumber":["8000"]}'
여기서 두 번 막혔습니다. 첫째, SNS 토픽 정책에 SNS:* 와일드카드를 쓰면 action out of service scope로 거부됩니다. 액션을 명시적으로 나열하고 budgets.amazonaws.com이 발행할 수 있게 허용하는 문을 따로 넣어야 했습니다. 그리고 구독을 붙이지 않으면 알림은 한 통도 오지 않습니다. 만들었다고 끝이 아니라는 것이 이 단계의 교훈이었습니다.
둘째는 로컬 쪽입니다. brew install --cask session-manager-plugin이 pkg 설치에 sudo를 요구해 비대화 셸에서 실패했습니다. AWS 공식 zip 번들에서 바이너리만 꺼내 사용자 소유 디렉터리에 넣으니 해결됐습니다.
install -m 0755 sessionmanager-bundle/bin/session-manager-plugin /opt/homebrew/bin/
Coolify 쪽에서 확인한 것들
설치 스크립트는 문서만 읽지 않고 원본을 열어봤습니다(확인 시점 v4.3.10).
| 항목 | 확인한 내용 |
|---|---|
| 최소 사양 | 2코어 / 2GB / 30GB. 스크립트는 디스크만 검사하고 미달이면 경고 후 진행 |
| OS | Ubuntu LTS(20.04/22.04/24.04) 전용, root 필요, Docker 24+ 자동 설치 |
| sshd | 건드리지 않음. PermitRootLogin이 꺼져 있으면 경고만 |
| 방화벽 | UFW 대신 클라우드 방화벽 권고(Docker의 NAT가 UFW를 우회) |
| 포트 | 도메인 연결 후 8000/6001/6002는 닫아도 됨 |
| 무인 설치 | ROOT_USERNAME / ROOT_USER_EMAIL / ROOT_USER_PASSWORD 지원 |
검색으로 나오는 install.sh -f/-n/-d 플래그는 v3 시절 유물이라 현행 스크립트에는 없습니다. 문서가 아니라 스크립트를 열어봐야 알 수 있는 종류였습니다.
한 가지는 단정하지 않고 남겨뒀습니다. Coolify는 localhost에도 SSH로 배포하는데, 이게 컨테이너에서 호스트로 가는 트래픽이라면 보안그룹 22번을 닫은 채로도 될 것으로 보입니다. 다만 첫 설치에서 실제로 확인해야 하는 부분이라 예상으로만 적어둡니다. 실측하지 않은 것을 실측한 것처럼 적으면, 이 글이 방금 지적한 계획서와 똑같아집니다.
남은 것과 교훈
EC2는 아직 띄우지 않았습니다. 지금까지 만든 것은 전부 유휴 비용이 0이고 삭제 명령 한 줄씩이면 사라집니다. 과금은 인스턴스를 켜는 순간부터라, 그 승인은 따로 받기로 했습니다.
돌이켜 생각해보면 이번에 얻은 것은 명령어 목록이 아니라 순서였습니다.
프로비저닝 계획은 쓴 날의 스냅샷입니다. 실행 직전에 describe로 전제를 다시 재야 합니다.
틀린 항목보다 빠져 있던 항목이 더 위험했습니다. 1순위 단계는 불필요했고, 정작 필수였던 hop limit·EIP 여유·DNS 권위는 계획서에 아예 없었습니다. 틀린 것은 실행하다 걸리지만 없는 것은 걸리지도 않습니다.
되돌릴 수 있는 것과 과금이 시작되는 것을 나누고, 전자만 먼저 합니다.
선을 규모가 아니라 비용과 되돌림에 그으면, 준비를 끝까지 해두고도 결정은 열어둘 수 있습니다.
검증은 생성과 다른 방법으로 합니다.
계획서를 계획서로 검토했을 때는 여덟 군데 모두 멀쩡해 보였습니다. 같은 경로로 확인하면 그 경로가 놓친 것은 영원히 보이지 않습니다.
아직 첫 배포는 남아 있습니다. 그때 무엇이 또 틀려 있었는지는 다음에 적겠습니다.
관련 글
OCI ARM64 서버 통합기: 빌드 RAM 병목을 GitHub Actions로 분리했습니다
무료 OCI ARM64 서버 2대를 1대로 합치려다, 진짜 병목이 런타임이 아니라 '빌드 RAM 피크'라는 걸 알게 됐습니다. 박스에서 빌드를 걷어내고 GitHub Actions 네이티브 arm64 러너로 빌드해 GHCR에 올리는 '빌드/실행 분리'로 통합을 해낸 회고입니다.
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.
Cloudflare R2 presigned 멀티파트 업로드 — mock 160개가 못 잡은 버그 2개
바이트가 서버를 통과하지 않는 순간, 서버는 파일 크기도 업로드 완료 여부도 스토리지에 남은 것도 잃습니다. R2 presigned 멀티파트를 붙이며 그 셋을 되찾은 과정과, mock 160개가 초록불인 채로 실물 레인 하나가 잡아낸 버그 두 개를 기록했습니다.