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


목차

  1. VPC Endpoint 구성
  2. IAM Role 구성
  3. EKS Cluster 생성
  4. Node Group 생성
  5. ECR 구성
  6. RDS / ElastiCache / S3 구성
  7. OIDC 구성 및 IRSA Role 생성
  8. 이미지 전송 및 ECR 업로드
  9. Helm Chart 배포
  10. 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 저장

주의: ec2 Endpoint가 없으면 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:

  • AmazonEC2ContainerRegistryReadOnly
  • AmazonEKS_CNI_Policy
  • AmazonEKSWorkerNodePolicy

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가 고정으로 붙습니다. (예: terraformterraform-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)로 지정해야 합니다. boolean true로 입력하면 동작하지 않습니다.

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로 연결하기 위해 프라이빗 호스팅 영역을 생성합니다.

  1. Route53 → 호스팅 영역 생성

    • 도메인 이름: TFE에 사용할 도메인 (예: tfe.example.internal)
    • 유형: 프라이빗 호스팅 영역
    • VPC: TFE가 배포된 VPC 선택
  2. 레코드 생성

    • 레코드 이름: TFE 서브도메인 (예: tfe)
    • 레코드 유형: A (별칭)
    • 별칭 대상: Network Load Balancer → 리전 선택 → Kubernetes Service로 생성된 NLB 선택

마치며

완전 폐쇄망 환경에서의 TFE EKS 구축은 외부 인터넷 차단으로 인해 단계 하나하나가 까다롭습니다. 특히 VPC Endpoint 누락, IRSA 신뢰 관계 오류, TFC Agent Namespace 규칙(-agents 접미사)이 가장 자주 발생하는 함정입니다.

설치 후에도 TFE 버전 업그레이드 시 이미지를 다시 Public 환경에서 받아 ECR에 Push하는 과정을 반복해야 하므로, 이 파이프라인을 자동화해두는 것을 권장합니다.

궁금한 점이나 다른 배포 방식(Docker Compose Mounted, Podman 등)에 대해서도 정리할 예정이니 댓글로 남겨주세요!