서버 로그를 보려는데 SSH가 타임아웃으로 막혔다. 보안 그룹에 등록해 둔 내 공인 IP가 사흘 만에 바뀌어 있었다. 새 IP로 규칙을 갈아 끼웠고, 작업 도중 또 바뀌어서 한 번 더 갈았다. 두 번째에야 규칙이 아니라 접근 방식이 잘못됐다는 걸 인정했다. 결과는 22번 인바운드 규칙 0개, 서버 설치 작업 0건, 추가 요금 0원이다
서비스는 열려 있는데 나만 못 들어갔다
공모전을 위해서 백엔드를 EC2 한 대(t3.small)에 올려 운영한다. 로그를 보려고 접속했더니 이렇게 됐다
$ ssh ec2-user@203.0.113.10 'journalctl -u myapp@community ...'
ssh: connect to host 203.0.113.10 port 22: Operation timed out
$ curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health
200
같은 서버인데 HTTPS는 200을 준다. 서버가 죽은 게 아니라 나만 못 들어가는 상태였다. 보안 그룹(SG)은 AWS의 인스턴스 단위 방화벽이다. 포트마다 “어느 IP에서 오는 걸 받을지”를 정한다. 설정은 이랬다
| 포트 | 허용 범위 | 결과 |
|---|---|---|
| 443 (서비스) | 0.0.0.0/0 — 누구나 | 정상 |
| 22 (SSH) | 내 IP /32 하나 | 타임아웃 |
443은 전부 열어 뒀으니 내 IP가 뭐든 상관없다. 22번만 출발지 주소로 걸러진다. 그래서 등록된 IP와 지금 IP가 다르면 나만 막힌다
$ aws ec2 describe-security-groups --group-ids sg-0aaa1111bbb222333 \
--query "SecurityGroups[0].IpPermissions[?FromPort==\`22\`].IpRanges[].CidrIp"
[ "198.51.100.24/32" ]
$ curl -s ifconfig.me
198.51.100.87
등록된 건 사흘 전 IP였다. 거절(Connection refused)이 아니라 타임아웃이 난 이유도 여기 있다. 거절은 상대가 “안 받는다”고 응답을 보내야 나오는데, SG는 응답 없이 패킷을 그냥 버린다. 집·사무실 인터넷은 대부분 유동 IP다. 통신사가 주기적으로 주소를 재할당한다. 실제로 규칙을 고치고 작업하는 중에 또 바뀌었다
현재 맥 공인 IP: 198.51.100.153 ← 방금 198.51.100.87이었는데 --- SG 22포트 허용 --- 198.51.100.87/32
한 세션 안에서 SG를 두 번 갈았다
IP를 쫓는 대신 포트를 없애기로 했다
먼저 규칙을 고치는 방향을 봤고 전부 접었다
0.0.0.0/0으로 열면 22번이 무차별 대입 스캔의 상시 표적이 된다. 통신사 대역 전체를 허용하면 “내 IP만”이라는 규칙의 의미가 사라진다. DDNS로 자동 갱신하면 손은 덜 가지만 22번은 계속 열려 있다. 배스천 호스트나 VPN은 인스턴스 한 대짜리 환경에 비용과 관리 대상만 늘린다. 세 번째까지 검토하고 나서 공통점이 보였다. 전부 “허용할 IP를 어떻게 맞출까”를 푸는 답이었다. 그런데 문제는 IP가 안 맞는 게 아니라, 애초에 IP로 사람을 식별하고 있다는 것이었다
그래서 SSM Session Manager로 갔다. AWS Systems Manager의 접속 기능인데, 동작 방향이 SSH와 반대다
[SSH] 내 PC ──접속──▶ 서버 22번 : 서버가 문을 열어 둬야 한다 [SSM] 내 PC ──▶ AWS ◀──접속── 서버 : 서버가 밖으로 걸어 나온다
서버 안의 에이전트가 AWS 쪽으로 HTTPS 연결을 먼저 만들어 두고, 내가 접속하면 AWS가 그 통로를 빌려준다. 서버는 받는 문을 열 필요가 없다. 그래서 22번 인바운드를 0개로 만들 수 있다. 누가 들어올 수 있는지는 IAM 권한이 정하고, 세션 시작은 CloudTrail에 남는다
정리하면, 허용 IP 목록은 고정 IP를 전제한 설계다. 전제가 깨졌으면 규칙을 계속 갱신할 게 아니라 전제를 안 쓰는 방식으로 옮겨야 한다
해야 할 일은 IAM 역할 하나였다
에이전트를 깔고 VPC 엔드포인트를 만드는 그림을 상상하고 있었다. 착수 전에 현재 값부터 재 봤더니 대부분 이미 갖춰져 있었다. 에이전트는 이미 돌고 있었다. Amazon Linux 2023은 기본 탑재다
$ ssh ... 'systemctl is-active amazon-ssm-agent; rpm -q amazon-ssm-agent' active amazon-ssm-agent-3.3.4624.0-1.amzn2023.x86_64
빠진 건 IAM 인스턴스 프로파일 하나였다.
$ aws ec2 describe-instances --instance-ids i-0abc1234def567890 --query '...'
{ "IamInstanceProfile": null }
에이전트가 AWS에 연결하려면 “나는 이 인스턴스다”라고 증명해야 한다. 그 자격증명을 EC2에 붙여 주는 게 인스턴스 프로파일이다. 없으면 에이전트가 돌고 있어도 SSM 관리 목록에 등록되지 않는다. 프로세스가 살아 있는 것과 SSM이 그 서버를 아는 건 별개다.
아웃바운드가 열려 있는지도 봤다. 에이전트가 밖으로 나가야 하니 이게 막혀 있으면 아무것도 안 된다.
$ aws ec2 describe-security-groups ... --query '...IpPermissionsEgress'
[ { "IpProtocol": "-1", "IpRanges": [ { "CidrIp": "0.0.0.0/0" } ] } ]
퍼블릭 IP가 있고 아웃바운드가 열려 있으니 인터넷 경로로 충분했다. 이게 비용이 0인 이유다. 프라이빗 서브넷이라면 ssm, ssmmessages, ec2messages VPC 엔드포인트 3개를 만들어야 하고, 시간당 과금이라 월 수십 달러가 붙는다.
Session Manager 자체는 무료다. 돈이 붙는 건 방금 말한 VPC 엔드포인트, 세션 로그를 S3나 CloudWatch에 저장하는 옵션(기본 꺼짐), 온프레미스 서버를 등록하는 Advanced Instances 티어 세 가지뿐이고 전부 우리가 안 쓰는 것들이었다.
결과적으로 서버 설치 작업 0건, 추가할 것은 IAM 역할과 프로파일뿐이었다
22번을 닫는 스크립트는 따로 뺐다
스크립트를 둘로 나눴다. 50-ssm.sh는 역할을 만들어 붙이고, 60-close-ssh.sh는 22번을 닫는다 .나눈 이유는 하나다. 포트를 닫는 건 되돌릴 수 있지만, 잘못하면 접속 수단을 통째로 잃는 동작이다. 설정 스크립트에 섞어 두면 “설정 좀 다시 돌릴게요” 하다가 같이 실행된다
50-ssm.sh의 핵심은 이 두 덩어리다
# 역할에 붙일 권한은 AWS 관리형 정책 하나로 충분하다 aws iam attach-role-policy --role-name "$ROLE_NAME" \ --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore # EC2는 역할을 '인스턴스 프로파일' 형태로만 받는다 aws iam create-instance-profile --instance-profile-name "$ROLE_NAME" aws iam add-role-to-instance-profile --instance-profile-name "$ROLE_NAME" --role-name "$ROLE_NAME" sleep 15 # IAM 전파 지연 — 바로 associate하면 Invalid IAM Instance Profile name
sleep 15은 눈에 거슬리지만 필요하다. 프로파일을 만든 직후 EC2에 붙이면 IAM 전파가 안 끝나서 Invalid IAM Instance Profile name으로 실패한다. 주석으로 이유를 남기지 않으면 다음 사람이 지운다
60-close-ssh.sh에는 안전장치를 코드로 넣었다.
# SSM으로 못 들어가는 상태에서 유일한 접근 수단을 닫지 않는다 if [ "$PING" != "Online" ]; then echo "SSM이 Online이 아니다(ping=$PING) — 22번을 닫지 않는다" >&2 exit 1 fi # 현재 걸려 있는 규칙을 조회해서 회수한다. IP를 스크립트에 박지 않는다 CIDRS=$(aws ec2 describe-security-groups ... --output text) for cidr in $CIDRS; do aws ec2 revoke-security-group-ingress ... --port 22 --cidr "$cidr" done
README에 “먼저 확인하세요”라고 쓰는 것과 스크립트가 exit 1로 거부하는 건 강도가 다르다. 급할 때 README는 안 읽지만 exit 1은 반드시 걸린다
ssh와 scp를 한 줄도 안 고쳤다
SSM 접속은 보통 aws ssm start-session으로 셸을 받는다. 그런데 이 방식은 ssm-user로 붙고 scp를 못 쓴다. 배포 스크립트가 ssh와 scp를 쓰고 있으면 전부 고쳐야 한다는 뜻이다
대신 SSH를 SSM 통로 위에 태웠다. ~/.ssh/config에 이렇게 넣는다
Host 203.0.113.10 myapp HostName i-0abc1234def567890 User ec2-user IdentityFile ~/.ssh/myapp-key.pem ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p' --region ap-northeast-2"
ProxyCommand는 “직접 연결하지 말고 이 명령이 만든 통로로 보내라”는 설정이다. SSH 자체는 그대로 동작하고 전송 경로만 바뀐다
포인트는 Host에 인스턴스 ID가 아니라 기존 EIP를 넣은 것이다. AWS 문서 예시는 Host i-* mi-*로 되어 있어서 접속할 때마다 인스턴스 ID를 써야 한다. EIP를 Host로 두고 HostName에 인스턴스 ID를 넣으면, 예전부터 쓰던 ssh ec2-user@203.0.113.10이나 ./deploy.sh 203.0.113.10이 수정 없이 그대로 SSM 통로를 탄다. ProxyCommand의 %h는 HostName 치환 후 값이라 인스턴스 ID가 들어간다
실제로 막힌 두 지점
프로파일을 붙였는데 등록이 안 됐다
스크립트가 등록 대기에서 멈췄다. 4분을 기다려도 상태가 Online이 되지 않았다
프로파일 부착: i-0abc1234def567890 SSM 등록 대기........................
프로파일은 정상 부착돼 있었다. 원인은 에이전트가 부팅 이후 계속 자격증명 없이 돌고 있었다는 것이다. 등록에 실패하면 에이전트는 재시도 간격을 점점 늘린다. 그래서 나중에 프로파일을 붙여도 바로 반응하지 않는다
해결은 재시작이다
$ ssh ... 'sudo systemctl restart amazon-ssm-agent && sleep 8 && sudo tail -5 /var/log/amazon/ssm/amazon-ssm-agent.log' ... [MGSInteractor] Opening websocket connection to: wss://ssmmessages.ap-northeast-2.amazonaws.com/... ... [MGSInteractor] Set up control channel successfully
Set up control channel successfully가 앞에서 말한 “서버가 밖으로 걸어 나온” 통로다. 이 줄이 나오면 등록된 것이다.
여기서 중요한 건 재시작을 SSH로 했다는 점이다. 22번이 아직 열려 있어서 가능했다. 순서를 뒤집어 포트부터 닫았다면 재시작할 방법이 없었다.
첫 접속이 Host key verification failed
config를 넣고 접속했더니 바로 실패했다
$ ssh 203.0.113.10 'echo OK' Host key verification failed.
SSH는 접속한 서버가 예전에 보던 그 서버가 맞는지 known_hosts에 저장해 둔 호스트 키로 확인한다. 그런데 ProxyCommand를 쓰면 이 조회를 HostName 값, 즉 인스턴스 ID 이름으로 한다. known_hosts에는 EIP 이름으로만 저장돼 있으니 “모르는 서버”가 된다. 비대화형 실행에서는 물어보지도 않고 실패한다
흔한 해법은 StrictHostKeyChecking accept-new를 넣는 것이다. 하지만 그건 처음 받은 키를 검증 없이 신뢰하겠다는 설정이다. 우리는 그럴 필요가 없었다. 이미 신뢰하던 키가 로컬에 있으니 같은 키를 새 이름으로 복사하면 된다
$ ssh-keygen -F 203.0.113.10 | grep -v '^#' | awk '{print $2, $3}' \
| while read -r t k; do echo "i-0abc1234def567890 $t $k"; done >> ~/.ssh/known_hosts
known_hosts가 해시로 저장돼 있어도(macOS 기본) ssh-keygen -F는 매칭 라인을 뽑아 준다. 키 타입과 본문만 취해 인스턴스 ID 이름으로 덧붙이는 것이다. 이 서버는 ed25519와 ecdsa 두 개가 있어서 루프로 돌렸다. 접근 방식을 바꾸면 호스트 키의 “이름”도 바뀐다. 경고를 끄는 방향이 아니라 신뢰를 옮기는 방향으로 푸는 게 맞다
순서를 지켜서 닫았다
Online 확인 → SSM 경유 ssh 성공 → scp·파이핑 확인 → 그 다음에 22번 닫기
대화형 SSH만 확인하고 닫았다면 배포 스크립트에서 터졌을 수 있다. 그래서 실제로 쓰는 방식까지 미리 확인했다
$ echo 'echo STDIN_PIPE_OK' | ssh 203.0.113.10 'bash -s' STDIN_PIPE_OK $ scp -q ./scptest.txt ec2-user@203.0.113.10:/tmp/ && echo SCP_OK SCP_OK
그리고 닫은 뒤 다시 확인했다. []는 22번 인바운드 규칙이 하나도 없다는 뜻이다
$ aws ec2 describe-security-groups ... --query "...IpPermissions[?FromPort==\`22\`]"
[]
$ ssh 203.0.113.10 'echo CLOSED_PORT_SSM_OK'
CLOSED_PORT_SSM_OK
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/health
200
포트가 닫힌 상태에서 ssh가 되고, 서비스 응답은 그대로였다
통하지 않는 조건
프라이빗 서브넷이면 비용 0원이라는 결론이 깨진다. VPC 엔드포인트 3개가 필요하고 시간당 과금이 붙는다. 인스턴스가 여러 대이고 접속자도 여러 명이면 배스천이나 VPN을 다시 볼 만하다. 나는 한 대짜리라 과잉이라고 판단했다. 우리 환경에는 함정이 하나 남았다. SG 프로비저닝 스크립트(10-security-groups.sh)가 실행 시점의 내 IP로 22번을 허용하도록 되어 있어서, 재실행하면 포트가 다시 열린다. 스크립트로 막을 수 없어 README 주의사항으로 남겼다
정리
한 줄로 요약하면, 허용 IP 목록은 고정 IP를 전제한 설계이고 전제가 깨졌으면 규칙이 아니라 방식을 바꿔야 한다
바로 쓸 수 있는 기준은 셋이다. 같은 이유로 SG 규칙을 두 번 고쳤다면 규칙이 아니라 접근 방식을 의심한다. 착수 전에 에이전트 상태, 인스턴스 프로파일, 아웃바운드를 재 보면 작업 범위가 확정된다. 유일한 접근 경로를 닫는 스크립트에는 대체 경로가 살아 있는지 검사하는 exit 1을 넣는다
이제 카페에서 노트북을 열어도 방화벽 규칙을 만지지 않는다
용어 정리
이 글에 나온 용어를 정리했다. 각 항목은 “무엇인지” 한 줄과 “이 글에서 어떤 역할이었는지” 한 줄로 되어 있다
네트워크와 접근 통제
보안 그룹(SG, Security Group) – EC2 인스턴스에 붙는 방화벽이다. 포트별로 어느 IP에서 오는 연결을 받을지 정한다. 이 글의 문제는 22번 SG 규칙에 등록한 내 IP가 계속 바뀐 것이었고, 결론은 그 규칙을 0개로 만든 것이다
CIDR과 /32 - 198.51.100.24/32처럼 IP 뒤에 붙는 숫자로 주소 범위를 표기하는 방식이다. /32는 딱 그 주소 하나, /0은 전 세계 전부다. “내 IP만 허용”은 /32, “누구나 허용”은 0.0.0.0/0으로 쓴다
EIP(Elastic IP) – AWS가 주는 고정 공인 IP다. 인스턴스를 껐다 켜도 주소가 유지된다. 서버 쪽은 EIP라 고정인데 접속하는 내 쪽이 유동이라 문제가 생겼다
유동 IP – 통신사가 주기적으로 재할당하는 공인 IP다. 집이나 카페 인터넷은 대부분 여기 해당한다. “허용 IP 목록”이라는 설계가 무너진 원인이다
프라이빗 서브넷 – 인터넷 게이트웨이로 나가는 경로가 없는 VPC 구간이다. 안에 있는 서버는 공인 IP로 직접 통신하지 못한다. 우리 서버는 퍼블릭 서브넷이라 SSM을 인터넷 경로로 썼고, 그래서 추가 비용이 0이었다
배스천 호스트(Bastion Host) – 외부에서 접속할 수 있는 서버를 한 대만 두고, 내부 서버에는 그 서버를 거쳐 들어가는 구조다. 점프 서버라고도 한다. 관리 대상과 상시 비용이 한 대 늘어나서 인스턴스 한 대짜리 환경에는 과잉이라고 판단했다
VPN – 내 PC를 사설망 안에 있는 것처럼 만들어 주는 방식이다. 접속 후에는 내부 IP로 서버에 붙는다. 배스천과 같은 이유로 접었다
DDNS(Dynamic DNS) – IP가 바뀔 때마다 도메인이 가리키는 주소를 자동으로 갱신해 주는 서비스다. IP 추적은 자동화되지만 22번 포트는 계속 열려 있어서 원인이 남는다는 이유로 기각했다
AWS 서비스
IAM(Identity and Access Management) – AWS의 권한 관리 서비스다. 누가 어떤 리소스에 무엇을 할 수 있는지 정책으로 정한다. SSM으로 바꾼 뒤에는 “IP가 맞는 사람”이 아니라 “IAM 권한이 있는 사람”이 접속한다
IAM 역할과 인스턴스 프로파일 – 역할은 권한 묶음이고, 인스턴스 프로파일은 그 역할을 EC2에 붙이기 위한 포장지다. EC2는 역할을 프로파일 형태로만 받는다. 이 글에서 유일하게 새로 만든 것이고, 이게 없어서 에이전트가 SSM에 등록되지 않고 있었다
SSM(AWS Systems Manager) – EC2와 온프레미스 서버를 관리하는 서비스 묶음이다. 원격 접속, 명령 실행, 인벤토리 수집 등이 들어 있다
Session Manager – SSM의 접속 기능이다. 서버 쪽 에이전트가 AWS로 먼저 연결을 걸어 두고, 사용자는 그 통로를 빌려 들어간다. 인바운드 포트를 열지 않아도 접속되는 이유가 이 방향 차이에 있다. 세션 자체는 무료다
amazon-ssm-agent – 서버 안에서 돌면서 AWS와 통로를 유지하는 데몬이다. Amazon Linux 2023에는 기본 설치돼 있다. 자격증명 없이 뜨면 등록에 실패하고 재시도 간격을 늘리기 때문에, 프로파일을 나중에 붙였다면 재시작해야 한다
VPC 엔드포인트 (ssm, ssmmessages, ec2messages) – 인터넷을 거치지 않고 VPC 안에서 AWS 서비스로 바로 연결하는 통로다. Session Manager는 이 셋이 필요하다. 프라이빗 서브넷에서만 필요하고 개당 시간당 과금이라, 우리처럼 인터넷 경로를 쓰면 만들 이유가 없다
CloudTrail – AWS API 호출 기록을 남기는 서비스다. 누가 언제 무엇을 했는지 감사 로그로 조회할 수 있다. SSM 접속은 StartSession API를 호출하므로 접속 이력이 자동으로 남는다. SSH에는 없던 이점이다
S3 / CloudWatch Logs – S3는 파일 저장소, CloudWatch Logs는 로그 수집·조회 서비스다. Session Manager는 세션 중 오간 터미널 내용을 이 둘에 저장하는 옵션이 있는데 기본은 꺼짐이다. 켜면 저장 요금이 붙는다
SSH 설정
ProxyCommand – SSH가 서버에 직접 연결하는 대신, 지정한 명령이 만든 통로로 트래픽을 보내게 하는 ~/.ssh/config 설정이다. aws ssm start-session을 여기에 넣어서 기존 ssh·scp 명령을 그대로 SSM 위에 태웠다
호스트 키와 known_hosts – 서버는 자기 신원을 증명하는 호스트 키를 갖고 있고, 클라이언트는 처음 접속할 때 본 키를 ~/.ssh/known_hosts에 서버 이름과 함께 저장한다. 다음 접속부터 키가 다르면 경고한다. ProxyCommand를 쓰면 조회 이름이 HostName 값(인스턴스 ID)으로 바뀌어서, EIP 이름으로만 저장돼 있던 키를 못 찾아 실패했다
StrictHostKeyChecking accept-new – 처음 보는 서버의 호스트 키를 물어보지 않고 그대로 신뢰해 저장하는 옵션이다. 편하지만 첫 접속의 키를 검증 없이 받는다는 뜻이라, 이미 신뢰하던 키를 새 이름으로 복사하는 방식을 택했다.
작업 방식
SG 프로비저닝 스크립트 – 보안 그룹을 코드로 만들고 규칙을 세팅하는 스크립트다. 10-security-groups.sh가 여기 해당한다. 실행 시점의 내 IP로 22번을 여는 로직이 남아 있어서, 재실행하면 닫아 둔 포트가 다시 열린다.
멱등(idempotent) 스크립트 – 몇 번을 실행해도 결과가 같은 스크립트다. 이미 만들어진 리소스는 건너뛰고 없는 것만 만든다. 인프라 스크립트를 이렇게 짜 두면 중간에 실패해도 처음부터 다시 돌릴 수 있다