본문 바로가기
카테고리 없음

SSH 없는 서버에 CI/CD 붙이기 — 제약이 설계를 정한 4주

by 발빠진 쥐 2026. 9. 29.

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분 빌드하고 알 수 없는 에러로 죽는 상황을 막았다. 그리고 그 알림이 사람에게 닿지 않으면 아무 소용이 없다는 것도 같이 배웠다.