[EKS] EKS: 인증/인가 - 1. 실습 환경 배포
서종호(가시다)님의 AWS EKS Workshop Study(AEWS) 4주차 학습 내용을 기반으로 합니다.
TL;DR
4주차 실습 환경을 배포하고 그 결과를 확인한다.
- 네트워크: 노드를 프라이빗 서브넷에 배포하고, NAT Gateway를 통해 인터넷에 접근한다. 노드 접근은 SSM으로 한다
- 컨트롤 플레인 로깅 전면 활성화: 인증/인가 흐름 분석을 위해 api, audit, authenticator, controllerManager, scheduler 로그를 모두 켰다
- addon 7종 구성: coredns, kube-proxy, vpc-cni 외에 metrics-server, external-dns, eks-pod-identity-agent, cert-manager를 포함한다
- Kubernetes 1.35, 워커 노드 2대
환경 개요
인증/인가 실습에 맞춰 구성된 4주차 환경의 주요 특징을 정리하면 다음과 같다.
| 항목 | 구성 | 이유 |
|---|---|---|
| K8s 버전 | 1.35 | 최신 버전 |
| 워커 노드 수 | 2 | 인증/인가 실습에 충분 |
| 노드 서브넷 | 프라이빗 | 외부에서 노드 직접 접근 차단 |
| NAT Gateway | Single | 프라이빗 노드의 인터넷 접근용 |
| 노드 접근 | SSM (SSH 없음) | 키 관리 불필요, 접근 로그 자동 기록 |
| CP 로깅 | 전체 활성화 (5종) | authenticator/audit 로그로 인증 흐름 디버깅 |
| addon | 7종 (coredns, kube-proxy, vpc-cni, metrics-server, external-dns, eks-pod-identity-agent, cert-manager) | 인증/인가 실습 준비 |
| 노드 IAM 정책 | ExternalDNS + SSM 추가 | addon 동작 + SSM 접근 |
| IMDS hop limit | 2 (IMDSv2 강제) | 파드에서 IMDS 접근 허용 (학습용) |
이제 각 항목을 코드 수준에서 살펴보자.
var.tf
SSH 없음, SSM으로 노드 접근
SSH 키 페어 관련 변수(KeyName, ssh_access_cidr)는 정의되어 있지 않다. 노드에 대한 접근은 AWS Systems Manager(SSM) Session Manager로 한다. SSM은 포트를 열지 않고도 EC2 인스턴스에 접근할 수 있는 서비스로, 키 페어 관리가 필요 없고 접근 로그가 자동으로 남는다. 이를 위해 노드 IAM 역할에 AmazonSSMManagedInstanceCore 정책을 추가하는데, 이것은 뒤의 eks.tf에서 확인한다.
Kubernetes 버전과 노드 수
variable "KubernetesVersion" {
type = string
default = "1.35"
}
variable "WorkerNodeCount" {
type = number
default = 2
}
Kubernetes 1.35를 사용하고, 워커 노드는 2대로 구성한다. 인증/인가 실습에서는 많은 파드를 생성하지 않으므로 2대면 충분하다.
vpc.tf
프라이빗 서브넷과 NAT Gateway
퍼블릭 서브넷 3개와 프라이빗 서브넷 3개를 함께 구성하고, NAT Gateway를 활성화했다.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~>6.5"
# ...
public_subnets = var.public_subnet_blocks # 퍼블릭 서브넷 3개
private_subnets = var.private_subnet_blocks # 프라이빗 서브넷 3개
enable_nat_gateway = true
single_nat_gateway = true # NAT Gateway 1개로 비용 절감
one_nat_gateway_per_az = false
# ...
private_subnet_tags = {
"Name" = "${var.ClusterBaseName}-PrivateSubnet"
"kubernetes.io/role/internal-elb" = "1"
}
}
워커 노드는 프라이빗 서브넷에 배치한다. 인터넷에서 노드로 직접 접근할 수 없고, 노드가 인터넷에 나가야 할 때(컨테이너 이미지 풀, AWS API 호출 등)는 NAT Gateway를 경유한다.
single_nat_gateway = true는 NAT Gateway를 AZ당 하나가 아닌 전체 1개만 생성한다는 뜻이다. 프로덕션에서는 AZ별로 NAT Gateway를 두는 것이 고가용성 측면에서 권장되지만, 실습 환경에서는 비용 절감을 위해 1개로 충분하다.
프라이빗 서브넷 태그의 kubernetes.io/role/internal-elb = "1"은 AWS Load Balancer Controller가 내부 로드밸런서를 배치할 서브넷을 식별하는 마커다. 퍼블릭 서브넷의 kubernetes.io/role/elb(외부용)와 대응된다.
eks.tf
가장 내용이 많은 파일이다. 핵심 설정 위주로 본다.
보안 그룹
resource "aws_security_group_rule" "allow_ssh" {
type = "ingress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [
var.VpcBlock
]
security_group_id = aws_security_group.node_group_sg.id
}
VpcBlock(VPC CIDR)만 허용한다. VPC 내부 통신만 가능하고, 외부에서의 직접 접근은 차단된다.
EKS 클러스터 모듈
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.0"
# ...
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
endpoint_public_access = true
endpoint_private_access = true
enabled_log_types = [
"api",
"scheduler",
"authenticator",
"controllerManager",
"audit"
]
enable_cluster_creator_admin_permissions = true
# ...
}
프라이빗 서브넷에 노드 배포
subnet_ids에 module.vpc.private_subnets을 지정하여 노드 그룹이 프라이빗 서브넷에 배포된다.
API 서버 엔드포인트는 endpoint_public_access = true와 endpoint_private_access = true를 모두 설정한다. 외부에서 kubectl 접근도 가능하고, 프라이빗 서브넷의 노드가 VPC 내부 경로로 API 서버에 접근하는 것도 가능하다.
컨트롤 플레인 로깅 전면 활성화
5개 로그 타입을 전부 활성화했다.
| 로그 타입 | 용도 |
|---|---|
api |
API 서버 요청/응답 로그 |
audit |
누가, 언제, 무엇을 요청했는지 기록 |
authenticator |
IAM 인증 관련 로그 |
controllerManager |
컨트롤러 매니저 동작 로그 |
scheduler |
스케줄러 결정 로그 |
인증/인가 흐름을 디버깅하려면 authenticator와 audit 로그가 필수다. 예를 들어, 특정 IAM 사용자가 kubectl로 접근했을 때 어떤 Access Entry에 매핑되는지, RBAC에서 허용/거부되는지를 추적할 수 있다. 나머지 로그도 함께 켜 두면 전체 흐름을 종합적으로 파악할 수 있다.
컨트롤 플레인 로그는 CloudWatch Logs의
/aws/eks/<클러스터명>/cluster로그 그룹에 저장된다. 로그 보관 기간은 기본 90일이며, Terraform 모듈이 자동으로 로그 그룹을 생성한다.
노드 그룹
eks_managed_node_groups = {
primary = {
name = "${var.ClusterBaseName}-ng-1"
use_name_prefix = false
ami_type = "AL2023_x86_64_STANDARD"
instance_types = ["${var.WorkerNodeInstanceType}"]
desired_size = var.WorkerNodeCount
max_size = var.WorkerNodeCount + 2
min_size = var.WorkerNodeCount - 1
disk_size = var.WorkerNodeVolumesize
subnets = module.vpc.private_subnets
vpc_security_group_ids = [aws_security_group.node_group_sg.id]
iam_role_name = "${var.ClusterBaseName}-ng-1"
iam_role_use_name_prefix = false
iam_role_additional_policies = {
"${var.ClusterBaseName}ExternalDNSPolicy" = aws_iam_policy.external_dns_policy.arn
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
metadata_options = {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 2
}
labels = {
tier = "primary"
}
cloudinit_pre_nodeadm = [
{
content_type = "text/x-shellscript"
content = <<-EOT
#!/bin/bash
dnf update -y
dnf install -y tree bind-utils tcpdump nvme-cli links sysstat ipset htop
EOT
}
]
}
}
IAM 추가 정책
iam_role_additional_policies로 두 가지 정책을 연결한다.
| 정책 | 용도 |
|---|---|
ExternalDNSPolicy |
ExternalDNS addon이 Route 53 레코드를 관리할 수 있도록 허용 |
AmazonSSMManagedInstanceCore |
SSM Session Manager로 노드에 접근할 수 있도록 허용 |
AmazonSSMManagedInstanceCore 정책이 있어야 SSM Agent가 AWS Systems Manager와 통신할 수 있다. SSH 키 페어 없이도 노드에 접근할 수 있는 기반이 된다.
IMDS hop limit
metadata_options = {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2 강제
http_put_response_hop_limit = 2 # 기본값 1 → 2로 증가
}
EC2 Instance Metadata Service(IMDS)의 hop limit을 2로 설정했다. 기본값은 1인데, 이 경우 컨테이너(파드) 내부에서 IMDS에 접근할 수 없다. 파드에서 IMDS까지의 네트워크 경로가 노드의 네트워크 네임스페이스를 거치면서 hop이 하나 더 추가되기 때문이다. hop limit을 2로 올리면 파드에서도 EC2 Instance Profile의 임시 자격증명을 조회할 수 있다.
IRSA나 Pod Identity를 사용하면 파드가 IMDS 대신 전용 메커니즘으로 AWS 자격증명을 받으므로 hop limit과 무관하다. 하지만 학습 목적으로 IMDS 기반 접근도 테스트할 수 있도록 열어 둔 것이다.
Addon
addon 구성을 살펴보자.
addons = {
coredns = { most_recent = true }
kube-proxy = { most_recent = true }
vpc-cni = {
most_recent = true
before_compute = true # 노드 그룹보다 먼저 배포
}
metrics-server = { most_recent = true }
external-dns = {
most_recent = true
configuration_values = jsonencode({
txtOwnerId = var.ClusterBaseName
policy = "sync"
})
}
eks-pod-identity-agent = { most_recent = true }
cert-manager = { most_recent = true }
}
총 7종의 addon을 구성한다. 기본 3종(coredns, kube-proxy, vpc-cni)과 함께 인증/인가 실습에 필요한 4종을 추가했다. VPC CNI는 WARM_ENI_TARGET 등 별도 설정 없이 기본값을 사용한다. 이번 주차는 네트워킹이 아닌 인증/인가가 주제이므로 기본값으로 충분하다.
각 addon의 역할은 다음과 같다.
| addon | 역할 | 비고 |
|---|---|---|
| metrics-server | 노드/파드의 CPU, 메모리 사용량을 수집하여 kubectl top 등에 제공 |
HPA(Horizontal Pod Autoscaler) 동작에도 필요 |
| external-dns | K8s Service/Ingress의 호스트명을 Route 53 DNS 레코드로 자동 동기화 | policy = "sync"는 K8s에서 삭제된 리소스의 DNS 레코드도 삭제한다는 의미 |
| eks-pod-identity-agent | EKS Pod Identity 기능을 위한 에이전트 (DaemonSet) | 파드가 AWS IAM 권한을 받는 IRSA의 차세대 방식 |
| cert-manager | K8s 클러스터 내 TLS 인증서를 자동 발급/갱신 | Let’s Encrypt 등과 연동 가능 |
특히 eks-pod-identity-agent는 이번 주차 인증/인가 실습의 핵심이다. IRSA가 OIDC Provider + IAM Trust Policy 조합으로 파드에 AWS 권한을 부여했다면, Pod Identity는 EKS가 직접 관리하는 더 간단한 방식이다. 이 에이전트가 DaemonSet으로 각 노드에서 실행되면서 파드의 AWS 자격증명 요청을 처리한다.
outputs.tf
output "configure_kubectl" {
description = "Configure kubectl: run this command to update your kubeconfig"
value = "aws eks --region ${var.TargetRegion} update-kubeconfig --name ${var.ClusterBaseName}"
}
configure_kubectl 출력으로 kubeconfig 설정 명령어를 제공한다.
배포
Terraform 실행
git clone https://github.com/gasida/aews.git
cd aews/4w
terraform init
terraform plan
nohup sh -c "terraform apply -auto-approve" > create.log 2>&1 &
tail -f create.log
SSH 관련 변수가 없으므로 별도의 환경 변수 설정 없이 바로 terraform apply를 실행하면 된다.
Terraform 전체 코드 (var.tf, vpc.tf, eks.tf, outputs.tf)
var.tf
variable "ClusterBaseName" {
description = "Base name of the cluster."
type = string
default = "myeks"
}
variable "KubernetesVersion" {
description = "Kubernetes version for the EKS cluster."
type = string
default = "1.35"
}
variable "WorkerNodeInstanceType" {
description = "EC2 instance type for the worker nodes."
type = string
default = "t3.medium"
}
variable "WorkerNodeCount" {
description = "Number of worker nodes."
type = number
default = 2
}
variable "WorkerNodeVolumesize" {
description = "Volume size for worker nodes (in GiB)."
type = number
default = 30
}
variable "TargetRegion" {
description = "AWS region where the resources will be created."
type = string
default = "ap-northeast-2"
}
variable "availability_zones" {
description = "List of availability zones."
type = list(string)
default = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
}
variable "VpcBlock" {
description = "CIDR block for the VPC."
type = string
default = "192.168.0.0/16"
}
variable "public_subnet_blocks" {
description = "List of CIDR blocks for the public subnets."
type = list(string)
default = ["192.168.0.0/22", "192.168.4.0/22", "192.168.8.0/22"]
}
variable "private_subnet_blocks" {
description = "List of CIDR blocks for the private subnets."
type = list(string)
default = ["192.168.12.0/22", "192.168.16.0/22", "192.168.20.0/22"]
}
vpc.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~>6.5"
name = "${var.ClusterBaseName}-VPC"
cidr = var.VpcBlock
azs = var.availability_zones
enable_dns_support = true
enable_dns_hostnames = true
public_subnets = var.public_subnet_blocks
private_subnets = var.private_subnet_blocks
enable_nat_gateway = true
single_nat_gateway = true
one_nat_gateway_per_az = false
manage_default_network_acl = false
map_public_ip_on_launch = true
igw_tags = { "Name" = "${var.ClusterBaseName}-IGW" }
nat_gateway_tags = { "Name" = "${var.ClusterBaseName}-NAT" }
public_subnet_tags = {
"Name" = "${var.ClusterBaseName}-PublicSubnet"
"kubernetes.io/role/elb" = "1"
}
private_subnet_tags = {
"Name" = "${var.ClusterBaseName}-PrivateSubnet"
"kubernetes.io/role/internal-elb" = "1"
}
tags = { "Environment" = "cloudneta-lab" }
}
eks.tf
provider "aws" {
region = var.TargetRegion
}
resource "aws_security_group" "node_group_sg" {
name = "${var.ClusterBaseName}-node-group-sg"
description = "Security group for EKS Node Group"
vpc_id = module.vpc.vpc_id
tags = { Name = "${var.ClusterBaseName}-node-group-sg" }
}
resource "aws_security_group_rule" "allow_ssh" {
type = "ingress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [var.VpcBlock]
security_group_id = aws_security_group.node_group_sg.id
}
resource "aws_iam_policy" "aws_lb_controller_policy" {
name = "${var.ClusterBaseName}AWSLoadBalancerControllerPolicy"
description = "Policy for allowing AWS LoadBalancerController to modify AWS ELB"
policy = file("aws_lb_controller_policy.json")
}
resource "aws_iam_policy" "external_dns_policy" {
name = "${var.ClusterBaseName}ExternalDNSPolicy"
description = "Policy for allowing ExternalDNS to modify Route 53 records"
policy = file("externaldns_controller_policy.json")
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 21.0"
name = var.ClusterBaseName
kubernetes_version = var.KubernetesVersion
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
enable_irsa = true
endpoint_public_access = true
endpoint_private_access = true
enabled_log_types = [
"api", "scheduler", "authenticator", "controllerManager", "audit"
]
enable_cluster_creator_admin_permissions = true
eks_managed_node_groups = {
primary = {
name = "${var.ClusterBaseName}-ng-1"
use_name_prefix = false
ami_type = "AL2023_x86_64_STANDARD"
instance_types = ["${var.WorkerNodeInstanceType}"]
desired_size = var.WorkerNodeCount
max_size = var.WorkerNodeCount + 2
min_size = var.WorkerNodeCount - 1
disk_size = var.WorkerNodeVolumesize
subnets = module.vpc.private_subnets
vpc_security_group_ids = [aws_security_group.node_group_sg.id]
iam_role_name = "${var.ClusterBaseName}-ng-1"
iam_role_use_name_prefix = false
iam_role_additional_policies = {
"${var.ClusterBaseName}ExternalDNSPolicy" = aws_iam_policy.external_dns_policy.arn
AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
metadata_options = {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 2
}
labels = { tier = "primary" }
cloudinit_pre_nodeadm = [
{
content_type = "text/x-shellscript"
content = <<-EOT
#!/bin/bash
dnf update -y
dnf install -y tree bind-utils tcpdump nvme-cli links sysstat ipset htop
EOT
}
]
}
}
addons = {
coredns = { most_recent = true }
kube-proxy = { most_recent = true }
vpc-cni = { most_recent = true, before_compute = true }
metrics-server = { most_recent = true }
external-dns = {
most_recent = true
configuration_values = jsonencode({
txtOwnerId = var.ClusterBaseName
policy = "sync"
})
}
eks-pod-identity-agent = { most_recent = true }
cert-manager = { most_recent = true }
}
tags = {
Environment = "cloudneta-lab"
Terraform = "true"
}
}
outputs.tf
output "configure_kubectl" {
description = "Configure kubectl: run this command to update your kubeconfig"
value = "aws eks --region ${var.TargetRegion} update-kubeconfig --name ${var.ClusterBaseName}"
}
kubeconfig 설정
배포가 완료되면 kubeconfig를 설정한다.
aws eks --region ap-northeast-2 update-kubeconfig --name myeks
context 이름이 ARN 형식이라 길고 불편하므로 짧게 변경한다.
kubectl config rename-context \
$(kubectl config current-context) \
myeks
kubens default
기본 정보 확인
노드 확인
kubectl get node --label-columns=node.kubernetes.io/instance-type,eks.amazonaws.com/capacityType,topology.kubernetes.io/zone
2개 노드가 서로 다른 가용 영역에 배포되어 있다. 노드 호스트명이 프라이빗 서브넷 대역(192.168.12.0/22, 192.168.16.0/22 등)의 IP를 갖고 있는 것을 확인할 수 있다.
파드 확인
kubectl get pod -A
aws-node, coredns, kube-proxy와 함께 cert-manager, external-dns, eks-pod-identity-agent, metrics-server 파드가 실행된다. 특히 eks-pod-identity-agent가 DaemonSet으로 각 노드에 배포되어 있는 것을 확인할 수 있다.
Addon 확인
eksctl get addon --cluster myeks
7개 addon이 모두 ACTIVE 상태인지 확인한다. external-dns의 CONFIGURATION VALUES에 txtOwnerId와 policy 설정이 반영되어 있어야 한다.
정리
4주차 실습 환경은 보안 강화(프라이빗 서브넷, SSH 제거, SSM 대체)와 인증/인가 실습 준비(CP 로깅, Pod Identity Agent, cert-manager)에 초점을 맞추고 있다.
| 구성 | 목적 |
|---|---|
| 프라이빗 서브넷 + NAT GW | 노드를 외부에서 직접 접근 불가능하게 격리 |
| SSM으로 노드 접근 (SSH 없음) | 키 관리 없는 안전한 노드 접근 |
| CP 로그 전면 활성화 | authenticator/audit 로그로 인증 흐름 추적 |
| eks-pod-identity-agent | Pod Identity 실습 기반 마련 |
| cert-manager | TLS 인증서 자동 관리 |
| IMDS hop limit 2 | 파드에서 EC2 메타데이터 접근 허용 (학습용) |
댓글남기기