Azure에 VM을 만들면 기본 흐름상 공용 IP가 붙고, NSG에서 22번(SSH) 또는 3389번(RDP) 포트를 열게 됩니다. 접속이 되니 문제가 없어 보입니다. 하지만 그 순간부터 전 세계에서 그 포트를 두드리기 시작합니다.
이 글은 “Bastion을 쓰세요”라는 결론을 미리 두고, 왜 그런지를 로그로 확인하는 것부터 시작합니다. 그리고 Bastion이 항상 정답은 아니므로 대안 세 가지와 비용까지 비교합니다.
먼저 확인: 지금 얼마나 두드려 맞고 있나
추상적인 위협이 아니라 실제 데이터로 보는 게 설득력 있습니다. NSG 흐름 로그가 켜져 있다면 이렇게 확인할 수 있습니다.
// 지난 24시간, 외부에서 22/3389 포트로 들어온 시도
AzureNetworkAnalytics_CL
| where TimeGenerated > ago(24h)
| where DestPort_d in (22, 3389)
| where FlowType_s == "ExternalPublic"
| summarize Attempts = count(), Sources = dcount(SrcIP_s)
by DestPort_d, bin(TimeGenerated, 1h)
| sort by TimeGenerated desc
Linux VM이라면 서버 안에서 직접 볼 수도 있습니다. 더 직관적입니다.
# 실패한 SSH 로그인 시도를 IP별로 집계
sudo journalctl -u ssh --since "24 hours ago" \
| grep -i "failed password\|invalid user" \
| grep -oE 'from [0-9.]+' | sort | uniq -c | sort -rn | head -20
# 시도된 계정명 순위 (root, admin, ubuntu, test ...)
sudo journalctl -u ssh --since "24 hours ago" \
| grep -oP 'invalid user \K\w+' | sort | uniq -c | sort -rn | head -20
공용 IP를 붙인 지 며칠만 지나도 수천 건 단위가 잡히는 게 보통입니다. 자동화된 스캐너가 IP 대역을 훑으며 흔한 계정명과 비밀번호를 시도합니다. 여기서 중요한 건, 이게 특정 조직을 노린 공격이 아니라 상시로 벌어지는 배경 소음이라는 점입니다. 공용 IP를 여는 순간 자동으로 대상이 됩니다.
“IP 제한하면 되지 않나”의 한계
가장 먼저 떠오르는 대안은 NSG에서 사무실 IP만 허용하는 것입니다. 효과는 확실합니다. 다만 실무에서 유지가 잘 안 됩니다.
- 재택근무자의 가정용 IP는 유동입니다. 바뀔 때마다 NSG 규칙을 고쳐야 합니다
- 급하게 접속해야 할 때 “일단
0.0.0.0/0으로 열고 나중에 닫자”가 됩니다. 그 “나중에”는 오지 않습니다 - 규칙이 쌓이면서 누가 왜 추가했는지 알 수 없는 항목이 남습니다
지금 구독에 이런 규칙이 있는지 한번 확인해보시길 권합니다.
# 인터넷 전체에 관리 포트를 연 NSG 규칙 찾기
az network nsg list --query "[].{nsg:name, rg:resourceGroup}" -o tsv \
| while read NSG RG; do
az network nsg rule list --nsg-name "$NSG" -g "$RG" \
--query "[?access=='Allow' && direction=='Inbound' \
&& (sourceAddressPrefix=='*' || sourceAddressPrefix=='Internet' \
|| sourceAddressPrefix=='0.0.0.0/0')].\
{nsg:'$NSG', rule:name, port:destinationPortRange, src:sourceAddressPrefix}" \
-o tsv
done
결과에 22나 3389가 나오면 그게 지금의 노출 지점입니다.
선택지 4개 비교
공용 IP를 없애는 방법은 여러 가지입니다. 상황에 따라 답이 다릅니다.
| 방법 | 공용 IP | 비용 | 적합한 상황 |
|---|---|---|---|
| NSG IP 제한 | 필요 | 무료 | 고정 IP 사무실만 있는 소규모 |
| JIT VM 액세스 | 필요 | Defender for Servers 요금 | 이미 Defender를 쓰는 경우 |
| Azure Bastion | 불필요 | 시간당 + 데이터 요금 | 여러 명이 상시 접속 |
| VPN / ExpressRoute | 불필요 | 게이트웨이 요금 | 온프레미스 연동이 이미 있음 |
JIT(Just-in-Time)는 절충안으로 좋습니다. 평소엔 NSG에서 포트를 닫아두고, 접속이 필요할 때 요청하면 요청자의 IP에만 정해진 시간(예: 3시간) 동안 열립니다. 시간이 지나면 자동으로 닫힙니다. 공용 IP는 남지만 열려 있는 시간이 극히 짧아집니다.
다만 JIT는 Microsoft Defender for Servers 플랜이 필요합니다. Bastion만을 위해 Defender를 켜는 건 대개 비싼 선택이고, 이미 Defender를 쓰고 있다면 무료로 얻는 기능입니다.
Bastion 구성하기
Bastion은 브라우저에서 포털을 통해 VM에 붙는 관리형 서비스입니다. VM에 공용 IP가 아예 없어도 됩니다.
#!/usr/bin/env bash
set -euo pipefail
RG="rg-prod-network"
VNET="vnet-prod-krc-01"
# 1) 전용 서브넷 - 이름이 반드시 AzureBastionSubnet 이어야 함
az network vnet subnet create \
--name AzureBastionSubnet \
--vnet-name "$VNET" -g "$RG" \
--address-prefixes 10.10.250.0/26
# 2) Bastion용 공용 IP (Standard SKU 필수)
az network public-ip create \
--name pip-bastion-prod-krc-01 -g "$RG" \
--sku Standard --location koreacentral
# 3) Bastion 호스트 생성 (10분 이상 소요)
az network bastion create \
--name bas-prod-krc-01 -g "$RG" \
--vnet-name "$VNET" \
--public-ip-address pip-bastion-prod-krc-01 \
--location koreacentral \
--sku Standard
여기서 걸리는 지점이 세 가지 있습니다.
- 서브넷 이름은 정확히
AzureBastionSubnet이어야 합니다. 대소문자까지 일치해야 하고, 다른 이름이면 인식하지 않습니다. - 최소
/26이 필요합니다./27로 만들면 생성이 실패하거나 확장이 막힙니다. 처음부터/26이상으로 잡으세요. - 이 서브넷에는 다른 리소스를 배치할 수 없습니다. 대역 계획할 때 미리 떼어두는 게 좋습니다.
SKU 선택
Basic으로도 브라우저 접속은 됩니다. 다만 Standard부터 가능한 기능들이 실무에서 꽤 유용합니다.
- 네이티브 클라이언트 지원 — 브라우저가 아니라 로컬
ssh·mstsc로 접속. 스크립트·파일 전송에 필수적입니다 - IP 기반 연결 — 피어링된 다른 VNet의 VM에도 접속
- 인스턴스 확장 — 동시 접속자가 많을 때
브라우저 콘솔만으로 실무를 하기는 불편합니다. Standard로 만들고 네이티브 클라이언트를 쓰는 것을 권합니다.
# 로컬 터미널에서 바로 SSH (공용 IP 없는 VM에)
az network bastion ssh \
--name bas-prod-krc-01 -g rg-prod-network \
--target-resource-id "/subscriptions/<SUB>/resourceGroups/rg-prod-app/providers/Microsoft.Compute/virtualMachines/vm-api-prod-krc-01" \
--auth-type ssh-key \
--username azureuser \
--ssh-key ~/.ssh/id_rsa
# 로컬 포트 터널링 (DB 접속 등)
az network bastion tunnel \
--name bas-prod-krc-01 -g rg-prod-network \
--target-resource-id "<VM_RESOURCE_ID>" \
--resource-port 3306 --port 13306
두 번째 tunnel 명령이 특히 유용합니다. VM 안의 DB 포트를 로컬로 끌어와서, 로컬 DB 클라이언트로 붙을 수 있습니다. DB에 공용 엔드포인트를 열지 않고도 작업이 됩니다.
마무리: 공용 IP 제거와 NSG 정리
Bastion을 만들었다고 끝이 아닙니다. 기존 공용 IP를 실제로 떼어내야 노출이 사라집니다. 이 단계를 안 하면 Bastion 요금만 추가로 내는 셈이 됩니다.
# 1) Bastion으로 접속이 되는지 먼저 확인 (중요)
# 2) VM의 NIC에서 공용 IP 연결 해제
az network nic ip-config update \
--name ipconfig1 \
--nic-name nic-api-prod-krc-01 \
-g rg-prod-app \
--remove PublicIpAddress
# 3) 떨어져 나온 공용 IP 삭제 (안 지우면 계속 과금)
az network public-ip delete \
--name pip-api-prod-krc-01 -g rg-prod-app
# 4) NSG에서 22/3389 인바운드 규칙 삭제
az network nsg rule delete \
--nsg-name nsg-api-prod -g rg-prod-network --name allow-ssh
3번을 빠뜨리는 경우가 많습니다. NIC에서 분리해도 공용 IP 리소스 자체는 남아서 요금이 계속 나갑니다. Standard SKU 공용 IP는 연결 여부와 무관하게 과금됩니다.
NSG에 Bastion용 규칙을 따로 추가할 필요는 없습니다. Bastion은 VNet 내부에서 오는 트래픽이므로 기본 규칙으로 통신됩니다. 다만 서브넷 NSG에서 VNet 내부 트래픽을 명시적으로 차단해둔 경우라면 AzureBastionSubnet 대역에서 오는 22/3389를 허용해야 합니다.
접속 기록을 남기는 것도 목적이다
Bastion을 도입하는 이유가 노출 제거만은 아닙니다. 누가 언제 어느 서버에 접속했는지 중앙에서 기록된다는 점이 실무에서 큰 가치가 있습니다. 공용 IP로 각자 접속하면 이 기록이 서버마다 흩어지고, 서버를 지우면 함께 사라집니다.
MicrosoftAzureBastionAuditLogs
| where TimeGenerated > ago(30d)
| project TimeGenerated,
User = tostring(properties.userName),
TargetVM = tostring(properties.targetVMName),
Protocol = tostring(properties.protocol),
ClientIP = tostring(properties.clientIpAddress)
| sort by TimeGenerated desc
감사 대응이나 사고 조사에서 이 로그 하나로 정리되는 경우가 많습니다. 진단 설정은 기본으로 꺼져 있으니 만들자마자 켜두시길 권합니다. 다만 로그를 켜면 수집량만큼 Log Analytics 요금이 붙습니다. Bastion 감사 로그는 양이 적어 부담이 크지 않지만, 다른 진단 설정을 무분별하게 켜면 비용이 튑니다.
권한 분리도 함께 가져갈 수 있다
Bastion 접속에는 RBAC이 적용됩니다. 접속 가능한 사람을 역할로 통제할 수 있다는 뜻입니다. 최소한 아래 권한이 필요합니다.
- 대상 VM에 대한 읽기 권한 (Reader)
- Bastion 리소스에 대한 읽기 권한 (Reader)
- Entra ID 로그인 사용 시 Virtual Machine Administrator Login 또는 User Login
Entra ID 기반 VM 로그인을 함께 구성하면 SSH 키를 나눠 갖지 않아도 됩니다. 퇴사자 계정을 비활성화하는 것만으로 서버 접근이 차단되므로 키 회수 절차가 필요 없어집니다. 키를 여러 명이 공유하는 구조라면 검토할 가치가 있습니다.
비용 관점에서의 판단
Bastion은 시간당 고정 요금 + 데이터 전송 요금 구조입니다. 트래픽이 없어도 시간당 요금이 계속 나갑니다. 여기서 판단이 갈립니다.
- VM이 1~2대뿐인 개발용 구독 — Bastion 고정 요금이 VM 값보다 클 수 있습니다. NSG IP 제한으로 충분합니다
- VM이 여러 대이고 접속자가 여럿 — Bastion 하나로 전부 커버되므로 VM당 공용 IP 요금과 비교하면 오히려 저렴해질 수 있습니다
- 운영 환경 — 비용보다 노출 제거가 우선입니다. 사고 한 번의 비용이 훨씬 큽니다
개발 구독에서는 필요할 때만 Bastion을 만들고 쓰고 지우는 방식도 가능합니다. 생성에 10분 정도 걸리지만 스크립트로 자동화해두면 쓸 만합니다. VNet과 서브넷은 남겨두고 Bastion 호스트만 지웠다 만들면 됩니다.
정리
- 먼저 지금 얼마나 스캔당하는지 로그로 확인한다 (설득에 필요)
- 구독 전체에서
0.0.0.0/0으로 22/3389를 연 NSG 규칙을 찾는다 - 규모에 맞는 방법을 고른다: 소규모는 NSG 제한, Defender 있으면 JIT, 다수는 Bastion
- Bastion은
AzureBastionSubnet/26이상 + Standard SKU로 만든다 - 공용 IP를 실제로 삭제하고 NSG 규칙을 정리한다 (여기까지 해야 끝)
관리 포트를 닫는 건 보안 조치 중에서 투입 대비 효과가 가장 확실한 편입니다. 네트워크 통제를 더 보시려면 NSG·Azure Firewall·WAF 비교를, 계정 쪽 보호는 조건부 액세스 MFA 도입을 참고하세요. 데이터 평면 접근까지 막으려면 Private Endpoint와 Service Endpoint 쪽으로 이어집니다.