Skip to content

Split View: 클라우드의 bastion 이란 무엇이고, AWS 에서는 어떻게 쓰는 것이 좋은가 — ProxyJump 실측, SSM Session Manager, EC2 Instance Connect Endpoint

|

클라우드의 bastion 이란 무엇이고, AWS 에서는 어떻게 쓰는 것이 좋은가 — ProxyJump 실측, SSM Session Manager, EC2 Instance Connect Endpoint

문이 하나뿐인 건물

서버를 사설 서브넷에 두면 인터넷에서 닿을 수 없다. 그것이 목적이다. 그런데 운영자는 들어가야 한다. 이 모순을 푸는 고전적인 답이 bastion host 다. 공개 서브넷에 작은 호스트를 하나 두고, 인터넷에서 열린 문은 그 호스트의 SSH 포트 하나뿐이며, 나머지 서버는 bastion 에서 오는 접속만 받는다.

건물에 비유하면 출입구를 하나로 줄이고 그 문에만 경비를 세우는 것이다. 경비가 한 명이면 출입 기록도 한 곳에 남는다. 대신 그 문이 뚫리면 건물 전체가 뚫린다. 그래서 bastion 은 가장 단단하게 만들고, 가장 자주 패치하고, 가장 꼼꼼히 기록해야 하는 호스트가 된다.

고전적인 bastion 은 SSH 한 줄로 만들어진다

OpenSSH 에는 이 패턴이 내장되어 있다. ssh_config 매뉴얼의 ProxyJump 설명은 이렇다.

Setting this option will cause ssh(1) to connect to the target host by
first making an ssh(1) connection to the specified ProxyJump host and
then establishing a TCP forwarding to the ultimate target from there.

먼저 점프 호스트에 SSH 로 붙고, 거기서 최종 대상까지 TCP 포워딩을 연다. 명령줄에서는 -J 다. 홈랩에서 실제로 해 봤다. 내 Mac(192.168.219.101)에서 cubi01(192.168.219.120)을 거쳐 nuc1(192.168.219.116)으로 들어간다.

ssh -J cubi01 nuc1 'hostname; echo $SSH_CONNECTION'

첫 시도는 실패했다.

channel 0: open failed: connect failed: Temporary failure in name resolution
stdio forwarding failed

-v 로 보면 ssh 가 무엇을 하는지 드러난다.

debug1: Setting implicit ProxyCommand from ProxyJump: ssh -v -W '[%h]:%p' cubi01
debug1: Executing proxy command: exec ssh -v -W '[nuc1]:22' cubi01
Authenticated to cubi01 ([192.168.219.120]:22) using "publickey".
debug1: channel_connect_stdio_fwd: nuc1:22

nuc1 이라는 이름은 내 Mac 의 /etc/hosts 에는 있지만 cubi01 에는 없다(getent hosts nuc1 이 비어 있다). 대상의 이름은 내 노트북이 아니라 점프 호스트가 푼다. bastion 을 쓰는 사람이 가장 먼저 부딪히는 사실이다. IP 로 바꾸고, known_hosts 의 이름 항목을 그대로 쓰도록 HostKeyAlias 를 주면 통과한다.

ssh -o HostKeyAlias=nuc1 -J cubi01 192.168.219.116 'hostname; echo $SSH_CONNECTION'
nuc1
SSH_CONNECTION=192.168.219.120 53994 192.168.219.116 22

점프 없이 직접 붙었을 때와 비교하면 차이가 보인다.

SSH_CONNECTION=192.168.219.101 55330 192.168.219.116 22

대상 서버는 접속이 bastion(…120)에서 왔다고 본다. 내 Mac 의 주소(…101)는 어디에도 없다. 대상 서버의 로그만 보면 "cubi01 이 들어왔다" 는 것밖에 모른다. 누가 들어왔는지는 bastion 의 로그에만 있다. bastion 의 감사 로그를 반드시 남기고 밖으로 내보내야 하는 이유가 이 한 줄에 있다.

이 홈랩은 다섯 노드가 192.168.219.0/24 안에 있고, 인터넷에서 열린 것은 게이트웨이의 80(ACME HTTP-01 인증용)과 443 뿐이다. 운영 명령은 LAN 안에서 ssh cubi01 로 들어가 실행한다. 즉 cubi01 은 LAN 안의 관리 노드이지 인터넷에서 보이는 bastion 은 아니다. 인터넷에 22번을 열어야 하는 순간부터가 진짜 bastion 이고, 그 순간부터 위의 걱정이 전부 현실이 된다.

bastion 을 직접 운영하면 떠안는 것

  • 인바운드 22번 포트. 인터넷에 열린 SSH 는 하루 종일 두드려진다. 열린 문 자체가 공격면이다.
  • 키 관리. 누가 나가면 bastion 과 모든 대상의 authorized_keys 를 손봐야 한다. 실제로는 잘 안 한다.
  • 패치. bastion 은 공개 서브넷에 있는 일반 EC2 다. 커널·OpenSSH 취약점이 나오면 가장 먼저 패치해야 한다.
  • 로그의 사각. 위에서 봤듯이 대상은 bastion 의 주소만 본다. bastion 에서 세션 기록을 남기지 않으면 "누가 무엇을 했는지" 가 사라진다.
  • 단일 장애점. bastion 이 죽으면 아무도 못 들어간다. 그렇다고 두 대를 두면 관리 대상이 둘이 된다.

AWS 는 이 목록을 그대로 문서에 적어 두고, 두 가지 대안을 내놓았다.

AWS 의 답 1: SSM Session Manager

Systems Manager 문서는 Session Manager 를 "인바운드 포트를 열거나, bastion 호스트를 유지하거나, SSH 키를 관리할 필요 없이" 노드를 관리하는 기능이라고 소개한다. 인스턴스 안의 SSM Agent 가 밖으로 Systems Manager 엔드포인트에 연결하고, 운영자는 그 연결을 타고 들어간다. 인바운드 규칙은 하나도 필요 없다.

aws ssm start-session --target i-0123456789abcdef0

권한은 전부 IAM 이다. 어느 인스턴스에 누가 들어갈 수 있는지를 태그나 리소스 ARN 으로 정하고, 세션 내용은 S3·CloudWatch Logs 로, API 호출은 CloudTrail 로 남긴다. 키 파일이 없으니 퇴사자의 키를 지울 일도 없다.

포트 포워딩도 된다. 인스턴스의 80번을 내 노트북의 56789 로 끌어오거나,

aws ssm start-session --target i-0123456789abcdef0 \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["80"], "localPortNumber":["56789"]}'

인스턴스를 발판 삼아 그 뒤의 RDS 까지 끌어올 수 있다. 원격 호스트는 Systems Manager 에 등록되어 있을 필요가 없다.

aws ssm start-session --target i-0123456789abcdef0 \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["mydb.example.us-east-2.rds.amazonaws.com"],"portNumber":["3306"], "localPortNumber":["3306"]}'

기존 ssh·scp 를 그대로 쓰고 싶으면 ~/.ssh/config 에 ProxyCommand 를 두면 된다. 노드의 22번은 닫아 둔 채로다(문서 표현으로 "You can close inbound ports on the node").

# SSH over Session Manager
Host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
    User ec2-user

여기에 한 가지 함정이 문서에 명시되어 있다. SSH 나 포트 포워딩으로 맺은 세션은 내용이 기록되지 않는다. SSH 가 TLS 터널 안에서 다시 암호화하기 때문에 Session Manager 는 터널 역할만 한다. "누가 언제 어느 인스턴스에 붙었는지" 는 CloudTrail 에 남지만, "무슨 명령을 쳤는지" 는 일반 세션(start-session 만 쓴 경우)에서만 남는다. 감사가 목적이라면 SSH 경유를 IAM 으로 막을 수 있고, 문서가 그 정책을 준다.

{
  "Effect": "Deny",
  "Action": "ssm:StartSession",
  "Resource": "arn:aws:ssm:*:*:document/AWS-StartSSHSession"
}

AWS 의 답 2: EC2 Instance Connect Endpoint

Session Manager 는 인스턴스 안에 에이전트가 있어야 한다. 에이전트를 못 넣는 인스턴스, 혹은 그냥 평범한 SSH·RDP 를 쓰고 싶을 때를 위해 AWS 는 EC2 Instance Connect Endpoint(EICE) 를 두었다. 문서는 이것을 "identity-aware TCP proxy" 라고 부른다. VPC 의 서브넷 하나에 엔드포인트를 만들면, IAM 자격 증명으로 인증된 터널이 내 컴퓨터에서 그 엔드포인트까지 열리고, 거기서 VPC 안의 인스턴스로 TCP 가 나간다.

문서에서 확인한 성질은 이렇다.

  • 인스턴스에 공인 IP 가 필요 없고, VPC 에 인터넷 게이트웨이도 필요 없다.
  • 성공·실패를 가리지 않고 모든 접속 시도가 CloudTrail 에 남는다.
  • 추가 비용이 없다(다른 가용 영역의 인스턴스에 붙을 때 AZ 간 전송 요금만 든다).
  • VPC 당, 서브넷 당 하나 만 만들 수 있고, 엔드포인트 하나가 동시 접속 20개 까지 받는다.
  • 터널은 최대 1시간 이며, IAM 정책의 maxTunnelDuration 조건으로 더 짧게 강제할 수 있다. IAM 자격 증명이 먼저 만료돼도 터널은 상한까지 유지된다.
  • 관리 트래픽용이다. 대량 전송은 스로틀된다.
  • 기본으로는 인스턴스가 엔드포인트 ENI 의 주소를 클라이언트로 본다. 클라이언트 IP 보존을 켜면 실제 주소가 보이지만, IPv4 전용이고 같은 VPC 여야 한다.

쓰는 법은 SSH 의 ProxyCommand 를 바꾸는 것뿐이다.

Host i-*
    ProxyCommand aws ec2-instance-connect open-tunnel --instance-id %h

또는 CLI 가 대신 해 준다.

aws ec2-instance-connect ssh --instance-id i-0123456789abcdef0 --connection-type eice

Windows 인스턴스는 --remote-port 3389 로 터널을 열고 로컬 포트에 RDP 클라이언트를 붙인다.

셋을 나란히

EC2 bastion (직접 운영)SSM Session ManagerEC2 Instance Connect Endpoint
인터넷에 여는 포트22없음 (에이전트가 밖으로 연결)없음 (엔드포인트가 VPC 안에)
인스턴스에 필요한 것공인 IP 있는 bastion + 대상의 SG 규칙SSM Agent + IAM 역할없음 (키 페어만)
인증SSH 키IAMIAM + SSH 키
접속 기록bastion 의 sshd 로그 (직접 관리)CloudTrail + 세션 내용(S3/CloudWatch)CloudTrail (시도 전부)
세션 내용 기록별도 도구 필요일반 세션만, SSH/포워딩 경유는 안 됨안 됨 (TCP 프록시)
상한없음서브넷당 1개, 동시 20, 1시간
비용EC2 인스턴스 요금없음없음 (AZ 간 전송 제외)
맞는 곳에이전트도 엔드포인트도 못 쓰는 경우감사·명령 기록이 필요한 운영평범한 SSH/RDP, 에이전트 없는 AMI

AWS 에서는 이렇게 쓰는 것이 좋다

첫 번째 선택은 Session Manager 다. 인바운드 포트가 없고, 키가 없고, 명령까지 기록된다. bastion 이 떠안던 다섯 가지 부담이 전부 사라진다. 인스턴스에 SSM Agent 와 AmazonSSMManagedInstanceCore 권한을 가진 역할만 붙이면 된다. 사설 서브넷에 인터넷 경로가 없다면 Systems Manager 용 VPC 엔드포인트(PrivateLink)를 두면 에이전트가 VPC 안에서 연결한다.

SSH 그 자체가 필요하면 EICE 다. rsync, scp, IDE 의 원격 개발, RDP 처럼 진짜 TCP 연결이 필요한 작업은 EICE 로 터널을 열고 늘 쓰던 도구를 쓴다. 서브넷당 하나, 동시 20개, 1시간이라는 상한을 기억하고, maxTunnelDuration 으로 더 짧게 묶는다.

bastion EC2 는 마지막 선택이다. 그래도 두어야 한다면 실측이 알려 준 두 가지를 지킨다. 대상 서버는 bastion 의 주소만 보므로 bastion 의 sshd 로그를 바깥(CloudWatch Logs 등)으로 내보내고, 대상의 보안 그룹은 bastion 의 보안 그룹에서 오는 22번만 허용한다. 그리고 그 bastion 에도 SSM Agent 를 넣어 두면, 어느 날 22번을 닫아도 들어갈 길이 남는다.

한 줄로

bastion 은 "문을 하나로 줄인다" 는 생각이고, AWS 에서 그 생각을 가장 잘 실현하는 것은 이제 bastion 호스트가 아니라 인바운드 포트 없이 IAM 으로 들어가는 Session Manager 다. 진짜 SSH 가 필요하면 EICE 를 쓰고, 직접 운영하는 bastion 은 대상이 bastion 의 주소만 본다는 사실을 잊지 않은 채 마지막에 고른다.

What a bastion is in the cloud, and how to use one well on AWS — ProxyJump measured, SSM Session Manager, EC2 Instance Connect Endpoint

A building with one door

Put servers in a private subnet and the internet cannot reach them. That is the point. But operators have to get in. The classic answer to this contradiction is the bastion host: one small host in a public subnet, the only door open to the internet is its SSH port, and every other server accepts connections only from the bastion.

In building terms, you reduce the entrances to one and post a guard at that one door. With one guard, the entry log lives in one place too. In exchange, if that door is breached the whole building is. So the bastion becomes the host you harden the most, patch the most often, and log the most carefully.

A classic bastion is one line of SSH

OpenSSH has this pattern built in. The ssh_config manual describes ProxyJump like this:

Setting this option will cause ssh(1) to connect to the target host by
first making an ssh(1) connection to the specified ProxyJump host and
then establishing a TCP forwarding to the ultimate target from there.

SSH to the jump host first, then open a TCP forward from there to the final target. On the command line it is -J. I tried it in the homelab: from my Mac (192.168.219.101) through cubi01 (192.168.219.120) into nuc1 (192.168.219.116).

ssh -J cubi01 nuc1 'hostname; echo $SSH_CONNECTION'

The first attempt failed.

channel 0: open failed: connect failed: Temporary failure in name resolution
stdio forwarding failed

With -v you can see what ssh does.

debug1: Setting implicit ProxyCommand from ProxyJump: ssh -v -W '[%h]:%p' cubi01
debug1: Executing proxy command: exec ssh -v -W '[nuc1]:22' cubi01
Authenticated to cubi01 ([192.168.219.120]:22) using "publickey".
debug1: channel_connect_stdio_fwd: nuc1:22

The name nuc1 is in my Mac's /etc/hosts but not on cubi01 (getent hosts nuc1 returns nothing there). The target's name is resolved by the jump host, not by your laptop. It is the first fact anyone using a bastion runs into. Switch to the IP, and add HostKeyAlias so the existing known_hosts entry by name is used, and it goes through.

ssh -o HostKeyAlias=nuc1 -J cubi01 192.168.219.116 'hostname; echo $SSH_CONNECTION'
nuc1
SSH_CONNECTION=192.168.219.120 53994 192.168.219.116 22

Compare with a direct connection, no jump:

SSH_CONNECTION=192.168.219.101 55330 192.168.219.116 22

The target server sees the connection as coming from the bastion (…120). My Mac's address (…101) appears nowhere. From the target's logs alone you only learn that "cubi01 logged in". Who actually logged in exists only in the bastion's logs. That one line is the reason a bastion's audit log must be kept and shipped elsewhere.

This homelab has five nodes inside 192.168.219.0/24, and the only things open from the internet are the gateway's ports 80 (for ACME HTTP-01) and 443. Operational commands run from inside the LAN via ssh cubi01. So cubi01 is an admin node on the LAN, not a bastion visible from the internet. The moment you have to open port 22 to the internet is when you have a real bastion, and from that moment every worry above becomes real.

What you take on by running your own bastion

  • Inbound port 22. SSH open to the internet gets hammered all day. The open door is itself the attack surface.
  • Key management. When someone leaves, authorized_keys on the bastion and on every target needs editing. In practice it does not happen.
  • Patching. The bastion is an ordinary EC2 instance in a public subnet. Kernel and OpenSSH vulnerabilities must be patched there first.
  • A logging blind spot. As shown above, targets see only the bastion's address. Without session recording on the bastion, "who did what" disappears.
  • A single point of failure. If the bastion dies nobody gets in. Run two and you have two things to manage.

AWS writes this list into its documentation nearly verbatim and offers two alternatives.

AWS's answer 1: SSM Session Manager

The Systems Manager documentation introduces Session Manager as node management "without the need to open inbound ports, maintain bastion hosts, or manage SSH keys". The SSM Agent inside the instance connects outward to Systems Manager endpoints, and the operator rides that connection in. No inbound rule is needed at all.

aws ssm start-session --target i-0123456789abcdef0

Permissions are entirely IAM. Who may enter which instance is decided by tags or resource ARNs; session content goes to S3 or CloudWatch Logs, and API calls to CloudTrail. With no key files there are no departed employees' keys to remove.

Port forwarding works too. Pull the instance's port 80 to 56789 on your laptop,

aws ssm start-session --target i-0123456789abcdef0 \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["80"], "localPortNumber":["56789"]}'

or use the instance as a stepping stone to reach the RDS behind it. The remote host does not need to be registered with Systems Manager.

aws ssm start-session --target i-0123456789abcdef0 \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["mydb.example.us-east-2.rds.amazonaws.com"],"portNumber":["3306"], "localPortNumber":["3306"]}'

To keep using plain ssh and scp, put a ProxyCommand in ~/.ssh/config. Port 22 on the node stays closed (in the documentation's words, "You can close inbound ports on the node").

# SSH over Session Manager
Host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
    User ec2-user

One trap is stated explicitly in the documentation. Sessions established through SSH or port forwarding are not logged. SSH encrypts again inside the TLS tunnel, so Session Manager acts only as a tunnel. "Who connected to which instance and when" is in CloudTrail, but "what commands were typed" is recorded only for plain sessions (bare start-session). If auditing is the goal, SSH via Session Manager can be denied in IAM, and the documentation gives the policy.

{
  "Effect": "Deny",
  "Action": "ssm:StartSession",
  "Resource": "arn:aws:ssm:*:*:document/AWS-StartSSHSession"
}

AWS's answer 2: EC2 Instance Connect Endpoint

Session Manager needs an agent inside the instance. For instances that cannot take the agent, or when you simply want ordinary SSH or RDP, AWS has the EC2 Instance Connect Endpoint (EICE). The documentation calls it an "identity-aware TCP proxy". Create an endpoint in one subnet of the VPC, and a tunnel authenticated with your IAM credentials opens from your computer to that endpoint, from which TCP goes out to instances inside the VPC.

Properties confirmed in the documentation:

  • Instances need no public IP, and the VPC needs no internet gateway.
  • Every connection attempt, successful or not, is logged to CloudTrail.
  • There is no additional cost (only cross-AZ data transfer when connecting to an instance in another Availability Zone).
  • Only one per VPC and per subnet, and each endpoint supports up to 20 concurrent connections.
  • A tunnel lasts at most 1 hour, and the IAM condition maxTunnelDuration can force it shorter. Even if the IAM credentials expire first, the tunnel persists until the limit.
  • It is for management traffic. High-volume transfers are throttled.
  • By default the instance sees the endpoint ENI's address as the client. With client IP preservation on it sees the real address, but that is IPv4-only and same-VPC only.

Using it is just a different ProxyCommand.

Host i-*
    ProxyCommand aws ec2-instance-connect open-tunnel --instance-id %h

Or let the CLI do it.

aws ec2-instance-connect ssh --instance-id i-0123456789abcdef0 --connection-type eice

For Windows instances, open the tunnel with --remote-port 3389 and point an RDP client at the local port.

The three side by side

EC2 bastion (self-run)SSM Session ManagerEC2 Instance Connect Endpoint
Port open to the internet22none (agent connects outward)none (endpoint inside the VPC)
Needed on the instancebastion with public IP + SG rules on targetsSSM Agent + IAM rolenothing (just a key pair)
AuthenticationSSH keyIAMIAM + SSH key
Connection logbastion's sshd log (self-managed)CloudTrail + session content (S3/CloudWatch)CloudTrail (every attempt)
Session contentseparate toolingplain sessions only, not via SSH/forwardingno (TCP proxy)
Limitsnone1 per subnet, 20 concurrent, 1 hour
CostEC2 instance chargesnonenone (except cross-AZ transfer)
Fitswhen neither agent nor endpoint is possibleoperations that need audit and command logsplain SSH/RDP, AMIs without the agent

How to use it on AWS

The first choice is Session Manager. No inbound port, no keys, and commands are recorded. All five burdens the bastion carried disappear. Attach the SSM Agent and a role with AmazonSSMManagedInstanceCore to the instance. If the private subnet has no internet route, add VPC endpoints for Systems Manager (PrivateLink) and the agent connects inside the VPC.

When you need SSH itself, use EICE. Work that needs a real TCP connection — rsync, scp, remote development in an IDE, RDP — opens a tunnel through EICE and uses the usual tools. Remember the limits of one per subnet, 20 concurrent, one hour, and tie it shorter with maxTunnelDuration.

A bastion EC2 is the last choice. If you must run one, keep the two things the measurement taught. Targets see only the bastion's address, so ship the bastion's sshd log out (CloudWatch Logs, for example), and let the targets' security groups allow port 22 only from the bastion's security group. Put the SSM Agent on that bastion too, so that the day you close port 22 there is still a way in.

In one line

A bastion is the idea of "reduce the doors to one", and on AWS the best realisation of that idea is no longer a bastion host but Session Manager, entered through IAM with no inbound port. Use EICE when you need real SSH, and choose a self-run bastion last, without forgetting that targets see only the bastion's address.