온프레미스 + AWS 하이브리드 헬스케어 플랫폼 구축기

OpenStack 위에 K8s 클러스터를 올리고, Terraform으로 AWS 인프라를 코드 한 줄로 찍어내고, strongSwan VPN으로 온프레미스와 AWS를 이어붙인 팀 프로젝트 전 과정 기록이다.


프로젝트 개요

헬스케어 데이터 분석 파이프라인을 온프레미스(OpenStack + K8s)와 AWS(SQS + Spot EC2 + S3)를 연결해 구축한 하이브리드 클라우드 프로젝트다.

핵심 흐름:

AgWaStsSap.KSoSKMs8QtQ8ahsSSsroEipPCPao2oDddB((A(MnSEaQSlSyDzPaeotrla)liSnegrvSiecrev)ice)

기술 스택:

영역기술
온프레미스 가상화OpenStack (KVM)
컨테이너 오케스트레이션Kubernetes v1.29 + Calico CNI
IaCTerraform (S3 백엔드 + DynamoDB 락)
구성 관리Ansible (j2 템플릿 + ProxyJump)
VPNstrongSwan IPSec IKEv1 + NAT-T
메시지 큐AWS SQS (입력/결과/DLQ)
백업Veeam Agent → S3 SSE-KMS
웹 노출SSH 리버스 터널 + Nginx + Let’s Encrypt

1. OpenStack 환경 구성 및 VM 생성

네트워크 설계

네트워크대역용도
public10.0.0.0/8KVM 물리 서버 대역
private172.16.0.0/24VM 내부 통신 대역

VM 구성

VM 이름IP역할
Compute-VM-1172.16.0.216K8s Master
Compute-VM-2172.16.0.166K8s Worker 1
Compute2-VM172.16.0.202K8s Worker 2
DB-VM172.16.0.157MariaDB
VPN-VM172.16.0.138strongSwan 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/8KVM 물리 서버 대역Flannel 기본 pod-cidr 10.244.0.0/16과 충돌
192.168.0.0/16K8s Pod 대역 (Calico)Flannel 대신 Calico 선택 이유
10.96.0.0/12K8s 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-servicemes-sender:latestpending/ JSON → SQS 입력 큐 전송
sqs-polling-servicesqs-receiver:latestSQS 결과 큐 폴링 → MariaDB 저장
dlq-consumerdlq-consumer:latestDLQ 모니터링 → 실패 메시지 로그
healthcare-webhealthcare-web:latestFastAPI 대시보드 (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

정상 상태:

NdhmsAleeqMqassE-l--ctdpohaonctlsaalur-imesne-egrwr--evs8bie6-cr87ev6b-i695cbdfed8d-c577bcfd-d58pb68b4c9h-95vnd64q-5ws9zs-hxs7882hvR1111E////A1111DYSRRRRTuuuuAnnnnTnnnnUiiiiSnnnnggggR0000ESTARTS

4. Terraform으로 AWS 인프라 구축

terraform apply 한 번으로 AWS 리소스 80개 이상을 자동 생성한다. 완료 시 Ansible이 바로 쓸 수 있는 inventory.ini, ansible.cfg, vpn_vars.yml까지 자동 생성된다.

생성되는 주요 리소스

네트워크:

VNPACT(G1a9t2e.w1a6y8.220a2a./a,02//c221cc6)(SBpEaoIstPt)ion

Site-to-Site VPN:

VSGtWSa11tt70(ai2.Atc.0Wi1.ScR60o..u00)t./e08+:/1C6GW(O(pVVePPnNNStack((IKPNV:eMu1t7r5oV.nM197B.G)2P)4.170))

SQS 큐 4개:

역할보존
Healthcare-input-queue온프레미스 → AWS1일
Healthcare-input-dlq3회 실패 격리14일
Healthcare-output-queueAWS → 온프레미스1일
Healthcare-output-dlq결과 큐 실패 격리14일

오토스케일링:

SSccaalleeOIunt::SSQQSS>100(3(1))ECE2C2+1-1

EventBridge + Lambda:

1234....SDBKpLiVoQlMtling2$10SDNL(SQClo1u0dW)atch

실행 순서

사전 준비 (최초 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마다 바뀌는 값들을 수동으로 수정할 필요가 없다.

변수 흐름

tviaaepnnnrnvssr_eiiavnbbfatlloroeersr.-m.ycpy.flamigaplnypibl(oy((oBPkarIsoPtx,iyjoJ2PnuSmKIp,P릿,BSaQSsSptoiUtoRnLI,PI,PS3KVMI)P),VPNIP)

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 터널을 연결했다.

AVWPSNVVGMWINPIe((SPu1et7c(r211o.:I7n1K563E...v10319.771..322844)1.(.1U87D10Ps)t/5r0o0n,g2S:4w5a50n20.)79.210.155)

설정


# 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 핵심 설정:

connllrrdaaeeiipuwffggdtstthhao-isttc=vdu=stsp=b3uitn1n.boa-7e3nnrt2t7e=tu.=.trn112=en6741se.219tl0..2a1.18.r1611t3.6808..00/.106/16Terraformoutput

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 백업

구조

KsV3M://hVtSeea3aerla.t(mgh1zc0Aa.g(r2eSe0nS-.tEd0-a.Kt1Ma,S-)s1t0o.br7aa.cg0ke.u-1pj)/in/veeam-backups/<>/
  • 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와 연결했다.

ASVhWSPeSHNalEtChD2NcNg(aSi1rn7eAx2-h.wtIp-1etPrR6bpo.s(x80P:3y0.o/._81d/3p03g9a:8(a.s1)1t1s77s822a11....211s1766h9...o7000p)...011.66166:::83300080008800))

핵심 개념: 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 콘솔 탭 미리 열기

123:::SECQClS2oudWHAaeutatclohthSccaaHrleeia-nligtnhpcuatr-ed-lsqqs(-DsL(cQSaclael-)eouOtut/In)

시연 순서

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]]