Terraform Enterprise를 AWS EKS 폐쇄망에 구축하기
인터넷이 차단된 완전 폐쇄망(Air-gapped) 환경의 AWS EKS 위에 Terraform Enterprise(TFE)를 Helm Chart로 배포하는 전 과정을 정리했습니다.
인터넷이 차단된 완전 폐쇄망(Air-gapped) 환경의 AWS EKS 위에 Terraform Enterprise(TFE)를 Helm Chart로 배포하는 전 과정을 정리했습니다. NAT Gateway 없이 VPC Endpoint만으로 AWS 서비스에 접근하는 방식을 기준으로 합니다.
전체 아키텍처 개요
[Client] → [Route53 Private Zone] → [NLB (Internal)]
↓
[EKS Cluster]
├── TFE Pod (×2)
├── TFC Agent Pod
└── Bundle Nginx Pod
↓
┌──────────────┼──────────────┐
[RDS] [ElastiCache] [S3]
(via VPC Endpoint)
VPC Endpoint를 통해 접근하는 AWS 서비스: ECR, S3, EKS, EC2, ELB, STS
목차
- VPC Endpoint 구성
- IAM Role 구성
- EKS Cluster 생성
- Node Group 생성
- ECR 구성
- RDS / ElastiCache / S3 구성
- OIDC 구성 및 IRSA Role 생성
- 이미지 전송 및 ECR 업로드
- Helm Chart 배포
- Route53 구성
1. VPC Endpoint 구성
NAT Gateway가 없는 폐쇄망 환경에서는 VPC Endpoint를 통해 AWS 서비스에 접근합니다.
Endpoint 공통 보안 그룹
| Direction | PORT | CIDR | 용도 |
|---|---|---|---|
| Inbound | 443 | VPC CIDR | 각 Endpoint 리소스 접근 |
Outbound 규칙은 제거해도 무방합니다.
필요한 Endpoint 목록
| Endpoint | 용도 |
|---|---|
com.amazonaws.ap-northeast-2.eks |
Bastion → EKS Cluster 접근 |
com.amazonaws.ap-northeast-2.ec2 |
EKS Node를 Node Group에 Join |
com.amazonaws.ap-northeast-2.ecr.api |
Bastion → ECR 이미지 업로드 |
com.amazonaws.ap-northeast-2.ecr.dkr |
Bastion → ECR 이미지 업로드 |
com.amazonaws.ap-northeast-2.elasticloadbalancing |
AWS Load Balancer Controller API 호출 |
com.amazonaws.ap-northeast-2.sts |
각종 인증 |
com.amazonaws.ap-northeast-2.s3 (Gateway) |
ECR 이미지 업로드 및 TFE → S3 State 저장 |
주의:
ec2Endpoint가 없으면 Node Group 생성 시 Node가 정상적으로 Join되지 않습니다.
2. IAM Role 구성
2-1. EKS Cluster용 IAM Role
신뢰 관계:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "eks.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
연결 Policy: AmazonEKSClusterPolicy
2-2. Node Group용 IAM Role
신뢰 관계:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
연결 Policy:
AmazonEC2ContainerRegistryReadOnlyAmazonEKS_CNI_PolicyAmazonEKSWorkerNodePolicy
2-3. Bastion Server Instance Profile
EKS Cluster 및 ECR 생성 후에 추가합니다.
⚠️ 주의: AWS Console에서 사용자 지정 신뢰 정책으로 직접 JSON을 작성하면 Instance Profile 형식이 아닌 일반 Role로 생성됩니다. 반드시 "AWS 서비스 → EC2" 방식으로 생성하세요.
EKS 접근용 Policy
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["eks:DescribeCluster", "eks:ListClusters"],
"Resource": ["<EKS Cluster ARN>"]
}]
}
ECR 이미지 업로드용 Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer",
"ecr:DescribeRepositories", "ecr:ListImages", "ecr:DescribeImages",
"ecr:BatchGetImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
"ecr:CompleteLayerUpload", "ecr:PutImage"
],
"Resource": "<ECR ARN>"
},
{
"Effect": "Allow",
"Action": ["ecr:GetAuthorizationToken"],
"Resource": "*"
}
]
}
3. EKS Cluster 생성
보안 그룹
| Direction | PORT | CIDR | 용도 |
|---|---|---|---|
| Inbound | 443 | Node Group CIDR | Node Group과의 통신 |
| Inbound | 443 | Bastion Server CIDR | kubectl, helm 명령 수신 |
| Outbound | ALL | 0.0.0.0/0 | - |
주요 설정값
- 클러스터 IAM 역할: 위에서 생성한 EKS Cluster용 IAM Role 선택
- EKS 자율 모드: 비활성화
- 클러스터 인증 모드: EKS API
- 클러스터 엔드포인트 액세스: 프라이빗 (폐쇄망은 VPC 내부에서만 접근 가능)
- Subnet: Private Subnet만 선택
- Kubernetes 서비스 IP 범위: VPC 대역과 겹치지 않도록 지정
- Addon: Amazon VPC CNI, kube-proxy, CoreDNS 선택
EKS 액세스 등록
클러스터 생성 후 Bastion Server가 kubectl 명령을 내릴 수 있도록 액세스 항목에 Bastion Instance Profile의 ARN을 클러스터 관리자 권한으로 등록합니다.
4. Node Group 생성
Launch Template 설정
- 인스턴스 유형:
m5.xlarge이상 권장 - AMI: Amazon EKS Optimized AL2 AMI (또는 RHEL 8 계열)
- 스토리지: 100 GB+
- 서브넷: Private Subnet
Node Group 설정
- Node Group IAM Role: 위에서 생성한 Node Group용 IAM Role 선택
- 가용 영역: 2개 이상
- 최소/최대 노드 수: 서비스 요구에 맞게 지정
5. ECR 구성
TFE 관련 이미지를 저장할 ECR Repository를 생성합니다. 폐쇄망에서는 하나의 Repository에 태그로 구분하는 방식이 편합니다.
| 태그 | 내용 |
|---|---|
terraform-enterprise |
TFE 메인 이미지 |
tfc-agent |
TFC Agent 이미지 (CA 인증서 커스텀 빌드) |
aws-load-balancer-controller |
AWS LB Controller 이미지 |
bundle |
Provider Bundle용 Nginx 이미지 |
6. RDS / ElastiCache / S3 구성
| 서비스 | 권장 사양 | 비고 |
|---|---|---|
| RDS (PostgreSQL) | db.m5d.xlarge (4core/16GB) | PostgreSQL 13.x ~ 17.x |
| ElastiCache (Redis) | cache.m5.large 이상 | Active-Active 모드에서 필수 |
| S3 | - | VPC Endpoint(Gateway 방식) 연결 필수 |
PostgreSQL 14.0~14.3은 알려진 버그로 인해 TFE와 호환되지 않습니다. 14.4 이상을 사용하세요.
7. OIDC 구성 및 IRSA Role 생성
EKS 클러스터의 OIDC Provider를 생성한 후 IRSA(IAM Role for Service Account) 방식으로 Pod에 AWS 권한을 부여합니다.
OIDC ARN 예시:
arn:aws:iam::123456789012:oidc-provider/oidc.eks.ap-northeast-2.amazonaws.com/id/ABCDEF1234567890
신뢰 관계 공통 패턴 (웹 자격 증명 방식으로 생성하면 편합니다):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "<OIDC ARN>" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"<OIDC 경로>:aud": "sts.amazonaws.com",
"<OIDC 경로>:sub": "system:serviceaccount:<Namespace>:<ServiceAccount>"
}
}
}]
}
IRSA Role 목록
| Role | Namespace:ServiceAccount | 연결 Policy |
|---|---|---|
| S3 접근용 | terraform:terraform |
S3 PutObject/GetObject/ListBucket/DeleteObject |
| AWS LB Controller용 | kube-system:aws-load-balancer-controller |
AWSLoadBalancerControllerIAMPolicy |
| TFE Agent용 | terraform-agents:terraform-agent |
배포 대상 계정의 AdministratorAccess |
TFE Agent Namespace 주의: TFE Agent의 Namespace는 TFE 배포 Namespace 뒤에
-agents가 고정으로 붙습니다. (예:terraform→terraform-agents)
타 계정엔 인프라를 배포하는 경우 TFE Agent Role에 아래 Policy를 추가하고, 대상 계정 Role에는 이 Role의 ARN을 신뢰 관계로 등록합니다:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::<대상 Account ID>:role/<대상 Role>"
}]
}
8. 이미지 전송 및 ECR 업로드
폐쇄망이므로 인터넷이 가능한 Public Bastion에서 이미지를 받아 Private Bastion으로 전송 후 ECR에 Push합니다.
8-1. Public Bastion에서 이미지 저장
# AWS Load Balancer Controller
docker pull public.ecr.aws/eks/aws-load-balancer-controller:v2.17.1
docker save public.ecr.aws/eks/aws-load-balancer-controller:v2.17.1 -o ~/aws-load-balancer-controller.tar
# TFE 이미지 (HashiCorp 레지스트리 로그인 필요)
echo "<TFE_LICENSE>" | docker login --username terraform \
images.releases.hashicorp.com --password-stdin
docker pull images.releases.hashicorp.com/hashicorp/terraform-enterprise:<VERSION>
docker save images.releases.hashicorp.com/hashicorp/terraform-enterprise:<VERSION> \
-o ~/terraform-enterprise.tar
# TFC Agent
docker pull hashicorp/tfc-agent:v1
docker save hashicorp/tfc-agent:v1 -o ~/tfc-agent.tar
# Bundle용 Nginx (별도 빌드한 이미지)
docker save nginx:bundle -o ~/nginx-bundle.tar
필요한 CLI 도구도 미리 다운로드합니다:
# AWS CLI v2
curl -o awscliv2.zip https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip
# kubectl
curl -LO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
# helm
curl -LO https://get.helm.sh/helm-v3.x.x-linux-amd64.tar.gz
# AWS Load Balancer Controller Helm Chart
helm pull eks/aws-load-balancer-controller --version <version>
# TFE Helm Chart
helm pull oci://registry.terraform.io/hashicorp/terraform-enterprise --version <version>
8-2. Private Bastion으로 파일 전송 (Public Bastion → Private Bastion)
scp -i <.pem 키> ~/awscliv2.zip <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/kubectl <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/linux-amd64/helm <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/aws-load-balancer-controller.tar <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/terraform-enterprise.tar <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/tfc-agent.tar <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/nginx-bundle.tar <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/aws-load-balancer-controller-<ver>.tgz <User>@<Private Bastion IP>:
scp -i <.pem 키> ~/terraform-enterprise-<ver>.tgz <User>@<Private Bastion IP>:
8-3. Private Bastion에서 CLI 도구 설치
# AWS CLI
unzip ~/awscliv2.zip && sudo ./aws/install && rm -rf aws awscliv2.zip
# kubectl
sudo install -o root -g root -m 0755 ~/kubectl /usr/local/bin/kubectl
echo 'alias k="kubectl"' >> ~/.bashrc && source ~/.bashrc
# Docker (RHEL 계열, 오프라인 RPM 설치)
sudo dnf install --skip-broken --disablerepo="*" --nogpgcheck -y ~/docker-installer/*.rpm
sudo systemctl enable --now docker
sudo chmod 666 /var/run/docker.sock && sudo usermod -aG docker ${USER}
# helm
sudo mv ~/helm /usr/local/bin
8-4. ECR에 이미지 Push
# ECR 로그인
aws ecr get-login-password --region ap-northeast-2 \
| docker login --username AWS --password-stdin <ECR URI>
# AWS Load Balancer Controller
docker load -i ~/aws-load-balancer-controller.tar
docker tag public.ecr.aws/eks/aws-load-balancer-controller:v2.17.1 \
<ECR URI>:aws-load-balancer-controller
docker push <ECR URI>:aws-load-balancer-controller
# TFE
docker load -i ~/terraform-enterprise.tar
docker tag images.releases.hashicorp.com/hashicorp/terraform-enterprise:<VERSION> \
<ECR URI>:terraform-enterprise
docker push <ECR URI>:terraform-enterprise
# TFC Agent (CA 인증서 커스텀 빌드)
docker load -i ~/tfc-agent.tar
mkdir -p ~/tfc-agent && cp ~/cert/ca.crt ~/tfc-agent/
cat > ~/tfc-agent/Dockerfile << 'EOF'
FROM hashicorp/tfc-agent:v1
USER root
ADD ca.crt /usr/local/share/ca-certificates
RUN update-ca-certificates
USER tfc-agent
EOF
docker build --no-cache -t hashicorp/tfc-agent:custom ~/tfc-agent
docker tag hashicorp/tfc-agent:custom <ECR URI>:tfc-agent
docker push <ECR URI>:tfc-agent
# Bundle Nginx
docker load -i ~/nginx-bundle.tar
docker tag nginx:bundle <ECR URI>:bundle
docker push <ECR URI>:bundle
TFC Agent Dockerfile 설명: 폐쇄망에서 사설 CA 인증서를 사용하는 경우, Agent가 TFE에 HTTPS로 연결할 때 인증서 검증에 실패합니다. CA 인증서를 이미지에 포함시켜 빌드해야 합니다.
9. Helm Chart 배포
9-1. EKS kubeconfig 설정
aws eks update-kubeconfig --region ap-northeast-2 --name <EKS Cluster 명>
9-2. AWS Load Balancer Controller 배포
aws-load-balancer-controller.yaml 작성:
clusterName: <EKS Cluster 명>
image:
repository: <ECR URI>
tag: aws-load-balancer-controller
serviceAccount:
create: true
name: aws-load-balancer-controller
annotations:
eks.amazonaws.com/role-arn: <LB Controller IRSA Role ARN>
region: ap-northeast-2
vpcId: <VPC ID>
배포:
helm install aws-load-balancer-controller \
~/aws-load-balancer-controller-<version>.tgz \
--namespace kube-system \
--values ~/aws-load-balancer-controller.yaml
에러 발생 시 아래 옵션 추가:
--skip-crds --disable-openapi-validation
9-3. Terraform Enterprise 배포
Kubernetes Secret 생성
# Namespace 생성
kubectl create namespace terraform
# Image Pull Secret
kubectl create secret docker-registry terraform-enterprise \
--namespace terraform \
--docker-server=<ECR URI (Repository 제외)> \
--docker-username=AWS \
--docker-password=$(aws ecr get-login-password --region ap-northeast-2)
# TFE 설정 Secret
kubectl create secret generic tfe-secrets \
--namespace=terraform \
--from-file=TFE_LICENSE=~/terraform.hclic \
--from-literal=TFE_ENCRYPTION_PASSWORD=<임의 패스워드> \
--from-literal=TFE_DATABASE_PASSWORD=<RDS 패스워드>
# TLS 인증서 Secret
kubectl create secret tls tfe-certs \
--namespace=terraform \
--cert=~/cert/service.crt \
--key=~/cert/service.key
terraform.yaml Helm Values 파일 작성
replicaCount: 2
tls:
certificateSecret: tfe-certs
# bundle.pem 을 base64로 인코딩한 값:
# cat ~/cert/ca.crt | base64 -w 0
caCertData: <Base64 인코딩된 CA 인증서>
image:
name: <ECR Repository 명>
repository: <ECR URI (Repository 제외)>
tag: terraform-enterprise
resources:
requests:
cpu: 2000m
memory: 8192Mi
serviceAccount:
enabled: true
name: terraform
annotations:
eks.amazonaws.com/role-arn: <S3 IRSA Role ARN>
agentWorkerPodTemplate:
spec:
serviceAccountName: terraform-agent
service:
type: LoadBalancer
port: 443
annotations:
service.beta.kubernetes.io/aws-load-balancer-name: "terraform-nlb"
service.beta.kubernetes.io/aws-load-balancer-type: nlb-ip # helm LB Controller 사용 시 반드시 nlb-ip
service.beta.kubernetes.io/aws-load-balancer-internal: "true" # boolean이 아닌 string "true"로 지정
service.beta.kubernetes.io/aws-load-balancer-backend-protocol: tcp
service.beta.kubernetes.io/aws-load-balancer-subnets: "<Private Subnet ID 1>,<Private Subnet ID 2>"
service.beta.kubernetes.io/aws-load-balancer-security-groups: "<Security Group ID>"
env:
secretRefs:
- name: tfe-secrets
variables:
TFE_HOSTNAME: <TFE 도메인>
TFE_DATABASE_HOST: <RDS Endpoint>:5432
TFE_DATABASE_NAME: terraform
TFE_DATABASE_USER: terraform
TFE_DATABASE_PARAMETERS: sslmode=require
TFE_OBJECT_STORAGE_TYPE: s3
TFE_OBJECT_STORAGE_S3_BUCKET: <S3 버킷명>
TFE_OBJECT_STORAGE_S3_REGION: ap-northeast-2
TFE_OBJECT_STORAGE_S3_USE_INSTANCE_PROFILE: true
TFE_OBJECT_STORAGE_S3_SERVER_SIDE_ENCRYPTION: AES256
TFE_RUN_PIPELINE_IMAGE: <ECR URI>:tfc-agent
TFE_REDIS_HOST: <ElastiCache Endpoint>:6379
TFE_REDIS_USE_AUTH: false
TFE_REDIS_USE_TLS: false
service.beta.kubernetes.io/aws-load-balancer-internal: 반드시"true"(string)로 지정해야 합니다. booleantrue로 입력하면 동작하지 않습니다.
aws-load-balancer-type: nlb-ip: Helm으로 AWS LB Controller를 배포했다면 반드시nlb-ip값을 사용해야 합니다.
배포:
helm install terraform-enterprise \
~/terraform-enterprise-<version>.tgz \
--namespace terraform \
--values ~/terraform.yaml
NLB가 생성되기까지 최대 약 10분 소요될 수 있습니다. (kubectl logs로 LB Controller 로그 확인 가능)
9-4. TFE Agent Service Account 생성
TFE Agent는 자동으로 terraform-agents Namespace에 배포되므로, 해당 Namespace에 Service Account를 별도로 생성해야 합니다.
# terraform-agent-sa.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: terraform-agent
namespace: terraform-agents # TFE Namespace + "-agents" 고정
annotations:
eks.amazonaws.com/role-arn: <TFE Agent IRSA Role ARN>
kubectl create --namespace terraform-agents -f ~/terraform-agent-sa.yaml
9-5. Bundle용 Nginx 배포
폐쇄망에서 Terraform Provider를 제공하기 위한 내부 Nginx 서버를 배포합니다.
# bundle.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: bundle
namespace: terraform
spec:
replicas: 1
selector:
matchLabels:
app: terraform-providers
template:
metadata:
labels:
app: terraform-providers
spec:
containers:
- name: bundle
image: <ECR URI>:bundle
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: bundle
namespace: terraform
spec:
type: ClusterIP
selector:
app: terraform-providers
ports:
- port: 8080
targetPort: 8080
protocol: TCP
kubectl apply -f ~/bundle.yaml
Provider Bundle 다운로드 URL (TFE Admin에서 Terraform Version 등록 시 사용):
http://bundle.terraform.svc.cluster.local:8080/providers/<번들 파일명>.zip
9-6. 배포 확인
# Health Check
kubectl exec -it pod/terraform-enterprise-<random> -n terraform \
-- tfe-health-check-status
# Admin Token 발급
kubectl exec -it pod/terraform-enterprise-<random> -n terraform \
-- tfectl admin token
Admin 계정 생성은 아래 URL에서 진행합니다:
https://<TFE 도메인>/admin/account/new?token=<발급된 토큰>
10. Route53 구성
TFE 도메인을 내부 NLB로 연결하기 위해 프라이빗 호스팅 영역을 생성합니다.
-
Route53 → 호스팅 영역 생성
- 도메인 이름: TFE에 사용할 도메인 (예:
tfe.example.internal) - 유형: 프라이빗 호스팅 영역
- VPC: TFE가 배포된 VPC 선택
- 도메인 이름: TFE에 사용할 도메인 (예:
-
레코드 생성
- 레코드 이름: TFE 서브도메인 (예:
tfe) - 레코드 유형: A (별칭)
- 별칭 대상: Network Load Balancer → 리전 선택 → Kubernetes Service로 생성된 NLB 선택
- 레코드 이름: TFE 서브도메인 (예:
마치며
완전 폐쇄망 환경에서의 TFE EKS 구축은 외부 인터넷 차단으로 인해 단계 하나하나가 까다롭습니다. 특히 VPC Endpoint 누락, IRSA 신뢰 관계 오류, TFC Agent Namespace 규칙(-agents 접미사)이 가장 자주 발생하는 함정입니다.
설치 후에도 TFE 버전 업그레이드 시 이미지를 다시 Public 환경에서 받아 ECR에 Push하는 과정을 반복해야 하므로, 이 파이프라인을 자동화해두는 것을 권장합니다.
궁금한 점이나 다른 배포 방식(Docker Compose Mounted, Podman 등)에 대해서도 정리할 예정이니 댓글로 남겨주세요!