Terraform ile Kubernetes Cluster Kurulumu: Adım Adım Rehber

Kubernetes cluster yönetimi söz konusu olduğunda, elle yapılan kurulumların ne kadar can sıkıcı olduğunu hepimiz biliriz. Bir cluster’ı kurar, bir şeyi yanlış yapılandırırsın, sonra her şeyi silip baştan başlarsın. Terraform devreye girdiğinde bu döngü kırılıyor ve altyapın kod haline geliyor. Bu yazıda AWS üzerinde EKS (Elastic Kubernetes Service) kullanarak gerçek dünya senaryolarına uygun bir Kubernetes cluster kurulumunu Terraform ile adım adım gerçekleştireceğiz.

Ön Koşullar ve Ortam Hazırlığı

Başlamadan önce bazı araçların kurulu olması gerekiyor. Bu rehberde AWS EKS kullanacağız, ancak aynı mantık GKE veya AKS için de geçerli.

Sisteminizde şunların kurulu olduğundan emin olun:

  • Terraform 1.5+: terraform --version ile kontrol et
  • AWS CLI v2: Kimlik bilgilerinin yapılandırılmış olması şart
  • kubectl: Cluster’a bağlanmak için
  • helm: İsteğe bağlı ama işe yarıyor

AWS kimlik bilgilerini ayarlamak için:

aws configure
# AWS Access Key ID: [your-key]
# AWS Secret Access Key: [your-secret]
# Default region name: eu-west-1
# Default output format: json

# Kimlik doğrulamayı test et
aws sts get-caller-identity

Proje dizin yapısını organize tutmak önemli. Özellikle birden fazla ortam (dev, staging, prod) yönetiyorsan modüler bir yapı seni kurtarır.

mkdir -p kubernetes-terraform/{modules/{vpc,eks,node-groups},environments/{dev,prod}}
cd kubernetes-terraform

# Dizin yapısı:
# .
# ├── modules/
# │   ├── vpc/
# │   ├── eks/
# │   └── node-groups/
# └── environments/
#     ├── dev/
#     └── prod/

Terraform Backend Yapılandırması

State dosyasını yerel tutmak küçük projeler için işe yarasa da ekip ortamında felaket reçetesidir. S3 backend kullanmak zorundasın.

# Önce S3 bucket ve DynamoDB tablosu oluştur
aws s3api create-bucket 
  --bucket my-terraform-state-k8s 
  --region eu-west-1 
  --create-bucket-configuration LocationConstraint=eu-west-1

aws s3api put-bucket-versioning 
  --bucket my-terraform-state-k8s 
  --versioning-configuration Status=Enabled

aws dynamodb create-table 
  --table-name terraform-state-lock 
  --attribute-definitions AttributeName=LockID,AttributeType=S 
  --key-schema AttributeName=LockID,KeyType=HASH 
  --billing-mode PAY_PER_REQUEST 
  --region eu-west-1

environments/dev/backend.tf dosyası:

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state-k8s"
    key            = "dev/eks/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }

  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "Terraform"
      Project     = "kubernetes-cluster"
    }
  }
}

VPC Modülü

EKS için özel bir VPC kurman gerekiyor. Mevcut VPC’yi kullanmak cazip gelir ama izolasyon açısından yeni bir VPC önerilir.

modules/vpc/main.tf:

# modules/vpc/main.tf

locals {
  azs = slice(data.aws_availability_zones.available.names, 0, 3)
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.cluster_name}-vpc"
    # EKS için zorunlu tag
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
  }
}

resource "aws_subnet" "private" {
  count             = length(local.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 4, count.index)
  availability_zone = local.azs[count.index]

  tags = {
    Name                                        = "${var.cluster_name}-private-${local.azs[count.index]}"
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
    "kubernetes.io/role/internal-elb"           = "1"
  }
}

resource "aws_subnet" "public" {
  count                   = length(local.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 4, count.index + 3)
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name                                        = "${var.cluster_name}-public-${local.azs[count.index]}"
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
    "kubernetes.io/role/elb"                    = "1"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.cluster_name}-igw"
  }
}

resource "aws_eip" "nat" {
  count  = length(local.azs)
  domain = "vpc"

  tags = {
    Name = "${var.cluster_name}-nat-eip-${count.index}"
  }
}

resource "aws_nat_gateway" "main" {
  count         = length(local.azs)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "${var.cluster_name}-nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(local.azs)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[count.index].id
  }

  tags = {
    Name = "${var.cluster_name}-private-rt-${count.index}"
  }
}

resource "aws_route_table_association" "private" {
  count          = length(local.azs)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

VPC Değişkenleri

modules/vpc/variables.tf:

variable "cluster_name" {
  description = "EKS cluster adi"
  type        = string
}

variable "vpc_cidr" {
  description = "VPC CIDR blogu"
  type        = string
  default     = "10.0.0.0/16"
}

variable "environment" {
  description = "Ortam adi (dev, staging, prod)"
  type        = string
}

EKS Cluster Modülü

Asıl iş burada başlıyor. EKS modülü hem control plane’i hem de node group’ları yönetiyor.

modules/eks/main.tf:

# modules/eks/main.tf

data "aws_iam_policy_document" "eks_cluster_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["eks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "eks_cluster" {
  name               = "${var.cluster_name}-cluster-role"
  assume_role_policy = data.aws_iam_policy_document.eks_cluster_assume_role.json
}

resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  role       = aws_iam_role.eks_cluster.name
}

resource "aws_security_group" "eks_cluster" {
  name        = "${var.cluster_name}-cluster-sg"
  description = "EKS cluster security group"
  vpc_id      = var.vpc_id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.cluster_name}-cluster-sg"
  }
}

resource "aws_eks_cluster" "main" {
  name     = var.cluster_name
  version  = var.kubernetes_version
  role_arn = aws_iam_role.eks_cluster.arn

  vpc_config {
    subnet_ids              = var.private_subnet_ids
    security_group_ids      = [aws_security_group.eks_cluster.id]
    endpoint_private_access = true
    endpoint_public_access  = var.public_access
    public_access_cidrs     = var.public_access_cidrs
  }

  enabled_cluster_log_types = [
    "api",
    "audit",
    "authenticator",
    "controllerManager",
    "scheduler"
  ]

  encryption_config {
    provider {
      key_arn = aws_kms_key.eks.arn
    }
    resources = ["secrets"]
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks_cluster_policy
  ]

  tags = {
    Name = var.cluster_name
  }
}

resource "aws_kms_key" "eks" {
  description             = "${var.cluster_name} EKS Secret Encryption Key"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  tags = {
    Name = "${var.cluster_name}-eks-key"
  }
}

# Node group IAM role
resource "aws_iam_role" "eks_nodes" {
  name = "${var.cluster_name}-node-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "eks_worker_node_policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
  role       = aws_iam_role.eks_nodes.name
}

resource "aws_iam_role_policy_attachment" "eks_cni_policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
  role       = aws_iam_role.eks_nodes.name
}

resource "aws_iam_role_policy_attachment" "eks_container_registry" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
  role       = aws_iam_role.eks_nodes.name
}

resource "aws_eks_node_group" "main" {
  cluster_name    = aws_eks_cluster.main.name
  node_group_name = "${var.cluster_name}-${each.key}"
  node_role_arn   = aws_iam_role.eks_nodes.arn
  subnet_ids      = var.private_subnet_ids

  for_each = var.node_groups

  instance_types = each.value.instance_types
  capacity_type  = each.value.capacity_type

  scaling_config {
    desired_size = each.value.desired_size
    min_size     = each.value.min_size
    max_size     = each.value.max_size
  }

  update_config {
    max_unavailable = 1
  }

  labels = each.value.labels

  dynamic "taint" {
    for_each = lookup(each.value, "taints", [])
    content {
      key    = taint.value.key
      value  = taint.value.value
      effect = taint.value.effect
    }
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks_worker_node_policy,
    aws_iam_role_policy_attachment.eks_cni_policy,
    aws_iam_role_policy_attachment.eks_container_registry,
  ]

  lifecycle {
    ignore_changes = [scaling_config[0].desired_size]
  }
}

Ortam Yapılandırması

Modülleri hazırladıktan sonra dev ortamı için ana konfigürasyonu oluşturuyoruz.

environments/dev/main.tf:

# environments/dev/main.tf

locals {
  cluster_name = "my-eks-${var.environment}"
}

module "vpc" {
  source = "../../modules/vpc"

  cluster_name = local.cluster_name
  vpc_cidr     = "10.0.0.0/16"
  environment  = var.environment
}

module "eks" {
  source = "../../modules/eks"

  cluster_name        = local.cluster_name
  kubernetes_version  = "1.28"
  vpc_id              = module.vpc.vpc_id
  private_subnet_ids  = module.vpc.private_subnet_ids
  public_access       = true
  public_access_cidrs = ["0.0.0.0/0"]  # Prod'da kısıtla!

  node_groups = {
    general = {
      instance_types = ["t3.medium"]
      capacity_type  = "ON_DEMAND"
      desired_size   = 2
      min_size       = 1
      max_size       = 5
      labels = {
        role = "general"
      }
      taints = []
    }
    spot = {
      instance_types = ["t3.large", "t3a.large", "m5.large"]
      capacity_type  = "SPOT"
      desired_size   = 1
      min_size       = 0
      max_size       = 10
      labels = {
        role     = "spot"
        workload = "batch"
      }
      taints = [{
        key    = "spot"
        value  = "true"
        effect = "NO_SCHEDULE"
      }]
    }
  }
}

# Cluster'a kubectl erişimi için kubeconfig güncelle
resource "null_resource" "update_kubeconfig" {
  triggers = {
    cluster_name = module.eks.cluster_name
  }

  provisioner "local-exec" {
    command = "aws eks update-kubeconfig --name ${module.eks.cluster_name} --region ${var.aws_region}"
  }

  depends_on = [module.eks]
}

environments/dev/variables.tf:

variable "aws_region" {
  description = "AWS bolge"
  type        = string
  default     = "eu-west-1"
}

variable "environment" {
  description = "Ortam adi"
  type        = string
  default     = "dev"
}

Cluster’ı Ayağa Kaldırma

Terraform kodları hazır olduğunda sıra uygulamaya geliyor. Bu adımları sırayla takip et.

cd environments/dev

# Terraform'u başlat
terraform init

# Neyin oluşturulacağını gör (her zaman önce bunu yap!)
terraform plan -out=tfplan

# Plan çıktısını incele, sonra uygula
terraform apply tfplan

# Yaklaşık 15-20 dakika sürer
# Bitmesini bekle ve çıktıları kontrol et
terraform output

# kubectl'in doğru cluster'a bağlı olduğunu doğrula
kubectl get nodes
kubectl get pods -A

# Node'ların durumunu kontrol et
kubectl get nodes -o wide

# Cluster bilgilerini görüntüle
kubectl cluster-info

Add-on’lar ve Cluster Tamamlama

Bare minimum EKS cluster çalışıyor ama production’a hazır değil. AWS Load Balancer Controller ve Cluster Autoscaler olmadan kullanışlı değil.

# Helm repo ekle
helm repo add eks https://aws.github.io/eks-charts
helm repo add autoscaler https://kubernetes.github.io/autoscaler
helm repo update

# AWS Load Balancer Controller kur
helm install aws-load-balancer-controller eks/aws-load-balancer-controller 
  -n kube-system 
  --set clusterName=my-eks-dev 
  --set serviceAccount.create=true 
  --set serviceAccount.name=aws-load-balancer-controller

# Cluster Autoscaler kur
helm install cluster-autoscaler autoscaler/cluster-autoscaler 
  --namespace kube-system 
  --set autoDiscovery.clusterName=my-eks-dev 
  --set awsRegion=eu-west-1 
  --set rbac.serviceAccount.create=true

# Metrics server kur (HPA için şart)
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# Kurulumları doğrula
kubectl get deployment -n kube-system aws-load-balancer-controller
kubectl get deployment -n kube-system cluster-autoscaler
kubectl top nodes

Gerçek Dünya Senaryosu: Mavi-Yeşil Node Güncellemesi

Production’da node group’u güncellemek riskli bir operasyondur. Terraform ile bu işlemi kontrollü yapabilirsin.

# Mevcut node group'u taint ile işaretle
kubectl taint nodes -l role=general 
  maintenance=true:NoSchedule

# Terraform'da yeni node group ekle (main.tf'i güncelle)
# Ardından plan ve apply yap
terraform plan -target=module.eks.aws_eks_node_group.main
terraform apply -target=module.eks.aws_eks_node_group.main

# Eski node'ları drain et
for node in $(kubectl get nodes -l role=general --no-headers | awk '{print $1}'); do
  kubectl drain $node 
    --ignore-daemonsets 
    --delete-emptydir-data 
    --force
done

# Eski node group'u Terraform'dan kaldır
# variables.tf'den eski node group'u sil
terraform apply

# Node'ların yeni gruba taşındığını doğrula
kubectl get nodes -o wide
kubectl get pods -A -o wide | grep -v Running

Ortamlar Arası Tutarlılık

Dev, staging ve prod arasında yapılandırma farklarını yönetmek için tfvars dosyalarını kullan.

# environments/prod/terraform.tfvars
aws_region  = "eu-west-1"
environment = "prod"

# Bu değerleri prod için override et
# modules/eks içindeki node_groups prod değerleri:
# general:
#   instance_types: ["m5.xlarge", "m5.2xlarge"]
#   capacity_type: ON_DEMAND
#   desired_size: 3
#   min_size: 2
#   max_size: 20
# spot:
#   instance_types: ["m5.2xlarge", "m5.4xlarge"]
#   capacity_type: SPOT
#   desired_size: 5
#   min_size: 2
#   max_size: 50

# Prod planını ayrı workspace'de çalıştır
terraform workspace new prod
terraform workspace select prod
terraform plan -var-file="terraform.tfvars" -out=prod-tfplan

# Kritik: prod apply öncesi mutlaka review et
terraform show prod-tfplan | less
terraform apply prod-tfplan

Yaygın Hatalar ve Çözümleri

Terraform ile EKS kurarken sık karşılaşılan sorunlar şunlar:

  • IAM yetki hataları: aws sts get-caller-identity çalışıyorsa ama Terraform hata veriyorsa, kullanıcının EKS ve VPC izinlerini kontrol et. AdministratorAccess geliştirme ortamında geçici çözüm, prod’da asgari yetki prensibini uygula.
  • Subnet tag eksikliği: EKS load balancer’ların hangi subnet’e yerleşeceğini bu tag’lerle anlıyor. kubernetes.io/role/elb ve kubernetes.io/role/internal-elb tag’lerini subnet’lere eklemeyi unutma.
  • Node’lar NotReady kalıyor: Genellikle CNI sorunu. kubectl describe node ile olayları incele. AWS VPC CNI plugin’inin düzgün çalışıp çalışmadığını kubectl get pods -n kube-system | grep aws-node ile kontrol et.
  • State lock sorunu: Ekipten biri Terraform çalıştırırken başkası da apply yapmaya çalışırsa DynamoDB lock devreye girer. terraform force-unlock ile çözebilirsin ama dikkatli ol.
  • for_each ile node group sorunları: Node group map’te değişiklik yaparken Terraform tüm node group’u silip yeniden oluşturmak isteyebilir. lifecycle { prevent_destroy = true } ile bunu engelle ve manuel müdahale et.
  • KMS key silme: terraform destroy yaptığında KMS key’ler 7-30 gün arası bekleme süresine giriyor. Bu normal davranış, deletion_window_in_days değerini düşürebilirsin ama 7 günün altına inemezsin.

Maliyet Optimizasyonu

Spot instance kullanımı maliyeti ciddi düşürür ama pod’ların ani kapanmaya hazır olması gerekir.

# Spot instance kesmelerini simüle et (test ortamında)
# aws-node-termination-handler kur
helm repo add eks https://aws.github.io/eks-charts
helm install aws-node-termination-handler 
  eks/aws-node-termination-handler 
  --namespace kube-system 
  --set enableSpotInterruptionDraining=true 
  --set enableRebalanceMonitoring=true 
  --set enableScheduledEventDraining=true

# Maliyet anomalisi için CloudWatch alarm oluştur
aws budgets create-budget 
  --account-id $(aws sts get-caller-identity --query Account --output text) 
  --budget file://budget.json 
  --notifications-with-subscribers file://notifications.json

Sonuç

Terraform ile EKS kurulumu başta karmaşık görünüyor, özellikle VPC, IAM ve node group konfigürasyonlarının birbirine bağımlılıkları nedeniyle. Ama modüler yapıya geçtiğinde her şey yerine oturuyor. Bu yazıda anlattığım yaklaşımın en büyük avantajı tekrar kullanılabilirlik; aynı modülleri dev, staging ve prod için birkaç değişkenle farklı konfigürasyonlarda kullanabiliyorsun.

Üretim ortamında dikkat etmen gereken en kritik noktalar şunlar: state dosyasını S3’te şifreli tut, public API endpoint erişimini kısıtla, node group’larını multi-AZ dağıt ve cluster autoscaler’ı mutlaka kur. KMS ile secret şifreleme ve CloudWatch log’larını etkinleştirmek de production checklist’inin vazgeçilmez parçaları.

Bir sonraki adım olarak ArgoCD veya Flux gibi GitOps araçlarını bu altyapının üzerine kurabilirsin. Terraform altyapıyı kuruyor, GitOps ise uygulama deployment’larını yönetiyor. İkisi birlikte tam anlamıyla code olarak altyapı ve uygulama yönetimi sağlıyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir