SSH 없는 서버에 CI/CD 붙이기 — 제약이 설계를 정한 4주
카카오테크캠퍼스 2단계 팀 프로젝트에서 배포 환경을 맡았다. 서버 한 대, SSH 키 없음, AWS 액세스 키 생성 차단. 이 제약들이 결과적으로 더 나은 설계를 강제했다.
0. 시작점 — 받은 환경
팀에 주어진 건 이게 전부였다.
항목 내용
| 서버 | t3.medium 1대 (2 vCPU / 4GB), 팀원 전원 공유 |
| OS | Ubuntu 24.04 LTS |
| 디스크 | 50GB 고정 |
| 접속 | SSH 키 없음, 22번 포트 닫힘, Session Manager만 |
그리고 차단 목록이 길었다.
- IAM 사용자·액세스 키 생성 불가
- RDS / ALB / EKS / ElastiCache 불가
- 서버 추가 생성 불가, 사양 변경 불가
- Elastic IP 할당 불가 (요청 시 검토)
처음엔 "이걸로 뭘 하라는 거지" 싶었는데, 하나씩 따져보니 이 제약들이 선택지를 좁혀줬다. 오히려 고민할 게 줄었다.
1. 제약이 배포 방식을 정했다
가장 흔한 배포 방법부터 하나씩 지워나갔다.
① SSH로 접속해서 배포 — 22번 포트가 닫혀 있고 키도 없다. 열 수는 있지만 안내문에 22번 포트를 0.0.0.0/0으로 열지 마세요라고 적혀 있었다. 탈락.
② GitHub Actions에서 AWS CLI로 배포 — GitHub Secrets에 AWS 액세스 키를 넣어야 하는데, 애초에 액세스 키를 만들 수 없다. 탈락.
③ self-hosted 러너를 서버에 설치 — GitHub이 우리한테 들어오는 게 아니라, 우리 서버가 GitHub에 "할 일 있어요?"를 물어보는 구조다. 포트도 키도 필요 없다. 유력 후보였다.
④ OIDC + SSM 원격 실행 — GitHub Actions가 OIDC로 AWS에 인증하고, AWS Systems Manager가 EC2에 명령을 대신 전달한다. 역시 키도 포트도 없다.
③과 ④ 중에 고민하다가, ④가 가능한지부터 확인하기로 했다. 확인 전용 워크플로를 하나 만들었다.
- name: 역할 권한 탐색
run: |
aws ssm send-command \
--instance-ids "$EC2_INSTANCE_ID" \
--document-name AWS-RunShellScript \
--parameters 'commands=["echo deploy-path-ok"]' \
--query 'Command.CommandId' --output text \
|| echo "NO ssm:SendCommand"
서버에서 echo 하나만 실행하는 무해한 호출이다. 결과:
인증 assumed-role/ktc-github-deploy/GitHubActions ✅
SSM 원격 실행 CommandId 283019a5-... ✅
됐다. 서버에 러너를 상시 띄우지 않아도 되고, 등록·갱신 같은 관리도 없고, 2 vCPU를 러너와 나눠 쓰지 않아도 된다. ④로 갔다.
이때 얻은 교훈 하나. "될 것 같다"와 "된다" 사이에 확인 워크플로 하나를 끼워 넣는 게 훨씬 빨랐다. 배포 파이프라인을 다 짜고 나서 권한이 없는 걸 발견했으면 설계를 통째로 다시 했어야 했다.
2. 서버 준비 — 4GB에서 살아남기
t3.medium은 RAM이 4GB다. pip install이 순간적으로 메모리를 크게 먹는데, 그때 PostgreSQL이 OOM으로 죽는 사고가 흔하다.
sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
디스크도 50GB 고정이라 방어가 필요했다. 도커는 기본값이 로그 무제한이다. 몇 주 돌면 수십 GB가 된다.
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
그리고 매일 새벽 안 쓰는 이미지 정리.
0 4 * * * docker system prune -af --filter until=168h
docker system prune은 --volumes를 안 붙이면 볼륨을 건드리지 않는다. DB 데이터는 안전하다. 이걸 몰라서 정리를 못 걸어두는 경우가 많다.
3. .env를 워크스페이스 밖에 둔 이유
이게 처음에 안 보였던 함정이다.
actions/checkout은 기본으로 git clean -ffdx를 돌린다. git이 추적하지 않는 파일을 전부 지운다는 뜻이고, .env가 정확히 거기 해당한다(.gitignore에 있으니까).
그래서 서버에서 .env를 만들어봤자 다음 배포 때 지워진다. 더 나쁜 건, 스크립트가 .env를 자동 생성하는 구조였다면 새 비밀번호로 다시 만들어지고, PostgreSQL 볼륨에는 처음 비밀번호가 박혀 있어서 인증 실패로 DB가 안 붙는다. 원인 찾기 정말 어려운 버그다.
그래서 이렇게 나눴다.
/opt/aidam/backend.env ← 원본. 워크스페이스 밖. 손으로 관리
/opt/aidam/app/backend/.env ← 배포 때마다 위에서 복사해옴
4. CI를 먼저 붙였다
배포보다 CI가 먼저였다. 이유는 단순하다. 자동 배포가 붙는 순간, 깨진 코드가 머지되면 그대로 서버까지 올라간다.
ruff 도입에서 겪은 것
팀 문서에는 "Ruff 하나로 린트+포맷"이라고 적혀 있었는데, 정작 ruff가 프로젝트에 설치돼 있지 않았다. 규칙과 현실이 어긋나 있던 상태.
설치하고 돌려보니 22건 지적, 21개 파일 재포맷 대상이었다. 여기서 가장 신경 쓴 게 "포맷터가 코드 의미를 바꾸지 않았다"를 어떻게 증명하느냐였다. 남의 코드 21개 파일을 건드리는 거라서.
import ast
old = subprocess.run(["git","show",f"HEAD:{f}"],capture_output=True,text=True).stdout
new = open(f).read()
assert ast.dump(ast.parse(old)) == ast.dump(ast.parse(new))
포맷 전후의 구문 트리(AST)를 비교했다. 파이썬이 코드를 이해한 결과가 완전히 같다면, 바뀐 건 공백과 줄바꿈뿐이다. 전 파일 통과했고, 이걸 PR 본문에 근거로 적었다.
커밋도 나눴다. chore:(설정) / style:(재포맷) / test:(수동 수정). 리뷰어가 style 커밋은 안 읽어도 되게.
CI가 바로 실전에서 잡았다
CI를 붙이자마자 첫 PR이 빨간불이 났다.
ERROR collecting tests/test_celery_smoke.py
→ celery_app → get_settings()
→ ValidationError: postgres_password Field required
내 로컬에는 .env가 있어서 98개 테스트가 다 통과했는데, CI에는 .env가 없어서(당연히, 커밋 금지 파일이니) 터진 거다.
CI가 없었다면 저장소를 새로 받은 팀원이 그대로 겪었을 문제다. 테스트가 개발자 개인 환경에 기대고 있다는 걸 기계가 알려줬다.
# tests/conftest.py
os.environ.setdefault("POSTGRES_PASSWORD", "test-only-password")
pytest는 테스트 파일보다 conftest.py를 먼저 읽는다. 여기서 채우면 수집 단계가 안 깨진다.
그 뒤로는 .env를 잠깐 치우고 테스트를 돌리는 게 습관이 됐다.
mv .env .env.bak && pytest -q; mv .env.bak .env
5. 배포 — 손으로 먼저, 그다음 자동화
팀 규칙에 이런 게 있었다.
완성본을 한 번에 올리지 않고, 단계마다 서버가 정상 동작하는지 확인하고 다음으로 갑니다.
그래서 자동화하기 전에 서버에서 손으로 한 번 끝까지 띄웠다. 그래야 나중에 실패했을 때 "코드가 문제냐 워크플로가 문제냐"가 갈린다.
그 과정에서 바로 걸렸다.
pydantic_core.ValidationError: 1 validation error for Settings
app_env
Input should be 'local', 'test' or 'production' input_value='prod'
APP_ENV=prod라고 적었는데 코드가 받는 값은 production이었다. 손으로 안 해봤으면 이걸 워크플로 디버깅하면서 찾았을 거다.
배포 절차를 스크립트로 분리
워크플로에 배포 명령을 인라인으로 박지 않고 backend/scripts/deploy.sh로 뺐다. 서버에서 똑같이 돌려볼 수 있어야 문제를 좁힐 수 있기 때문이다.
echo "== 1/6 서버 설정 파일 확인 =="
echo "== 2/6 .env 키 대조 =="
echo "== 3/6 이미지 빌드 =="
echo "== 4/6 postgres·redis 기동 =="
echo "== 5/6 마이그레이션 =="
echo "== 6/6 api·worker 기동 =="
단계마다 번호를 찍어둔 게 나중에 큰 도움이 됐다. 실패 로그만 봐도 어디서 멈췄는지 바로 보인다.
2/6이 5일치 사고를 막았다
.env.example과 서버 .env의 키 목록을 대조하는 단계를 넣었다.
missing=$(comm -23 \
<(grep -o '^[A-Z_][A-Z0-9_]*=' "$COMPOSE_DIR/.env.example" | sort) \
<(grep -o '^[A-Z_][A-Z0-9_]*=' "$COMPOSE_DIR/.env" | sort))
if [ -n "$missing" ]; then
echo "실패: 서버 .env에 없는 키가 있습니다."
echo "$missing"
exit 1
fi
그리고 실제로 이게 걸렸다. 팀원이 얼굴 임베딩 암호화 기능을 머지하면서 .env.example에 키를 두 개 추가했는데, 서버 .env에는 없었다.
== 2/6 .env 키 대조 ==
실패: 서버 .env에 없는 키가 있습니다.
FACE_EMBEDDING_KEY=
FACE_EMBEDDING_KEY_REF=
이 검사가 없었으면 5분 빌드하고 컨테이너 기동 단계에서 Field required로 죽었을 거다. 로그도 훨씬 지저분했을 거고.
다만 여기서 다른 교훈도 얻었다. 알림이 정확해도 사람이 조치를 안 하면 소용없다. 이 배포는 5일간 같은 이유로 실패했다. 로그는 매번 정확히 뭘 해야 하는지 말하고 있었는데, 실패 알림이 디스코드로 안 가고 있었던 게 문제였다.
원격 명령을 안전하게 전달하기
SSM으로 보낼 명령에 여러 줄 스크립트를 따옴표로 감싸 넣으려니 이스케이프가 지저분해졌다. 리뷰에서도 "값에 따옴표가 섞이면 조용히 깨진다"는 지적을 받았다.
base64로 바꿨다.
ENCODED=$(printf '%s' "$SCRIPT" | base64 -w0)
REMOTE="set -e
trap 'rm -f /tmp/aidam-deploy.sh' EXIT
umask 077
printf '%s' '${ENCODED}' | base64 -d > /tmp/aidam-deploy.sh
chown ubuntu:ubuntu /tmp/aidam-deploy.sh
runuser -l ubuntu -c 'bash /tmp/aidam-deploy.sh'"
base64 문자 집합에는 따옴표가 없다. 서버에서 실행할 명령이 변수가 끼어들지 않는 고정 문자열이 된다.
runuser -l ubuntu도 이유가 있다. SSM은 기본이 root라 그냥 두면 .env와 도커 산출물이 root 소유가 되고, 팀원이 서버에서 손으로 다룰 때 막힌다.
결과를 기다리는 부분
aws ssm send-command는 명령을 던지고 바로 돌아온다. 결과를 안 기다리면 배포가 실패해도 워크플로는 초록불이다.
aws ssm wait command-executed가 있긴 한데 최대 100초라 빌드 시간을 못 버틴다. 직접 폴링했다. 그리고 리뷰에서 이런 지적을 받았다.
권한 오류로 실패해도 || echo Pending으로 삼켜져서 "아직 진행중"과 똑같이 취급됩니다. 120회(20분)를 다 채우고서야 실패로 끝나게 됩니다.
맞는 말이었다. 명령이 아직 등록되지 않은 경우(InvocationDoesNotExist)만 재시도하고, 나머지 오류는 즉시 멈추게 고쳤다.
6. 머지 차단 — CI에 이빨 달기
워크플로만 있으면 빨간 X는 뜨는데 머지 버튼은 안 막힌다. 무시하고 머지하면 그냥 된다.
gh api repos/{owner}/{repo}/rulesets -X POST --input ruleset.json
{
"name": "develop-ci",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["refs/heads/develop"] } },
"rules": [{
"type": "required_status_checks",
"parameters": { "required_status_checks": [{ "context": "backend" }] }
}]
}
여기서 함정을 하나 밟았다. 룰셋을 켜자마자 기존 PR 두 개가 영구 대기 상태가 됐다. 두 PR 모두 CI가 생기기 전에 열려서 backend 검사가 한 번도 돈 적이 없는데, 룰셋은 그 검사를 요구하니 "검사 대기 중"에서 멈춘 것이다.
일시적으로 끄고, 해당 PR 저자들에게 develop을 받아 push해달라고 요청한 뒤 다시 켰다.
같은 이유로 CI 워크플로에 paths 필터를 넣지 않았다. 필터에 걸려 실행되지 않은 PR은 required check를 영원히 못 채운다. 문서만 고친 PR에서도 그냥 돌리고 30초 만에 끝나게 두는 편이 낫다.
7. HTTPS — Caddy + 도메인
여기까지 오면 서버에서 앱이 돌지만, 외부에서는 아무것도 안 보인다. compose가 API를 127.0.0.1:8000에만 묶어놨기 때문이다. 의도적이었다 — 아무것도 없는 서버를 인터넷에 열어두면 몇 시간 안에 스캔봇이 붙는다.
공개하려면 네 가지가 필요했다.
필요한 것 왜
| 리버스 프록시 | 80/443을 받아 내부 8000으로 넘김 |
| HTTPS 인증서 | 브라우저가 http API 호출을 막음 |
| 고정 IP | EIP 없으면 서버 재시작 시 주소가 바뀜 |
| 도메인 | 인증서 발급에 필요 |
EIP는 운영진에 요청해서 받았고, 도메인은 무료 서비스로 잡았다. 인증서는 Caddy가 알아서 해준다.
{$PUBLIC_HOST} {
reverse_proxy api:8000
}
이게 전부다. Caddy가 Let's Encrypt에서 발급받고 갱신까지 한다. 대신 두 가지가 전제다.
- 도메인 A 레코드가 서버 EIP를 가리킬 것
- 보안그룹에 80·443이 열려 있을 것 (80은 인증서 소유권 확인에 쓰인다)
발급받은 인증서는 볼륨에 저장된다. 볼륨을 지우면 재시작마다 새로 발급받게 되는데, Let's Encrypt는 주당 발급 횟수 제한이 있어서 결국 HTTPS가 끊긴다.
헬스 체크를 두 단계로
앱이 살아 있어도 프록시가 앞에서 막히면 사용자에게는 장애(502)다. 그래서 공개 경로도 확인하게 했다.
curl -fsS --resolve "${PUBLIC_HOST}:443:127.0.0.1" "https://${PUBLIC_HOST}/health"
--resolve로 이 서버의 Caddy에 직접 붙는다. 외부 DNS를 한 바퀴 돌지 않아서, 프록시 자체의 문제와 DNS 전파 문제가 섞이지 않는다. 인증서 검증은 그대로 한다.
8. 리뷰에서 배운 것
팀 리뷰에서 나온 지적들이 실제로 좋았다.
① 프록시 헤더가 빠졌다
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
이대로면 Caddy 뒤에서 request.client.host가 Caddy 컨테이너 IP로 찍힌다. 지금은 IP를 쓰는 코드가 없어서 티가 안 나는데, 접근 로그에 IP를 남기기 시작하면 전 기록이 같은 값이 된다. 그때 발견하면 이미 쌓인 로그를 못 쓴다.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", \
"--proxy-headers", "--forwarded-allow-ips", "*"]
*가 위험해 보이지만, 이 컨테이너는 Caddy 외에 도달할 경로가 없다. 다만 나중에 포트를 외부로 여는 변경이 생기면 이 값을 좁혀야 한다. 주석으로 박아뒀다.
② /docs가 외부에 열렸다
FastAPI는 기본으로 /docs, /redoc, /openapi.json을 서빙한다. 127.0.0.1 바인딩일 때는 문제가 없었는데, Caddy가 경로 제한 없이 프록시하면서 처음으로 외부에 노출됐다.
완전히 끄지 않고 환경으로 갈랐다. 프론트가 로컬에서 스키마를 봐야 하니까.
_docs_public = get_settings().app_env != "production"
app = FastAPI(
docs_url="/docs" if _docs_public else None,
redoc_url="/redoc" if _docs_public else None,
openapi_url="/openapi.json" if _docs_public else None,
)
③ 도메인 하드코딩
Caddyfile에 도메인을 직접 적어뒀는데, 팀 규칙이 "코드에 URL·키·비밀번호를 박지 않는다"였다. 처음엔 "도메인은 공개값이니 예외"라고 답했다가 철회했다. 규칙을 바꾸려면 팀 승인을 받는 게 순서지, 내가 임의로 해석할 자리가 아니었다.
.env의 PUBLIC_HOST 하나를 원본으로 두고 세 곳이 읽게 했다.
.env PUBLIC_HOST
├─ compose가 caddy 컨테이너에 주입
├─ Caddyfile이 {$PUBLIC_HOST}로 읽음
└─ deploy.sh가 공개 경로 확인에 씀
덤으로, Caddyfile을 grep으로 파싱하던 취약한 코드가 통째로 사라졌다. 반대했던 변경이 결과적으로 코드를 더 단순하게 만들었다.
9. 완성된 흐름
코드 작성 → PR
↓ ① pytest + ruff 통과해야 머지 버튼이 열림
머지
↓ ② GitHub Actions → OIDC → AWS SSM → EC2에서 deploy.sh
↓ 설정 확인 → 키 대조 → 빌드 → DB 기동 → 마이그레이션
↓ → 앱 기동 → 앱 헬스체크 → 공개 경로 헬스체크
↓ ③ 배포 태그 자동 생성 (롤백용)
https://<도메인> ← 갱신됨
실제 배포 로그는 이렇게 나온다.
== 1/6 서버 설정 파일 확인 ==
== 2/6 .env 키 대조 ==
== 3/6 이미지 빌드 ==
== 4/6 postgres·redis 기동 ==
== 5/6 마이그레이션 ==
== 6/6 api·worker 기동 ==
== 헬스 체크 1/2: 앱 ==
{"status":"ok","database":"ok"}
== 헬스 체크 2/2: 공개 경로 ==
{"status":"ok"}
배포 성공
태그 생성: deploy-20260929-1126
10. 남겨둔 것
정직하게 적으면, 아직 안 한 게 많다.
- DB 백업 — RDS를 못 쓰니 PostgreSQL이 서버 안 컨테이너 볼륨 하나에 들어 있다. 지금 백업이 없다. 데이터가 들어가기 전에 붙여야 한다
- ECR — 멘토 리뷰에서 나온 제안. 지금은 EC2가 직접 빌드해서 배포 중 서비스가 1~2분 느려진다. GitHub Actions에서 빌드해 레지스트리에 올리고 EC2는 pull만 하는 게 낫다
- 서버 설정의 코드화 — 스왑, 로그 제한, cron이 전부 저장소 밖에 있다. 서버를 새로 받으면 재현을 못 한다
- 모니터링·알림 — 배포 실패를 5일간 아무도 몰랐던 이유
정리하며
돌아보면 제약이 설계를 좋게 만든 경우가 많았다.
- 액세스 키를 못 만들어서 → OIDC를 쓰게 됐고, 저장소에 장기 자격증명이 하나도 없다
- SSH를 못 써서 → SSM을 쓰게 됐고, 열어둔 포트가 없다
- 서버가 한 대라서 → 쿠버네티스를 안 쓰게 됐고, 그 시간을 제품에 썼다
- RDS를 못 써서 → DB가 compose 안에 있고, 로컬과 서버의 구성이 같다
물론 제약이 그냥 불편하기만 한 것도 있었다(디스크 50GB, 서버 한 대 공유). 그래도 "뭘 쓸까"를 고민하는 시간이 줄고 "어떻게 안전하게 굴릴까"에 집중하게 된 건 분명한 이득이었다.
가장 크게 배운 건 이거다. 파이프라인은 사람이 실수하는 지점에서 멈춰줘야 한다. .env 키 대조 한 단계가, 5분 빌드하고 알 수 없는 에러로 죽는 상황을 막았다. 그리고 그 알림이 사람에게 닿지 않으면 아무 소용이 없다는 것도 같이 배웠다.