3줄 요약
- Gitea runner가 빌드한 산출물은 레지스트리를 경유해 운영 Proxmox VM들에 자동 배포된다.
- 배포 형태는 워크로드별로 다르다 — Spring Boot는 JAR(내장 톰캣), Keycloak·Kong·ELK는 Docker, PostgreSQL·nginx는 소스 설치.
- “전부 Docker”가 아니라 일의 성격에 맞는 도구를 선택하는 것이 핵심이다.
운영 서버에 배포한다는 것 — “전부 Docker”는 답이 아니다
개발 환경에서 컨테이너로 돌리던 MSA 서비스들을 운영 서버에 올릴 때, 많은 팀이 “개발에서 Docker 썼으니 운영도 전부 Docker”라는 결론으로 직행한다. 하지만 그 결론이 항상 옳지는 않다. 실제로 우리가 운영 Proxmox에 배포한 방식은 서비스마다 달랐다 — Spring Boot API는 JAR로, 인증·게이트웨이·로그 스택은 Docker로, DB와 웹 서버는 소스 설치로. 이 편에서는 그 선택의 근거와 Gitea runner에서 운영 VM까지 이어지는 전체 배포 파이프라인을 정리한다.
토폴로지 — 개발과 운영은 물리 서버부터 분리된다
먼저 구조를 명확히 해둔다. 우리 환경에서 개발과 운영은 서로 다른 물리 서버다.
- 개발 서버 — 구형 워크스테이션 1대. Intel Xeon E5-2690 v2, 듀얼 소켓 20코어, DDR3 64GB, 1TB SSD, Ubuntu 24. Proxmox 없이 Docker로 MSA 서비스들을 로컬 구동한다. Claude Code 작업·테스트·빌드가 이 서버에서 이루어진다.
- 운영 서버 — 별도 물리 장비에 설치된 Proxmox 1노드. 듀얼 소켓 Xeon E5-2683 v4(32코어/64스레드), DDR4 128GB, 6TB. 이 위에 단일 목적 VM을 여러 개 만들어 서비스를 격리 운영한다. Gitea와 PSTA도 이 Proxmox 위의 VM이다.
파이프라인의 흐름은 아래와 같다.
- 개발자(또는 Claude Code)가 개발 서버에서 소스를 작성·커밋한다.
- 소스를 Gitea(Proxmox VM)에 푸시한다.
- Gitea runner가 트리거되어 빌드 → 레지스트리 푸시를 수행한다.
- runner의 deploy job이 대상 운영 VM에 접속해 이미지를 pull하거나 JAR를 전달하고 서비스를 재기동한다.
레지스트리를 경유하는 이유는 단순하다 — 이미지 버전을 명시적으로 관리하고, 롤백 시 이전 태그를 그대로 pull해서 올릴 수 있기 때문이다.
★ 워크로드별 배포 형태 — 일의 성격에 맞는 도구
운영 Proxmox에 올라가는 서비스들의 배포 형태를 정리하면 아래와 같다. 서비스마다 선택이 다르다는 점이 핵심이다.
| 서비스 | 역할 | 배포 형태 | 이유 |
|---|---|---|---|
| Keycloak | 인증(SSO·토큰 발급) | Docker (레지스트리 이미지) | 공식 이미지 그대로 쓰는 것이 안전하고 업그레이드 쉬움 |
| Kong | API 게이트웨이 | Docker (레지스트리 이미지) | 플러그인·설정을 컨테이너 단위로 관리 |
| Elasticsearch + ELK | 서치·로깅 | Docker (레지스트리 이미지) | 스택 버전 일치 관리, 공식 이미지 의존성 포함 |
| Spring Boot API (다수) | 비즈니스 로직 API | JAR (내장 톰캣) | Docker 래핑 시 자원 오버헤드, 내장 톰캣으로 충분 |
| nginx | 프론트 서빙·리버스 프록시 | 소스 설치 (상주) | OS 수준 최적화, 컨테이너 레이어 불필요 |
| PostgreSQL | 메인 DB | 소스 설치 (상주) | 볼륨·퍼미션·WAL 관리 단순화, 컨테이너 레이어 불필요 |
| 백오피스 app | 내부 관리 화면 | VM 내 직접 배포 | 소규모·내부 서비스, 오버헤드 최소화 |
| Gitea · PSTA | 소스 관리·프로젝트 추적 | Docker (VM 내) | 격리 운영, 공식 이미지 활용 |
이 선택은 파티션 설계에서 이야기한 것과 같은 사고방식이다 — 일의 성격에 맞게 도구와 영역을 나눈다는 원칙. PostgreSQL을 컨테이너로 감싸면 볼륨 마운트·퍼미션·WAL 경로를 별도로 신경 써야 하는데, 소스 설치로 OS에 직접 두면 불필요한 레이어가 없어진다. 반대로 Keycloak처럼 공식 이미지가 잘 관리되고 업그레이드 이력이 중요한 서비스는 Docker가 오히려 편하다. Spring Boot API는 그 중간 — 내장 톰캣 JAR 하나가 이미 독립 프로세스이므로, Docker로 한 번 더 감쌀 이유가 없다.
왜 서비스별 VM으로 분산하나 — 격리와 장애 차단
하나의 큰 VM에 모든 서비스를 몰아넣으면 안 되는 이유는 MSA의 핵심 원칙과 맞닿아 있다. Elasticsearch가 메모리를 과점하면 같은 VM의 Kong이 응답을 멈춘다. PostgreSQL의 I/O 스파이크가 Spring Boot API 응답에 영향을 준다. 하나가 문제를 일으키면 나머지까지 연쇄 영향을 받는다.
VM 단위로 서비스를 분산하면 세 가지가 달라진다.
- 장애 전파 차단 — 한 VM이 죽어도 나머지는 영향 없다.
- 자원 분리 — CPU·메모리 할당을 서비스 특성에 맞게 독립적으로 조정할 수 있다(Elasticsearch는 메모리 많이, 백오피스는 적게).
- 배포 독립성 — Spring Boot API VM만 재시작해도 Keycloak VM에는 영향 없다. MSA 아키텍처의 독립 배포 이점이 인프라 레벨까지 이어진다.
프로젝트 관리로 치면 리스크 격리의 원칙이다. 한 팀의 문제가 전체 프로젝트를 멈추지 않도록 책임과 의존을 분리하는 것과 같다. 서버 파티션을 /app·/log·/data로 나누는 이유와 동일한 논리 — 관심사 분리는 코드 아키텍처부터 인프라까지 일관되게 적용된다.
실전 — runner에서 VM까지 배포하기
아래는 Gitea Actions runner의 deploy job 예시다. 환경마다 경로·이미지명·시크릿 구성이 다르므로 구조 참고용으로 활용할 것.
① Docker 서비스 배포 예시 (Keycloak·Kong·ELK)
# .gitea/workflows/deploy-keycloak.yml (예시 — 환경마다 달라짐)
name: Deploy Keycloak
on:
push:
branches: [main]
jobs:
build-push:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build and push to registry
run: |
docker build -t ${{ vars.REGISTRY }}/keycloak:${{ gitea.sha }} .
echo "${{ secrets.REGISTRY_PASSWORD }}" |
docker login ${{ vars.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker push ${{ vars.REGISTRY }}/keycloak:${{ gitea.sha }}
deploy:
needs: build-push
runs-on: self-hosted
steps:
- name: SSH deploy to Keycloak VM
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY_IAM }}
VM_HOST: ${{ secrets.IAM_VM_HOST }}
run: |
echo "$SSH_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no deploy@$VM_HOST
"docker pull ${{ vars.REGISTRY }}/keycloak:${{ gitea.sha }} &&
docker stop keycloak || true &&
docker run -d --name keycloak --restart unless-stopped
--network app-net
-v /data/keycloak:/opt/keycloak/data
${{ vars.REGISTRY }}/keycloak:${{ gitea.sha }}"
rm -f /tmp/deploy_key
- name: Verify health
env:
VM_HOST: ${{ secrets.IAM_VM_HOST }}
run: |
sleep 5
curl --fail --retry 5 --retry-delay 3 "http://$VM_HOST:8080/health/ready"
Docker를 쓰는 VM은 /etc/docker/daemon.json에 "data-root": "/data/docker"를 반드시 지정한다. 이미지·볼륨·컨테이너 로그가 루트 파티션(/)을 잠식하지 않도록 막는 핵심 설정이다.
② Spring Boot JAR 배포 예시
Spring Boot는 bootJar로 빌드한 실행 가능한 JAR를 VM에 직접 전달하고, systemd 서비스로 관리한다. Docker로 감싸지 않기 때문에 이미지 빌드·레지스트리 push 단계가 없어 파이프라인이 짧아진다.
# .gitea/workflows/deploy-api.yml (예시 — 환경마다 달라짐)
name: Deploy API
on:
push:
branches: [main]
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Gradle build
run: ./gradlew bootJar
- name: Upload JAR to VM via SCP
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY_API }}
VM_HOST: ${{ secrets.API_VM_HOST }}
run: |
echo "$SSH_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
scp -i /tmp/deploy_key -o StrictHostKeyChecking=no
build/libs/app.jar deploy@$VM_HOST:/app/api/app.jar
rm -f /tmp/deploy_key
- name: Restart via systemd and verify
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY_API }}
VM_HOST: ${{ secrets.API_VM_HOST }}
run: |
echo "$SSH_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no deploy@$VM_HOST
"sudo systemctl restart app-api && sleep 5 &&
systemctl is-active --quiet app-api && echo 'OK' || exit 1"
rm -f /tmp/deploy_key
③ systemd 서비스 단위 파일 예시 (Spring Boot용)
# /etc/systemd/system/app-api.service (예시)
[Unit]
Description=Spring Boot API
After=network.target postgresql.service
[Service]
User=appuser
WorkingDirectory=/app/api
ExecStart=/usr/bin/java -jar /app/api/app.jar
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
JAR 경로는 /app/api/에 둔다. /app 파티션은 엔진·소스·빌드 산출물 전용이므로 JAR가 자연스럽게 여기에 위치한다. 로그는 logback 설정으로 /log/{service}/에 쓰도록 별도 구성한다. systemd로 관리하면 Restart=on-failure와 함께 프로세스 수명을 OS가 관리해 고아 프로세스 문제를 방지한다.
흔한 함정 — 이것만 피하면 절반은 성공이다
- 시크릿 하드코딩 — SSH 키·레지스트리 비밀번호·DB 접속 정보를 YAML이나 소스에 직접 쓰면 레포 전체가 탈취된다. 반드시 Gitea Repository Secrets로 관리하고
${{ secrets.* }}로만 참조한다. - 헬스체크 없는 배포 —
docker run이나systemctl restart가 성공해도 서비스가 바로 죽을 수 있다. HTTP 헬스체크로 200 응답을 확인한 뒤 구 버전을 제거해야 한다. 헬스체크 없는 배포는 장애를 자동화하는 것과 같다. - 롤백 경로 부재 — 이미지 태그를
latest만 쓰면 이전 버전으로 돌아가기 어렵다. 커밋 SHA 태그를 함께 보관해 빠른 롤백이 가능하도록 한다. JAR도 이전 버전을/app/api/app-{sha}.jar로 남기고 symlink를 활용하면 된다. - Docker data-root 미설정 — 기본 경로(
/var/lib/docker)를 그대로 두면 이미지가 루트 파티션을 채워 VM 전체가 멈춘다. 설치 직후daemon.json을 설정하고 서비스를 재시작한다. - JAR 고아 프로세스 — systemd 없이
nohup java -jar &로 올리면 재배포 시 이전 프로세스가 남아 포트 충돌이 난다. systemd unit으로 관리하면 이 문제가 사라진다.
🎓 자동 배포와 프로젝트 관리는 한 쌍이다
파이프라인이 완성되면 “코드를 올리면 VM이 알아서 바뀐다”는 편리함을 얻는다. 하지만 어떤 변경이 언제 어느 VM에 반영됐는지 추적하지 않으면 장애 원인을 찾기가 급격히 어려워진다. 특히 Spring Boot API VM이 여러 개일 때 어느 VM의 어느 버전에서 문제가 생겼는지 알 수 없으면 디버깅이 답이 없어진다.
실제로 효과적인 방법은 Gitea 이슈를 배포 단위로 연결하는 것이다. Claude Code가 스스로 이슈를 등록하고, 관련 PR이 merge될 때 이슈 번호를 커밋 메시지에 남긴다. 어떤 기능이 어느 VM의 어느 서비스에 들어갔는지 이슈 히스토리만 봐도 파악된다 — MSA에서 서비스별 담당을 Gitea 이슈 안에서 지정하고 소통시키는 것, 사람 팀이 협업하듯 AI를 조율하는 것이 에이전틱 개발에서 프로젝트 관리가 핵심인 이유다.
FAQ
Q. Spring Boot는 왜 Docker로 안 감싸나요?
Spring Boot는 bootJar로 빌드하면 내장 톰캣이 포함된 실행 가능한 JAR 하나가 나온다. 이 JAR를 java -jar로 실행하면 웹 서버까지 포함된 독립 프로세스가 뜬다. Docker로 한 번 더 감싸면 컨테이너 레이어·이미지 빌드 시간·런타임 메모리 오버헤드가 추가된다. 단일 VM에서 여러 개의 Spring Boot API 서비스를 올릴 때 이 오버헤드가 누적된다. 내장 톰캣 JAR를 systemd로 관리하는 것이 더 가볍고, 파이프라인도 짧아진다.
Q. 개발 서버에 Proxmox를 올리면 안 되나요?
기술적으로 불가능하지는 않지만 분리를 강력히 권장한다. 개발 VM이 불안정하거나 재부팅될 때 운영 서비스까지 같이 내려가면 운영 장애가 된다. 리소스 충돌 문제도 있다 — 빌드 중 CPU 스파이크가 운영 DB에 영향을 줄 수 있다. 개발과 운영은 물리적으로 나누는 것이 가장 안전한 출발점이다.
Q. Proxmox 노드가 1개뿐이면 가용성은 괜찮나요?
노드 1개로 HA(고가용성) 클러스터 구성은 되지 않는다. 노드 자체가 다운되면 전체 VM이 멈춘다. 다만 VM 분산으로 소프트웨어 격리는 충분히 가능하고, 스타트업·소규모 팀이 초기 1노드로 시작하는 것은 현실적인 선택이다. VM 스냅샷·정기 백업으로 장애 복구 경로를 확보하고, 노드 다운 리스크를 인지한 상태에서 운영하는 것이 현실적인 접근이다. 예산과 요구사항에 따라 노드를 늘리는 것은 이후 확장 경로다.
Q. 레지스트리는 어디에 두나요? 퍼블릭을 써도 되나요?
퍼블릭 레지스트리(Docker Hub 등)를 써도 동작하지만, 내부 이미지가 외부에 올라가는 보안 리스크가 있다. Gitea 자체에 내장 컨테이너 레지스트리 기능이 있고, Harbor 같은 셀프호스트 레지스트리를 Proxmox VM으로 올리는 것도 선택지다. 어느 쪽이든 레지스트리 접근 인증을 Gitea Secrets에 넣고 runner에서만 참조하도록 구성한다.
MSA 배포 파이프라인 구축, 주니크와 함께 설계하고 싶다면
Gitea 셋업부터 Proxmox VM 구성, gitea runner 자동화까지 — 우리가 직접 운영하는 구조 그대로 컨설팅합니다. 어떤 규모의 MSA에서도 적용 가능한 VM 분산 배포 파이프라인 설계를 함께 고민해드립니다.
『에이전틱 개발 파이프라인』 시리즈
← 이전: 06. GitHub 대신 Gitea: 셀프호스팅 CI/CD
→ 다음: 08. AI-native 파이프라인 운영 회고