온프레미스 + AWS 하이브리드 헬스케어 플랫폼 구축기
OpenStack 위에 K8s 클러스터를 올리고, Terraform으로 AWS 인프라를 코드 한 줄로 찍어내고, strongSwan VPN으로 온프레미스와 AWS를 이어붙인 팀 프로젝트 전 과정 기록이다.
프로젝트 개요
헬스케어 데이터 분석 파이프라인을 온프레미스(OpenStack + K8s)와 AWS(SQS + Spot EC2 + S3)를 연결해 구축한 하이브리드 클라우드 프로젝트다.
핵심 흐름:
기술 스택:
| 영역 | 기술 |
|---|---|
| 온프레미스 가상화 | OpenStack (KVM) |
| 컨테이너 오케스트레이션 | Kubernetes v1.29 + Calico CNI |
| IaC | Terraform (S3 백엔드 + DynamoDB 락) |
| 구성 관리 | Ansible (j2 템플릿 + ProxyJump) |
| VPN | strongSwan IPSec IKEv1 + NAT-T |
| 메시지 큐 | AWS SQS (입력/결과/DLQ) |
| 백업 | Veeam Agent → S3 SSE-KMS |
| 웹 노출 | SSH 리버스 터널 + Nginx + Let’s Encrypt |
1. OpenStack 환경 구성 및 VM 생성
네트워크 설계
| 네트워크 | 대역 | 용도 |
|---|---|---|
| public | 10.0.0.0/8 | KVM 물리 서버 대역 |
| private | 172.16.0.0/24 | VM 내부 통신 대역 |
VM 구성
| VM 이름 | IP | 역할 |
|---|---|---|
| Compute-VM-1 | 172.16.0.216 | K8s Master |
| Compute-VM-2 | 172.16.0.166 | K8s Worker 1 |
| Compute2-VM | 172.16.0.202 | K8s Worker 2 |
| DB-VM | 172.16.0.157 | MariaDB |
| VPN-VM | 172.16.0.138 | strongSwan VPN |
VM 생성 (CLI)
source /root/keystonerc_admin
# 특정 compute 노드에 VM 배치
openstack server create \
--flavor m1.large \
--image Ubuntu-22.04-Cloud \
--network private \
--availability-zone nova:compute2 \
<VM이름>
# 상태 확인
openstack server list
availability-zone nova:compute2→ KVM 물리 서버 compute2에 VM 직접 배치. 하이퍼바이저 레벨 배치 제어다.
2. K8s 클러스터 구축
대역 설계가 핵심이다
처음에 Flannel로 시작했다가 통신이 전혀 안 됐다. 이유는 대역 충돌이었다.
| 대역 | 용도 | 주의사항 |
|---|---|---|
| 172.16.0.0/24 | 온프레미스 VM 대역 | service-cidr과 겹치면 CoreDNS 죽음 |
| 10.0.0.0/8 | KVM 물리 서버 대역 | Flannel 기본 pod-cidr 10.244.0.0/16과 충돌 |
| 192.168.0.0/16 | K8s Pod 대역 (Calico) | Flannel 대신 Calico 선택 이유 |
| 10.96.0.0/12 | K8s Service 대역 | 반드시 이걸 써야 CoreDNS 정상 |
설치 순서
모든 노드 공통 사전 설정:
sudo -i
# swap 비활성화 (K8s 필수)
swapoff -a
sed -i '/ swap / s/^/#/' /etc/fstab
# 브릿지 네트워크 설정
cat <<EOF | tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
modprobe overlay
modprobe br_netfilter
cat <<EOF | tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system
# kubeadm 설치
apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl
Master 초기화:
kubeadm init \
--apiserver-advertise-address=172.16.0.216 \
--pod-network-cidr=192.168.0.0/16 \
--service-cidr=10.96.0.0/12
# kubeconfig 설정
mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
Calico CNI 설치:
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml
Worker 노드 Join:
kubeadm join 172.16.0.216:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
겪은 삽질들
CoreDNS 계속 재시작하는 문제
- 원인:
--service-cidr미설정 → CLUSTER-IP가 172.16.x.x 대역으로 잡힘 → VM 대역과 충돌 → CoreDNS가 API 서버 못 찾음 - 해결: 전체 클러스터 초기화 후
--service-cidr=10.96.0.0/12명시
Flannel → Calico 교체
- 원인: Flannel 기본 pod-cidr
10.244.0.0/16이 KVM 물리 서버 대역10.0.0.0/8과 충돌 - 해결: Calico로 교체,
192.168.0.0/16지정
ErrImageNeverPull
- 원인: Master VM에서만 이미지 빌드하고 Worker 노드에 이미지 없음
- 해결:
docker save → scp → ctr import방식으로 Worker 전체에 배포
docker save mes-sender:latest -o /tmp/mes-sender.tar
scp /tmp/mes-sender.tar ubuntu@172.16.0.166:/tmp/
sudo ctr -n k8s.io images import /tmp/mes-sender.tar
3. Pod 배포
Pod 구성
| Pod | 이미지 | 역할 |
|---|---|---|
| mes-data-service | mes-sender:latest | pending/ JSON → SQS 입력 큐 전송 |
| sqs-polling-service | sqs-receiver:latest | SQS 결과 큐 폴링 → MariaDB 저장 |
| dlq-consumer | dlq-consumer:latest | DLQ 모니터링 → 실패 메시지 로그 |
| healthcare-web | healthcare-web:latest | FastAPI 대시보드 (gatsa.shop) |
로컬 이미지 배포 방식
레지스트리 없이 imagePullPolicy: Never로 로컬 이미지를 사용한다. Worker 노드마다 이미지를 직접 넣어야 한다.
# 이미지 빌드 (Master VM)
docker build -f Dockerfile.sender -t mes-sender:latest .
# Worker 노드에 배포
docker save mes-sender:latest -o /tmp/mes-sender.tar
scp /tmp/mes-sender.tar ubuntu@172.16.0.166:/tmp/
scp /tmp/mes-sender.tar ubuntu@172.16.0.202:/tmp/
# Worker에서 이미지 로드
sudo ctr -n k8s.io images import /tmp/mes-sender.tar
# AWS 인증 Secret 주입
kubectl create secret generic aws-credentials \
--from-literal=AWS_ACCESS_KEY_ID=<키> \
--from-literal=AWS_SECRET_ACCESS_KEY=<시크릿>
# 배포
kubectl apply -f deployment.yaml
kubectl apply -f dlq_deployment.yaml
kubectl apply -f web_deployment.yaml
정상 상태:
4. Terraform으로 AWS 인프라 구축
terraform apply 한 번으로 AWS 리소스 80개 이상을 자동 생성한다. 완료 시 Ansible이 바로 쓸 수 있는 inventory.ini, ansible.cfg, vpn_vars.yml까지 자동 생성된다.
생성되는 주요 리소스
네트워크:
Site-to-Site VPN:
SQS 큐 4개:
| 큐 | 역할 | 보존 |
|---|---|---|
| Healthcare-input-queue | 온프레미스 → AWS | 1일 |
| Healthcare-input-dlq | 3회 실패 격리 | 14일 |
| Healthcare-output-queue | AWS → 온프레미스 | 1일 |
| Healthcare-output-dlq | 결과 큐 실패 격리 | 14일 |
오토스케일링:
EventBridge + Lambda:
실행 순서
사전 준비 (최초 1회):
# 1. DynamoDB 락 테이블 수동 생성 (init 전에 반드시)
aws dynamodb create-table \
--table-name 3team3mission \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region ap-northeast-2
# 2. Lambda zip 준비
Compress-Archive -Path dlq_reprocessor.py -DestinationPath dlq_reprocessor.zip
terraform init
terraform plan # Plan: 80 to add
terraform apply
겪은 삽질들
Billing 알람이 us-east-1에서만 동작하는 문제
- AWS Billing 메트릭은 us-east-1 전용
- 해결: provider.tf에 alias 프로바이더 추가 후 billing_alarm 리소스에
provider = aws.us_east_1지정
tfstate 불일치 (콘솔에서 수동 삭제 후)
terraform state rm <리소스 주소>
5. Ansible 자동화
Terraform이 생성한 변수를 j2 템플릿으로 서버에 자동 주입한다. 터널 IP, PSK 같은 apply마다 바뀌는 값들을 수동으로 수정할 필요가 없다.
변수 흐름
ProxyJump 구조
Spot 인스턴스는 프라이빗 서브넷에 있어서 직접 접근 불가. ansible.cfg에 ProxyJump 설정으로 Bastion 경유를 자동화했다.
[ssh_connection]
ssh_args = -o StrictHostKeyChecking=no -o ProxyJump=ec2-user@<Bastion공인IP>
실행 순서 (순서 중요)
# VPN 먼저 올라야 EC2 프라이빗 IP 접근 가능
ansible-playbook -i inventory.ini deploy_vpn.yml
ansible-playbook -i inventory.ini deploy_analyzer.yml
ansible-playbook -i inventory.ini deploy_veeam.yml
겪은 삽질들
deploy_vpn.yml에서 VPN 노드도 ProxyJump 적용되는 문제
- VPN VM은 온프레미스에 있어서 Bastion 경유 불필요
- 해결: deploy_vpn.yml에
ansible_ssh_common_args직접 오버라이드
vars:
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
6. VPN 구성 (strongSwan IPSec)
왜 strongSwan인가
OpenStack Neutron 바닐라 버전은 VPNaaS를 지원하지 않는다. 그래서 VPN 전용 VM에 strongSwan 소프트웨어를 직접 설치했다.
강의실 NAT 뒤에 있어서 UDP 500/4500을 Neutron 포트 포워딩으로 VPN VM에 전달하고, NAT-T(NAT Traversal) 방식으로 IPSec 터널을 연결했다.
설정
# Neutron 포트 포워딩
openstack floating ip port forwarding create \
--internal-ip-address 172.16.0.138 \
--internal-port 500 --external-port 500 \
--protocol udp <floating_ip_id>
openstack floating ip port forwarding create \
--internal-ip-address 172.16.0.138 \
--internal-port 4500 --external-port 4500 \
--protocol udp <floating_ip_id>
# strongSwan 설치
apt install -y strongswan
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p
ipsec.conf 핵심 설정:
dpdaction=restart: Dead Peer Detection. 터널이 끊기면 자동으로 재연결 시도한다.
Terraform apply마다 터널 IP와 PSK가 바뀌기 때문에 수동 수정 대신 Ansible로 자동화한다:
ansible-playbook -i inventory.ini deploy_vpn.yml
확인:
sudo ipsec status
# Security Associations (2 up, 0 connecting):
# aws-vpn-tunnel1[1]: ESTABLISHED
# aws-vpn-tunnel2[2]: ESTABLISHED
7. Veeam 백업
구조
- cron으로 매일 02:00 자동 실행
- S3 STANDARD_IA 스토리지 클래스 (저비용)
- KMS 암호화:
alias/healthcare-key - 로컬 7일, S3 30일 보존 후 자동 삭제
KVM 인스턴스 장애 감지 (check_instance.sh)
CloudWatch Custom 메트릭으로 KVM VM 목록 변화를 감지한다. sort 없이 비교하면 출력 순서 차이로 오탐이 발생하기 때문에 반드시 정렬 후 비교해야 한다.
CURRENT=$(virsh list --all --name | sort | grep -v "^$")
BASELINE_SORTED=$(sort "$BASELINE")
if diff <(echo "$CURRENT") <(echo "$BASELINE_SORTED") > /dev/null; then
aws cloudwatch put-metric-data --metric-name InstanceFailedCount --value 0 ...
else
aws cloudwatch put-metric-data --metric-name InstanceFailedCount --value 1 ...
fi
장애 감지 시: CloudWatch 알람 → EventBridge → Lambda → SNS 이메일로 연결된다.
8. Web 프록시 (SSH 리버스 터널 + Nginx)
문제 상황
온프레미스 VM이 강의실 NAT 뒤에 있어서 공인 IP를 직접 소유할 수 없다. 외부에서 K8s Pod에 직접 접근 불가.
해결: SSH 리버스 터널
AWS EC2 탄력적 IP를 공인 IP 창구로 활용하고, VPN 서버에서 SSH 리버스 터널로 AWS EC2 8080포트를 온프레미스 Pod와 연결했다.
핵심 개념: SSH 리버스 터널은 내부에서 먼저 파이프를 열어두는 방식이다. 외부에서 안으로 들어오는 게 아니라 내부에서 연결을 만들어두기 때문에 NAT 뒤에 있어도 외부 노출이 가능하다.
# VPN 서버에서 실행 (VPN 서버는 항상 켜져있어서 터널 유지 가능)
nohup ssh -i ~/dana-01.pem -N \
-R 8080:172.16.0.166:30080 \
ubuntu@3.39.181.197 &
Nginx 설정:
server {
listen 443 ssl;
server_name gatsa.shop;
ssl_certificate /etc/letsencrypt/live/gatsa.shop/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gatsa.shop/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
개선 방향:
autossh로 교체 시 터널 끊겨도 자동 재연결 가능하다.
9. 발표 시연 체크리스트
발표 30분 전 확인
# Pod 전체 상태
kubectl get pods
# VPN 터널 확인
ssh -i /root/project3/vpn.pem ubuntu@172.16.0.138 "sudo ipsec status"
# SSH 리버스 터널 확인
ps aux | grep ssh | grep 8080
# 웹 접속 확인
curl -I https://gatsa.shop
AWS 콘솔 탭 미리 열기
시연 순서
1. VPN 확인:
ssh -i /root/project3/vpn.pem ubuntu@172.16.0.138 "sudo ipsec status"
# → ESTABLISHED 2개 보여주기
2. 파이프라인 흐름:
kubectl cp /home/ubuntu/fill_to_3000.py mes-data-service-<pod>:/app/
kubectl exec -it mes-data-service-<pod> -- python3 /app/fill_to_3000.py
# → gatsa.shop에서 환자 데이터 실시간 증가 확인
3. Scale Out/In:
kubectl exec -it mes-data-service-<pod> -- python3 /app/generate_200.py
# → EC2 Auto Scaling 활동 탭에서 desired 1→2 증가 확인
# → 큐 소진 후 3분 뒤 2→1 Scale In
4. DLQ:
kubectl exec -it mes-data-service-<pod> -- python3 /app/generate_dlq_errors.py
# → SQS Healthcare-input-dlq 메시지 수 5 증가 확인
프로젝트 회고
잘 됐던 것들
- Terraform의
local_file리소스로 Ansible 설정 파일까지 자동 생성한 것. apply 한 번으로 IaC → 구성 관리까지 이어지는 자동화 파이프라인을 만들었다. - Calico CNI 선택. Flannel은 대역이 고정이라 KVM 환경에서 충돌이 생기는데, Calico는 자유롭게 설정 가능해서 문제를 깔끔하게 해결했다.
- SSH 리버스 터널 아이디어. 공인 IP 없이도 외부 노출을 가능하게 한 구조가 발표에서 반응이 좋았다.
아쉬운 것들
- 이미지 레지스트리 부재. Docker Hub나 Harbor가 있었다면 Worker 노드마다
ctr import할 필요가 없었다. - SSH 리버스 터널 안정성. autossh로 교체하지 못해서 발표 중 터널이 끊겼을 때 수동 재연결이 필요했다.
- Jaeger 연동 과정에서 팀원이 deployment.yaml CMD를 임의로 오버라이드해서 Pod가 CrashLoopBackOff가 됐다. 팀 작업 시 yaml 파일 변경 규칙을 사전에 정해야 한다.
배운 것들
- 대역 설계는 처음부터 꼼꼼히. service-cidr 하나 잘못 잡으면 CoreDNS 전체가 죽는다.
- Terraform output → Ansible 변수 자동화. 수동 복붙은 실수를 부른다. 파이프라인으로 연결하라.
- Sort 없이 diff 쓰지 마라. 순서 차이만으로 오탐이 생긴다.
- 터널 실행 주체는 항상 켜져있는 서버에서. 로컬 PC에서 터널을 실행하면 PC 꺼지면 서비스도 꺼진다.
연결 노트
- [[Kubernetes CNI 비교 - Calico vs Flannel]]
- [[Terraform S3 백엔드 + DynamoDB 락 설정]]
- [[strongSwan IPSec IKEv1 설정 가이드]]
- [[AWS Site-to-Site VPN Static 라우팅]]
- [[SSH 리버스 터널 vs autossh]]