Cloud

[AWS] Private Subnet으로 EC2 이관 여정

immersive 2025. 8. 16. 16:23

서론

이번 블로그에서는 보안을 강화하기 위해 기존에 Public Subnet에 위치했던 EC2 인스턴스를 Private Subnet으로 이전하게 된 배경과, 실제 이관 과정에서 발생했던 문제들과 이를 해결한 과정을 자세히 소개하려고 합니다. 실제 운영 환경에서 겪은 트러블 슈팅 경험과 적용한 설정들을 함께 공유함으로써, Private Subnet으로의 이관이 왜 필요한지와 구체적인 절차를 이해하는 데 도움을 드리고자 합니다.

1. 기존 인프라 구조와 취약점

이관 전에는 모든 EC2 인스턴스가 Public Subnet에 위치해 있었습니다. 이는 개발 단계에서 소셜 로그인, 이메일 발송 등 인터넷과 직접 통신이 필요한 기능을 별도의 추가 설정 없이 간편하게 구현하기 위함이었습니다.

Prod/Dev 인스턴스 내부에는 ECS가 관리하는 Spring API Server ServiceMySQL Service가 함께 존재하며, 개발 단계에서 로그 확인과 MySQL 접속을 위해 22번(SSH)과 3306번(MySQL) 포트를 개방한 상태였습니다. 개발 단계에서의 인프라 구조는 다음과 같았습니다.

하지만, 이러한 구조는 보안상 매우 취약합니다. 물론 .pem 키가 있어야 22번 포트를 통해 접속이 가능하지만, 원칙적으로 공격 경로가 열려 있기 때문에 악의적인 IP가 수천·수만 번의 접속 시도를 할 수 있고, 이는 서버에 심각한 부하를 유발할 수 있습니다. 더 큰 문제는, SSH, OpenSSL, 혹은 서버 OS에 취약점이 발견되는 순간입니다. 이 경우 Public Subnet에 위치한 EC2는 .pem 키가 없어도 패치 전까지 무방비 상태가 될 수 있습니다.

즉, 현재 구조는 자격 증명을 통해 리소스를 보호하고 있으나, 그 이전 단계에서 공격이 가능한 진입점을 원천적으로 제거해야 합니다. 따라서 서비스 출시 전, 이러한 위험을 최소화하기 위한 보안 강화 전략이 반드시 필요했습니다.

2. 보안 강화를 위한 안전한 이관 계획

보안을 강화하기 위해, Spring API Service와 MySQL Service가 기동 중인 Prod/Dev 인스턴스를 Private Subnet에 배치하는 것이 합리적이라고 판단했습니다. 하지만, Private Subnet에 위치한 리소스는 Public IP가 할당되지 않으므로 인터넷으로 직접 트래픽을 보낼 수 없고, 외부에서도 곧바로 접근할 수 없습니다. 이를 위해서는 해결하기 위해 두 가지 구성이 필요했습니다.

  1. Bastion Host: 개발자가 Prod/Dev 인스턴스에 접속하기 위해 Public Subnet에 배치
  2. NAT Gateway: Prod/Dev 인스턴스에서 외부로 트래픽을 전송할 때 사용

그러나 한정된 예산 안에서 이 모든 리소스를 추가하는 것은 부담이 컸습니다. 이에 따라, 상대적으로 중요도가 낮은 데이터를 보유한 Monitoring Instance를 Public Subnet에 남기고, 해당 인스턴스가 Bastion HostNAT Gateway 역할을 겸하도록 결정했습니다.

<Monitoring Instance가 Nat Instance처럼 동작하도록 설정>

Monitoring Instance를 Proxy 서버로 구성할지, 아니면 NAT Instance로 구성할지에 대한 고민이 있었습니다. Proxy 서버로 동작시키려면 Squid와 같은 애플리케이션을 설치해야 하고, NAT Instance로 동작시키려면 인스턴스의 iptables 설정을 변경해야 했습니다.

두 방식의 가장 큰 차이는 동작 계층에 있습니다. Proxy 서버는 L7(Application Layer)에서 트래픽을 처리하는 반면, NAT Instance는 L3(Network Layer)에서 트래픽을 처리합니다.

처음에는 Monitoring Instance에 Squid를 설치해 Proxy 서버로 구성했습니다. 그 결과, HTTP/HTTPS 트래픽은 정상적으로 인터넷에 도달할 수 있었습니다. 그러나 이메일 전송처럼 SMTP 프로토콜을 사용하는 트래픽은 Squid를 통과할 수 없어, 이를 처리하기 위해서는 별도로 MTA(Mail Transfer Agent)를 추가 설치해야 했습니다. Proxy 서버는 애플리케이션 계층에서 트래픽을 제어하기 때문에 세밀한 라우팅과 로깅이 가능하다는 장점이 있었지만, 제 상황에서는 단순히 트래픽을 외부로 전달하기만 하면 충분했습니다.

따라서 Monitoring Instance에서 기존에 설정한 프록시 설정을 모두 제거하고, iptables 설정을 변경해 Monitoring Instance가 NAT Instance처럼 동작하도록 구성했습니다.

 

<프록시 설정 제거>

기존에는 Monitoring Instance를 Proxy 서버로 구성하고, Spring Application에서 HTTP_PROXY, HTTPS_PROXY, NO_PROXY 환경 변수를 설정하여 외부와 통신하도록 했습니다. 하지만 NAT Instance로 전환하면서 애플리케이션 레벨에서 프록시를 지정할 필요가 없어졌습니다. 따라서 Task Definition에서 관련 환경 변수를 삭제하고, Spring 내부 설정에서도 불필요한 Proxy 설정 코드를 제거했습니다. 이로 인해 애플리케이션은 NAT Instance를 통해 자연스럽게 외부와 통신할 수 있으며, Proxy 서버를 따로 유지할 필요가 없어져서 코드가 한결 간결해질 수 있었습니다.

 

<Private Route Table 생성 및 보안 그룹 설정>

Private Subnet에 위치한 인스턴스는 기본적으로 인터넷에 직접 접근할 수 없기 때문에, NAT Instance를 통해서만 외부와 통신할 수 있도록 Private Route Table을 새로 생성했습니다. Private Route Table에는 0.0.0.0/0 대상이 NAT Instance의 ENI를 향하도록 설정하여, Private Subnet의 인스턴스가 외부 인터넷과 통신할 수 있도록 구성했습니다.

보안 그룹 측면에서는 NAT Instance의 SG는 Private Subnet에서 들어오는 트래픽을 허용하고, 외부로 나가는 트래픽은 모두 허용하도록 설정했습니다. 반대로 Private Subnet 인스턴스의 SG는 NAT Instance로 나가는 트래픽만 허용하고, 인바운드 규칙은 최소한의 포트만 열어 내부 통신에 필요한 트래픽만 허용하도록 하였습니다. 이 구조를 통해 Private Subnet 인스턴스는 NAT Instance를 경유하여 안전하게 외부와 통신할 수 있게 되었습니다.

 

 

 

<Public Subnet에 위치한 Prod/Dev 인스턴스를 Private Subnet을 이관>

보안 강화를 위해 Public Subnet에 위치한 운영 및 개발 인스턴스를 Private Subnet으로 옮겼습니다. 이전 과정에서는 인스턴스를 잠시 중단해야 했기 때문에, 중요한 데이터인 MySQL 데이터와 Redis 데이터를 모두 백업했습니다. 이후 Private Subnet에 동일한 환경으로 새로운 인스턴스를 생성하고, 기존 데이터는 복구했습니다. 이 과정에서 보안 그룹을 재구성하여 외부에서는 ALB나 Bastion Host를 통해서만 접근 가능하도록 제한하고, DB 접근은 Private Subnet 내부에서만 허용했습니다. 결과적으로 인스턴스가 인터넷에 직접 노출되지 않고, NAT Instance를 통해 외부와 통신하면서도 내부 보안이 강화된 구조로 전환할 수 있었습니다.


트러블 슈팅 과정

이관 계획은 나름 단순했지만, 의도한대로 바로 Private Subnet에 위치한 인스턴스에서 Private Route Table의 규칙을 적용받아 Public Subnet에 위치한 NAT Instance로 트래픽이 전달되지 않는 문제를 겪었습니다. 이 문제를 해결하기 위해 다음 2가지 트러블 슈팅 과정을 겪었습니다.

1. Private Subnet에 위치한 인스턴스에서 외부로 트래픽이 안나가는 문제 (호스트 레벨)

iptables -t nat -A POSTROUTING -s 10.0.11.0/24 -o eth0 -j MASQUERADE
iptables -t nat -A POSTROUTING -s 10.0.12.0/24 -o eth0 -j MASQUERADE

 

원래 위와 같은 설정이 Monitoring Instance에 적용되어 있었습니다. 패킷 처리 과정은 다음과 같습니다.


패킷 도착 -> 라우팅 결정 -> 출력 인터페이스 결정 -> POSTROUTING 체인 진입 -> 규칙 매칭 확인

  • 패킷 도착: private subnet에서 패킷 도착 (출발지 IP가 10.0.11.0/24, 1.0.12.0/24 대역)
  • 라우팅 결정: 커널이 라우팅 테이블을 확인 
  • 출력 인터페이스 결정: 커널이 출력 인터페이스 선택 (ens5를 출력 인터페이스로 선택)
  • POSTROUTING 체인 진입: 패킷이 POSTROUTING에서 iptables 규칙 검사 (eth0 출력 인터페이스로만 나가는 패킷에 적용)
  • 규칙 매칭 확인: 조건 불일치로 MASQUERADE 적용 실패 (소스 IP를 해당 인터페이스의 IP로 변경)

즉, POSTROUTING 체인 진입 단계에서 iptables NAT 규칙 검사 결과 eth0으로 나가는 패킷에 대해서만 MASQUERADE를 적용하도록 설정되어 있지만, 실제 패킷의 출력 인터페이스는 ens5였기 때문에 규칙 매칭에 실패하여 NAT 변환이 적용되지 않았습니다.

더보기

<참고>

과거 Linux 시스템에서는 eth0(eth0, eth1, eth2... )로 커널이 네트워크 카드를 발견하는 순서에 따라 명명을 했지만, 이렇게 되면 하드웨어 변경이나 부팅 순서에 따라 인터페이스 이름이 바뀔 수 있습니다. 따라서, 예측 가능한 명명 규칙으로 ens5(en: Ethernet + s5: 슬롯 번호 5)가 등장했으며 AWS에서는 일관되게 가상 네트워크 인터페이스를 PCI 슬롯 5에 배치하므로 대부분의 경우 ens5가 사용됩니다

PRIMARY_INTERFACE=$(ip route | grep default | awk '{print $5}' | head -n1)

iptables -t nat -A POSTROUTING -s 10.0.11.0/24 -o $PRIMARY_INTERFACE -j MASQUERADE
iptables -t nat -A POSTROUTING -s 10.0.12.0/24 -o $PRIMARY_INTERFACE -j MASQUERADE

 

Monitoring Instance에 설정을 변경하여 위와 같이 네트워크 인터페이스를 하드코딩하지 않고 동적으로 출력 인터페이스를 감지하도록 설정했습니다. 그 결과 아래와 같이 Private Subnet에 위치한 인스턴스에서 외부 인터넷 공간으로 패킷이 전달되는 것을 확인할 수 있었습니다.

Private Subnet에 위치한 EC2에서 인터넷으로 접근이 가능

2. ECS Service에서 외부로 트래픽이 안나가는 문제 (컨테이너 레벨)

문제 해결 후, EC2 인스턴스 자체에서는 정상적으로 외부로 트래픽이 나가는 것을 확인했습니다. 그러나 실제로 Spring API Server가 실행 중인 컨테이너에 접속해 외부 요청을 보내보니 요청이 실패했습니다.

원인을 조사해 보니, Spring API Server는 awsvpc 네트워크 모드로 실행되고 있었습니다. CodeDeploy의 Blue/Green 배포를 사용하려면 서비스가 awsvpc 모드로 실행되어야 하므로, 컨테이너는 호스트 EC2와 독립적인 별도의 ENI를 사용하고 있었습니다.

해당 ENI를 확인한 결과, Public Subnet의 IP를 할당받고 있었습니다. 이는 Private Subnet으로 이관하는 과정에서 미처 고려하지 못한 부분이었습니다. Public Subnet의 IP를 사용한다는 것은 Internet Gateway와 연결된 Public Route Table 규칙을 따른다는 의미이며, 이 때문에 Spring API Server는 NAT Instance를 거치지 않고 직접 외부로 트래픽을 전송하려고 했습니다. 그러나 VPC 내부 전용 IP만 보유한 상태에서는 직접 인터넷에 접근할 수 없어 요청이 실패했던 것입니다.

이 문제를 해결하기 위해, Spring API Server가 할당받는 IP를 Private Subnet 대역으로 변경했습니다. 그 결과, 서비스는 NAT Instance로 트래픽을 전달하는 Private Route Table 규칙을 따르게 되었고, 인터넷 트래픽이 정상적으로 외부로 전달될 수 있었습니다.

 

컨테이너 내부에서도 인터넷으로 접근이 가능

 

Before: Spring Container ➡️ Public Route Table ➡️ Internet Gateway ➡️  Internet (❌ 실패!)

After: Spring Container ➡️ Private Route Table ➡️ NAT Instance ➡️ Internet Gateway ➡️  Internet (✅ 성공!)


결론

Public Subnet에 있던 인스턴스를 Private Subnet으로 이전함으로써, 인터넷상의 악성 트래픽으로부터 중요한 자원들(특히 MySQL 컨테이너)을 1차적으로 보호할 수 있었습니다. 이제 개발자가 Prod/Dev 인스턴스에 접속하기 위해서는 반드시 Bastion Host를 거쳐야 하며, 예를 들어 MySQL에 접근하려면 Bastion Host를 통해 SSH 터널링을 해야 합니다.

다만, NAT Instance이자 Bastion Host 역할을 하는 Monitoring Instance는 여전히 Public Subnet에 위치해 있습니다. 특히 이 인스턴스의 22번 포트가 인터넷에 직접 노출되어 있어 보안 위험이 존재합니다. 또한, Subnet 앞단에 위치한 로드밸런서는 80 포트와 443 포트를 개방한 상태로 모든 외부 트래픽을 수신할 수 있는 상황입니다.

이러한 보안 취약점을 개선하기 위해, 앞으로 Monitoring Instance에 VPN을 구성하여 인증된 사용자만 접근할 수 있도록 할 예정입니다. 더불어, 로드밸런서 앞단에는 AWS WAF(Web Application Firewall)를 도입해 ALB 단계에서 웹 공격, 봇, 스팸 트래픽 등 다양한 위협으로부터 애플리케이션을 보호할 계획입니다.

'Cloud' 카테고리의 다른 글

[AWS] Terraform으로 인프라를 코드로 "잘" 관리하기  (0) 2025.10.04