أساسيات Terraform

مشروع: بناء بنية ويب كاملة مع Terraform

35 دقيقة الدرس 10 من 30

مشروع: بناء بنية ويب كاملة مع Terraform

يُحوّل هذا الدرس الختامي كل مفهوم من مفاهيم الدروس السابقة — بنية HCL، الموفرون، المتغيرات، الحالة، الخلفيات البعيدة، مصادر البيانات، الوسيطات التعريفية، والوحدات — إلى مشروع متكامل جاهز للإنتاج. ستُنشئ بنية ويب ثلاثية الطبقات على AWS: شبكة VPC مخصصة بشبكات فرعية عامة وخاصة عبر مناطق توافر متعددة، ومجموعة توسع تلقائي (ASG) من نسخ EC2 خلف موازن تحميل للتطبيقات (ALB)، مع تخزين الحالة عن بُعد في S3 ومزامنة DynamoDB. هذا هو النمط الذي تستخدمه فرق هندسة المنصات في شركات مثل Stripe وShopify وAirbnb لعملياتها السحابية الأساسية.

هيكل مجلد المشروع

نظّم المشروع كوحدة جذرية تستدعي وحدتين فرعيتين قابلتين لإعادة الاستخدام: modules/network للـ VPC والشبكات الفرعية، وmodules/compute لموازن التحميل ومجموعة التوسع التلقائي ومجموعات الأمان. يُشغَّل التمهيد للحالة البعيدة بشكل منفصل — لا تترك Terraform تدير دلو S3 وجدول DynamoDB اللذين يحملان ملف حالتها الخاصة.

web-stack/ ├── backend-bootstrap/ # مرة واحدة: إنشاء دلو S3 + جدول DynamoDB │ └── main.tf ├── modules/ │ ├── network/ │ │ ├── main.tf # VPC، شبكات فرعية، IGW، NAT GW، جداول التوجيه │ │ ├── variables.tf │ │ └── outputs.tf │ └── compute/ │ ├── main.tf # ALB، ASG، قالب الإطلاق، مجموعات الأمان │ ├── variables.tf │ └── outputs.tf ├── main.tf # الجذر: يستدعي وحدتي network و compute ├── variables.tf ├── outputs.tf ├── locals.tf ├── versions.tf # موفرون مطلوبون + قيود الإصدار ├── backend.tf # إعداد خلفية S3 البعيدة └── terraform.tfvars # قيم المتغيرات غير الحساسة
التمهيد مقابل الحالة المُدارة: مجلد backend-bootstrap/ هو فضاء عمل Terraform صغير منفصل يستخدم الحالة المحلية ويُشغَّل مرة واحدة فقط لكل بيئة. ينشئ دلو S3 (مع الإصدارات والتشفير) وجدول DynamoDB. بما أن هذه الموارد تحمل حالة المشروع الرئيسي، يجب ألا تُدار أبدًا من قِبل المشروع الرئيسي نفسه — تنفيذ destroy سيمحو ملف الحالة ويُوقعك في كارثة لا يمكن التعافي منها.

الخطوة الأولى — تمهيد الحالة البعيدة

قبل أن يتمكن المشروع الرئيسي من استخدام خلفية بعيدة، يجب أن تكون بنية الخلفية موجودة. هذه عملية تُنفَّذ مرة واحدة لكل بيئة. في خطوط أنابيب CI بالمؤسسات الكبيرة، تخضع هذه الخطوة لخط أنابيب "تمهيد" منفصل يستلزم موافقة SRE للتشغيل.

# backend-bootstrap/main.tf terraform { required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } } } provider "aws" { region = "us-east-1" } resource "aws_s3_bucket" "tf_state" { bucket = "acme-terraform-state-prod" lifecycle { prevent_destroy = true # حماية من المسح العرضي } tags = { ManagedBy = "terraform-bootstrap", Purpose = "terraform-state" } } resource "aws_s3_bucket_versioning" "tf_state" { bucket = aws_s3_bucket.tf_state.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" { bucket = aws_s3_bucket.tf_state.id rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } } } resource "aws_s3_bucket_public_access_block" "tf_state" { bucket = aws_s3_bucket.tf_state.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_dynamodb_table" "tf_lock" { name = "acme-terraform-lock" billing_mode = "PAY_PER_REQUEST" hash_key = "LockID" attribute { name = "LockID" type = "S" } tags = { ManagedBy = "terraform-bootstrap", Purpose = "terraform-lock" } } # شغّل مرة واحدة: # cd backend-bootstrap && terraform init && terraform apply

الخطوة الثانية — الوحدة الجذرية: الإصدارات والخلفية والمحليات

يُثبّت ملف versions.tf كل موفر في نطاق إصدار فرعي باستخدام عامل القيد المتشائم (~>). الموفرون غير المُثبَّتين هم أحد أكثر أسباب الانجراف غير المتوقع للبنية التحتية شيوعًا في المستودعات المشتركة — تشغيل terraform init -upgrade على جهاز زميل قد يسحب موفرًا يحتوي على تغيير كاسر ويُتلف بنية تحتية حقيقية إذا طُبّق التخطيط تلقائيًا.

# versions.tf terraform { required_version = "~> 1.8" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.50" } } } # backend.tf terraform { backend "s3" { bucket = "acme-terraform-state-prod" key = "web-stack/production/terraform.tfstate" region = "us-east-1" dynamodb_table = "acme-terraform-lock" encrypt = true kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123" } } # locals.tf locals { name_prefix = "${var.environment}-${var.project_name}" common_tags = merge( { Environment = var.environment Project = var.project_name ManagedBy = "terraform" Owner = "platform-team" }, var.extra_tags ) # حساب عدد مناطق التوافر: 3 للإنتاج، 2 لغيرها az_count = var.environment == "production" ? 3 : 2 }

الخطوة الثالثة — وحدة الشبكة (VPC + شبكات فرعية)

تبني وحدة الشبكة VPC بتصميم مركزي: الشبكات الفرعية العامة تستضيف موازن التحميل وبوابات NAT، بينما تستضيف الشبكات الفرعية الخاصة نسخ EC2. كل منطقة توافر تحصل على شبكة فرعية عامة وأخرى خاصة. استخدام for_each على شريحة من قائمة مناطق التوافر يجعل الوحدة مستقلة عن عدد المناطق — تعمل مع بيئة تطوير بمنطقتين وبيئة إنتاج بثلاث، مدفوعةً بمتغير واحد فقط.

# modules/network/main.tf data "aws_availability_zones" "available" { state = "available" } locals { azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) public_cidrs = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i)] private_cidrs = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i + 10)] } resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = merge(var.tags, { Name = "${var.name_prefix}-vpc" }) } resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = merge(var.tags, { Name = "${var.name_prefix}-igw" }) } resource "aws_subnet" "public" { for_each = { for i, az in local.azs : az => { cidr = local.public_cidrs[i], idx = i } } vpc_id = aws_vpc.main.id cidr_block = each.value.cidr availability_zone = each.key map_public_ip_on_launch = true tags = merge(var.tags, { Name = "${var.name_prefix}-public-${each.value.idx + 1}", Tier = "public" }) } resource "aws_subnet" "private" { for_each = { for i, az in local.azs : az => { cidr = local.private_cidrs[i], idx = i } } vpc_id = aws_vpc.main.id cidr_block = each.value.cidr availability_zone = each.key tags = merge(var.tags, { Name = "${var.name_prefix}-private-${each.value.idx + 1}", Tier = "private" }) } resource "aws_eip" "nat" { for_each = aws_subnet.public domain = "vpc" tags = merge(var.tags, { Name = "${var.name_prefix}-nat-eip-${each.value.availability_zone}" }) } resource "aws_nat_gateway" "main" { for_each = aws_subnet.public allocation_id = aws_eip.nat[each.key].id subnet_id = each.value.id tags = merge(var.tags, { Name = "${var.name_prefix}-nat-${each.value.availability_zone}" }) depends_on = [aws_internet_gateway.main] } resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.main.id } tags = merge(var.tags, { Name = "${var.name_prefix}-rt-public" }) } resource "aws_route_table_association" "public" { for_each = aws_subnet.public subnet_id = each.value.id route_table_id = aws_route_table.public.id } resource "aws_route_table" "private" { for_each = aws_subnet.private vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.main[each.key].id } tags = merge(var.tags, { Name = "${var.name_prefix}-rt-private-${each.value.availability_zone}" }) } resource "aws_route_table_association" "private" { for_each = aws_subnet.private subnet_id = each.value.id route_table_id = aws_route_table.private[each.key].id }
VPC Architecture: Public and Private Subnets Across AZs VPC 10.0.0.0/16 Internet Gateway AZ — us-east-1a Public Subnet 10.0.0.0/24 NAT GW ALB Node Private Subnet 10.0.10.0/24 EC2 (ASG) SG: 443 من ALB فقط AZ — us-east-1b Public Subnet 10.0.1.0/24 NAT GW ALB Node Private Subnet 10.0.11.0/24 EC2 (ASG) SG: 443 من ALB فقط AZ — us-east-1c Public Subnet 10.0.2.0/24 NAT GW ALB Node Private Subnet 10.0.12.0/24 EC2 (ASG) SG: 443 من ALB فقط Public Subnet (ALB + NAT) Private Subnet (EC2 ASG) Egress: NAT Gateway Security Groups
تصميم VPC بثلاث مناطق توافر: عُقد ALB وبوابات NAT في الشبكات الفرعية العامة؛ نسخ EC2 (ASG) في الشبكات الخاصة وتصل إلى الإنترنت عبر NAT فقط.

الخطوة الرابعة — وحدة الحوسبة (ALB + ASG)

تربط وحدة الحوسبة موازن التحميل في الشبكات الفرعية العامة، وقالب الإطلاق المشير إلى أحدث AMI لـ Amazon Linux 2023 عبر مصدر بيانات، ومجموعة التوسع التلقائي الممتدة على الشبكات الفرعية الخاصة. نموذج مجموعة الأمان صريح ومحدود: موازن التحميل يقبل المنفذ 443 من الإنترنت، ونسخ EC2 تقبل المنفذ 443 من مجموعة أمان موازن التحميل فقط — لا من 0.0.0.0/0 أبدًا.

# modules/compute/main.tf (مختصر — الموارد الجوهرية فقط) data "aws_ami" "al2023" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["al2023-ami-*-x86_64"] } } # --- مجموعات الأمان --- resource "aws_security_group" "alb" { name = "${var.name_prefix}-alb-sg" description = "السماح بـ HTTPS الوارد من الإنترنت، الصادر كاملًا إلى النسخ." vpc_id = var.vpc_id ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTPS من الإنترنت" } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = merge(var.tags, { Name = "${var.name_prefix}-alb-sg" }) } resource "aws_security_group" "ec2" { name = "${var.name_prefix}-ec2-sg" description = "السماح بـ HTTPS من مجموعة أمان ALB فقط." vpc_id = var.vpc_id ingress { from_port = 443 to_port = 443 protocol = "tcp" security_groups = [aws_security_group.alb.id] description = "HTTPS من ALB فقط" } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] description = "السماح بالصادر (عبر NAT GW)" } tags = merge(var.tags, { Name = "${var.name_prefix}-ec2-sg" }) } # --- موازن التحميل --- resource "aws_lb" "main" { name = "${var.name_prefix}-alb" internal = false load_balancer_type = "application" security_groups = [aws_security_group.alb.id] subnets = values(var.public_subnet_ids) idle_timeout = 60 drop_invalid_header_fields = true # متطلب أمني للإنتاج access_logs { bucket = var.access_log_bucket prefix = "${var.name_prefix}-alb" enabled = true } tags = merge(var.tags, { Name = "${var.name_prefix}-alb" }) } resource "aws_lb_target_group" "app" { name = "${var.name_prefix}-tg" port = 443 protocol = "HTTPS" vpc_id = var.vpc_id target_type = "instance" health_check { path = "/health" healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 30 matcher = "200" } tags = merge(var.tags, { Name = "${var.name_prefix}-tg" }) } resource "aws_lb_listener" "https" { load_balancer_arn = aws_lb.main.arn port = 443 protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = var.acm_certificate_arn default_action { type = "forward" target_group_arn = aws_lb_target_group.app.arn } } # --- قالب الإطلاق + ASG --- resource "aws_launch_template" "app" { name_prefix = "${var.name_prefix}-lt-" image_id = data.aws_ami.al2023.id instance_type = var.instance_type network_interfaces { associate_public_ip_address = false security_groups = [aws_security_group.ec2.id] } iam_instance_profile { name = var.instance_profile_name } metadata_options { http_endpoint = "enabled" http_tokens = "required" # IMDSv2 فقط — خط الأساس الأمني http_put_response_hop_limit = 1 } lifecycle { create_before_destroy = true } tags = merge(var.tags, { Name = "${var.name_prefix}-lt" }) } resource "aws_autoscaling_group" "app" { name = "${var.name_prefix}-asg" min_size = var.asg_min max_size = var.asg_max desired_capacity = var.asg_desired vpc_zone_identifier = values(var.private_subnet_ids) health_check_type = "ELB" health_check_grace_period = 120 target_group_arns = [aws_lb_target_group.app.arn] launch_template { id = aws_launch_template.app.id version = "$Latest" } instance_refresh { strategy = "Rolling" preferences { min_healthy_percentage = 80 instance_warmup = 60 } } lifecycle { create_before_destroy = true } }
خطأ إنتاجي — IMDSv1 على EC2: حذف http_tokens = "required" من قالب الإطلاق يُبقي IMDSv1 مُفعَّلًا. يمكن الوصول إلى IMDSv1 من أي عملية على النسخة، بما في ذلك ثغرات SSRF في كود التطبيق. اختراق Capital One (2019) استغل IMDS لسرقة بيانات اعتماد IAM. أَلزِم IMDSv2 دائمًا في كل قالب إطلاق. تضبط AWS الحسابات الجديدة على IMDSv2 افتراضيًا، لكن الحسابات القديمة والـ AMIs قد تبقى على IMDSv1.

الخطوة الخامسة — الوحدة الجذرية وسير العمل

تربط الوحدة الجذرية الوحدتين الفرعيتين معًا، ممررةً مخرجات الشبكة إلى مدخلات وحدة الحوسبة. كما تُصدر المخرجات الجوهرية التي تستهلكها خطوات خط أنابيب CI اللاحقة — رابط اختبار الدخان واسم ARN لموازن التحميل لإنشاء سجل DNS.

# main.tf (الوحدة الجذرية) provider "aws" { region = var.aws_region default_tags { tags = local.common_tags } } module "network" { source = "./modules/network" name_prefix = local.name_prefix vpc_cidr = var.vpc_cidr az_count = local.az_count tags = local.common_tags } module "compute" { source = "./modules/compute" name_prefix = local.name_prefix vpc_id = module.network.vpc_id public_subnet_ids = module.network.public_subnet_ids private_subnet_ids = module.network.private_subnet_ids instance_type = var.instance_type acm_certificate_arn = var.acm_certificate_arn instance_profile_name = var.instance_profile_name access_log_bucket = var.access_log_bucket asg_min = var.asg_min asg_max = var.asg_max asg_desired = var.asg_desired tags = local.common_tags } # outputs.tf output "alb_dns_name" { description = "اسم DNS لموازن التحميل." value = module.compute.alb_dns_name } output "vpc_id" { description = "معرف VPC." value = module.network.vpc_id } output "asg_name" { description = "اسم مجموعة التوسع التلقائي." value = module.compute.asg_name } # --- # أوامر النشر (تشغَّل من CI بعد اعتماد التخطيط): # التهيئة (تنزيل الموفرين، إعداد خلفية S3): terraform init \ -backend-config="bucket=acme-terraform-state-prod" \ -backend-config="key=web-stack/production/terraform.tfstate" \ -backend-config="region=us-east-1" # التخطيط (الناتج محفوظ كقطعة أثرية لبوابة المراجعة): terraform plan -var-file=envs/production.tfvars -out=tfplan # التطبيق (يستخدم التخطيط المحفوظ — لا مفاجآت): terraform apply tfplan # اختبار الدخان بعد التطبيق: ALB=$(terraform output -raw alb_dns_name) curl -sf --retry 5 --retry-delay 10 "https://${ALB}/health" \ || { echo "فشل اختبار الدخان — تراجع"; terraform destroy -auto-approve -target=module.compute; exit 1; }
دائمًا طبّق ملف التخطيط المحفوظ في CI. تشغيل terraform apply دون -out=tfplan ثم terraform apply tfplan يعني أن Terraform تُنشئ تخطيطًا جديدًا عند التطبيق. بين مراجعة الإنسان والتطبيق، قد يُغيّر خط أنابيب آخر أو تغيير يدوي الحالة — منتجًا تطبيقًا لا يطابق ما جرى مراجعته. حفظ التخطيط بـ -out وتطبيق تلك القطعة الأثرية بالضبط هو الطريق الوحيد لضمان سلامة مراجعة التخطيط. تفرض Terraform Cloud من HashiCorp هذا كميزة سير عمل إلزامية في خطط المؤسسات.

أنماط الفشل الإنتاجية التي يجب معرفتها

بعد تشغيل عشرات من نشر الـ Terraform ستواجه أنماط فشل متكررة. معرفتها مسبقًا تحوّل حادثة منتصف الليل إلى إصلاح في عشر دقائق:

  • قفل الحالة غير محرَّر بعد تطبيق منقطع: شغّل terraform force-unlock <LOCK_ID> — معرف القفل يظهر في رسالة الخطأ. تحقق من أن التطبيق السابق فشل فعلًا قبل الإلغاء؛ إن اكتمل فالإلغاء آمن. إن كان تطبيق آخر يعمل فعلًا، لا تُلغِ القفل أبدًا.
  • انجراف الطاقة المطلوبة في ASG: إن ضبط مشغّل desired_capacity يدويًا في وحدة التحكم AWS، ستعرض terraform plan التالية فرقًا وتُعيد ضبطها. استخدم ignore_changes = [desired_capacity] في كتلة lifecycle للـ ASG إن كنت تدير الطاقة المطلوبة عبر سياسة تحجيم تلقائي منفصلة.
  • حد EIP لبوابة NAT: الحد الافتراضي لـ AWS هو 5 عناوين EIP لكل منطقة. البنية ذات ثلاث مناطق توافر تحتاج 3 EIPs لبوابات NAT. عبر بيئات متعددة في منطقة واحدة تصل الحد بسرعة — اطلب رفع الحصة ضمن إعداد البنية التحتية الأولي قبل أول تطبيق.
  • إلغاء تسجيل AMI: إن أُلغي تسجيل AMI المستخدم في قالب الإطلاق، تفشل نسخ ASG الجديدة في الإطلاق لكن النسخ القائمة لا تتأثر. الحل هو تحديث مرجع AMI في قالب الإطلاق وتشغيل تحديث النسخ. أثبّت دائمًا قوالب الإطلاق على AMIs مُدارة عبر AWS Image Builder أو Packer، لا AMIs عامة قد تُحذف.
  • تعارض إصدار الموفر عبر الفضاءات: زميل يشغّل terraform init -upgrade ويُدرج ملف .terraform.lock.hcl محدَّثًا بإصدار موفر جديد. يلتقطه CI في المرة التالية. قد يحتوي الموفر الجديد على تغيير كاسر لمورد تستخدمه. الحل: راجع فروق ملف lock في طلبات الدمج بالجدية ذاتها التي تراجع بها كود التطبيق.
إصدار الوحدات في البيئات الجماعية: في مشاريع الأفراد أو الفرق الصغيرة يُقبل الرجوع إلى الوحدات عبر مسارات نسبية (./modules/network). في المؤسسات الكبيرة، تُنشر الوحدات في سجل Terraform خاص أو مستودع Git بإصدارات موسومة، ويُثبّت المستدعون على إصدار دلالي: source = "git::https://github.com/acme/terraform-modules.git//network?ref=v2.3.0". يضمن هذا أن تغيير وحدة في فرع فريق لن يكسر بنية فريق آخر التحتية عند init التالية.

ES
Edrees Salih
منذ ساعة

We are still cooking the magic in the way!