배스천 호스트(Bastion Host)

도은호·2025년 9월 23일

terraform

목록 보기
16/32

배스천은 사설 서브넷(Private Subnet)의 인스턴스에 안전하게 접속하기 위한 “점프 서버(관문)”.
운영자는 인터넷 → 배스천(퍼블릭 서브넷) → 사설 인스턴스(프라이빗 서브넷) 순서로 SSH/RDP 접속합.

Internet
   │ (허용된 관리 IP만)
   ▼
[ Bastion (Public Subnet, EIP) ]  ─── SSH/RDP ───►  [ App/DB (Private Subnet) ]
        │ 0.0.0.0/0 → IGW                        (공인 IP 없음)

1) 개념 (왜 필요한가?)

  • 문제: Private Subnet 인스턴스는 공인 IP가 없어 직접 접속이 불가.

  • 해결: 공인 IP/EIP가 붙은 배스천에만 외부 접속을 열고, 거기서 다시 내부로 점프.

  • 이점

    • 공격 표면 최소화: 외부에 노출되는 서버는 배스천 1대뿐.
    • 접속 제어/추적: 한 곳에 로그/감사를 모으고 정책을 집중 관리.
  • 자주 헷갈리는 것

    • NAT 게이트웨이 ≠ 배스천: NAT는 내부 → 외부(아웃바운드) 인터넷 통신용.
      배스천은 운영자 외부 → 내부(인바운드) 접속용.

2) Terraform 예제 (멀티-AZ VPC가 이미 있다고 가정)

입력으로 VPC/서브넷/사무실 IP 대역만 받습니다.
배스천은 퍼블릭 서브넷, 앱 인스턴스는 프라이빗 서브넷에 배치합니다.
(리전: 서울 ap-northeast-2)

versions.tf

terraform {
  required_version = "~> 1.9"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}
provider "aws" { region = "ap-northeast-2" }

variables.tf

variable "vpc_id"            { type = string }
variable "public_subnet_id"  { type = string }
variable "private_subnet_id" { type = string }
variable "office_cidr" {
  description = "관리자 사무실 고정 IP 대역 (예: 203.0.113.0/24)"
  type        = string
  default     = "203.0.113.0/24"
  validation {
    condition     = can(cidrnetmask(var.office_cidr))
    error_message = "유효한 IPv4 CIDR이어야 합니다."
  }
}

data&locals.tf (AMI 조회 + 공통 태그)

# Amazon Linux 2023 최신 AMI (x86_64)
data "aws_ssm_parameter" "al2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

locals {
  name_prefix = "velog-bastion-demo"
  tags = { Project = "velog", Role = "bastion-demo" }
}

security.tf (보안그룹)

# 배스천: 사무실 IP만 22포트 허용
resource "aws_security_group" "bastion" {
  name   = "${local.name_prefix}-bastion-sg"
  vpc_id = var.vpc_id
  tags   = local.tags
}

resource "aws_security_group_rule" "bastion_ssh_in" {
  type              = "ingress"
  security_group_id = aws_security_group.bastion.id
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = [var.office_cidr]
}

resource "aws_security_group_rule" "bastion_all_out" {
  type              = "egress"
  security_group_id = aws_security_group.bastion.id
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
}

# Private 인스턴스: 배스천 SG에서만 22 허용
resource "aws_security_group" "private" {
  name   = "${local.name_prefix}-private-sg"
  vpc_id = var.vpc_id
  tags   = local.tags
}

resource "aws_security_group_rule" "private_ssh_from_bastion" {
  type                     = "ingress"
  security_group_id        = aws_security_group.private.id
  from_port                = 22
  to_port                  = 22
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.bastion.id
}

resource "aws_security_group_rule" "private_all_out" {
  type              = "egress"
  security_group_id = aws_security_group.private.id
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
}

compute.tf (EC2 배치 + EIP)

# 배스천 (Public Subnet, EIP)
resource "aws_instance" "bastion" {
  ami                         = data.aws_ssm_parameter.al2023.value
  instance_type               = "t3.micro"
  subnet_id                   = var.public_subnet_id
  vpc_security_group_ids      = [aws_security_group.bastion.id]
  associate_public_ip_address = true
  tags = merge(local.tags, { Name = "${local.name_prefix}-bastion" })
}

resource "aws_eip" "bastion" {
  domain = "vpc"
  tags   = local.tags
}

resource "aws_eip_association" "bastion" {
  instance_id   = aws_instance.bastion.id
  allocation_id = aws_eip.bastion.id
}

# Private 인스턴스 (공인 IP 없음)
resource "aws_instance" "app" {
  ami                         = data.aws_ssm_parameter.al2023.value
  instance_type               = "t3.micro"
  subnet_id                   = var.private_subnet_id
  vpc_security_group_ids      = [aws_security_group.private.id]
  associate_public_ip_address = false
  tags = merge(local.tags, { Name = "${local.name_prefix}-app" })
}

outputs.tf

output "bastion_eip"  { value = aws_eip.bastion.public_ip }
output "app_private_ip" { value = aws_instance.app.private_ip }

실행

terraform init
terraform plan -out=plan.bin
terraform apply plan.bin
terraform output

3) 접속/확인 (SSH 점프)

① 한 번에 점프

# mac/linux
ssh -J ec2-user@$(terraform output -raw bastion_eip) \
    ec2-user@$(terraform output -raw app_private_ip)

② SSH 설정으로 깔끔하게 (~/.ssh/config)

Host bastion
  HostName <BASTION_EIP>
  User ec2-user
  IdentityFile ~/.ssh/id_rsa

Host app-priv
  HostName <APP_PRIVATE_IP>
  User ec2-user
  ProxyJump bastion
ssh app-priv

Amazon Linux 기본 유저: ec2-user. 우분투면 ubuntu.
키페어(프라이빗 키) 권한은 chmod 600인 것 확인!


4) 보안 체크리스트 (운영 필수)

  • 인바운드 최소화: 배스천 SG는 사무실 고정 IP 대역만 허용(0.0.0.0/0 금지).
  • 키/자격증명: SSH 키는 짧은 수명, 분실 시 즉시 폐기. 가능하면 MFA + 일회성 토큰 연계.
  • OS 하드닝: 비밀번호 로그인 비활성화, Fail2ban/iptables, 자동 보안 패치.
  • 접속감사: CloudWatch/CloudTrail(+S3)로 접속 로그 보관.
  • 세션 프록시/레코딩: 필요 시 세션 기록 솔루션(예: SSM Session Manager 기록, Bastion 관리 솔루션) 적용.
  • 고가용성: 멀티 AZ에 배치하거나 ASG/헬스체크로 자동 복구.
  • 비용: t3.micro 지속 과금 + EIP 비용. 실습 후엔 destroy.

5) 배스천 “없는” 대안 (참고)

  • AWS Systems Manager Session Manager

    • 인바운드 포트 0개(보안그룹 22 필요 없음).
    • 인스턴스 프로파일(IAM) + SSM Agent로 브라우저/CLI에서 셸 접속, 포트 포워딩 가능.
    • 감사/세션 기록 연계가 쉬워 운영 보안에 유리.
  • EC2 Instance Connect(또는 EIC Endpoint)

    • 임시 키로 브라우저/CLI에서 SSH.
    • 사내에서 배스천 없이 프라이빗 인스턴스에 접속하는 아키텍처도 구성 가능(요건 확인).

신규/보안 민감 환경이면 Session Manager배스천리스(bastionless) 접근을 우선 검토하세요.


6) 문제 해결 팁

  • 배스천 접속 안 됨: SG 인바운드(22)와 NACL, 라우팅(IGW) 확인. EIP 정상 부여?
  • 배스천→프라이빗 안 됨: 프라이빗 SG의 소스 SG 규칙(22)이 Bastion SG를 참조하는지 확인.
  • 키 권한 에러: chmod 600 ~/.ssh/id_rsa
  • 유저명 오류: Amazon Linux=ec2-user, Ubuntu=ubuntu, RHEL=ec2-user/ec2-user@…

요약

배스천 = 외부 접속의 단일 관문. 퍼블릭에 배스천 1대만 열고, 프라이빗은 배스천 SG만 허용하자. 가능한 경우 Session Manager로 포트 0개 운영도 고려!

profile
`•.¸¸.•´´¯`••._.• 🎀 𝒸𝓇𝒶𝓏𝓎 𝓅𝓈𝓎𝒸𝒽💞𝓅𝒶𝓉𝒽 🎀 •._.••`¯´´•.¸¸.•`

0개의 댓글