loading...

ใช้ Terraform ทำ Blue-Green Deployment แบบง่ายๆ

zkan profile image Kan Ouivirach ・4 min read

Terraform มันคืออาวุธที่ใช้ในการทำ infrastructure as code เพื่อจัดการการวาง infrastructure ต่างๆ เราสามารถสร้างเครื่อง จัดการเครื่อง หรือลบเครื่องได้โดยไม่ต้องไปกดๆ ใน cloud console เลย และที่สำคัญคือมันทำ immutable infrastructure ลองดูวีดีโอด้านล่างนี้ 👇 เค้าอธิบาย mutable กับ immutable infrastructure ว่าต่างกันอย่างไร

ส่วน blue-green deployment มันคืออะไร? ถ้าใครไม่รู้จัก ผมอยากให้ตามไปอ่านโพสต์ BlueGreenDeployment ของ Martin Fowler กันก่อนครับ 😆 ด้านล่างนี่เป็นรูป blue-green deployment ที่ Martin เค้าวาดไว้

รูป blue-green deployment จากโพสต์ BlueGreenDeployment ของ Martin Flower

มันก็ประมาณว่า เวลาที่เรา deploy server เราจะมอง server ตัวเก่าเป็น blue และตัวที่กำลังจะ deploy เป็น green โดยที่ตัว infrastructure ของเราจะรอให้ green ทำงานได้ก่อน รับ request ได้ก่อน แล้วค่อยฆ่าตัว blue ทิ้ง (ซึ่งแน่นอนครับ ต้องอาศัย load balancer สักตัวหนึ่งมาช่วย)

ซึ่งตัว Terraform ที่เกริ่นมาข้างต้นนี่แหละ สามารถเอามาทำ blue-green deployment ได้นะ โดยหลักการที่เราจะทำเนี่ย เนื่องจาก Terraform ออกแบบมาเพื่อ immutable infrastructure ซึ่งการที่เราจะสร้าง infrastructure stack ทั้งหมด ตั้งแต่ DNS ยัน database เลยเนี่ย มันจะทำให้เราต้องมาทำ load balancer ครอบ stack ของเราอีกรอบ (ย้อนกลับไปดูรูปด้านบนครับ ตัว load balancer ก็คือ Router ในรูปนั่นเอง)

ดังนั้นเราจะสร้างโครงขึ้นมาก่อน แล้วส่วนที่เราต้องการให้มีการเปลี่ยนแปลงอยู่ตลอดเวลา เช่น เครื่อง server เราก็จะแยกออกมาจากโครงนั้นเอามาทำ blue-green deployment ดูโค้ดกันเลย (ในทีนี้ใช้ AWS เป็น cloud provider นะ)

เบื้องต้นผมจะทำไว้ 2 โฟลเดอร์มีหน้าตาประมาณนี้

➜  terraform tree -L 2
.
├── app
│   ├── bootstrap.sh
│   └── main.tf
└── base
    ├── bootstrap.sh
    └── main.tf

2 directories, 4 files

โฟลเดอร์ base จะเป็น folder ที่ผมจะเอาไว้สร้างโครงครับ ซึ่งตรงนี้จะเป็น Terraform ก็ได้ หรือจะสร้างผ่าน CLI ก็ได้ หรือจะไป manual กดๆ ในหน้า AWS console เลยก็ได้ครับ ส่วนโฟลเดอร์ app ผมก็จะมีแค่การสร้าง EC2 instance แล้วก็การเอา instance นั้นไป register เข้ากับ load balancer (ในที่นี้ใช้ ALB)

ส่วนไฟล์ bootstrap.sh ทั้ง 2 ไฟล์ มีหน้าตาเหมือนกันครับ จะเป็นสคริปสำหรับติดตั้งอะไรก็ได้ตอนที่เครื่อง EC2 ถูก provision ขึ้นมา ในที่นี้ผมจะเอาไว้ลง Docker เฉยๆ เนื้อหาในไฟล์จะประมาณนี้

#!/bin/sh

rm -rf /var/lib/cloud/*
sudo apt-get update -y
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
sudo usermod -aG docker ubuntu
sudo curl -L "https://github.com/docker/compose/releases/download/1.25.5/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

มาดู base/main.tf กัน จะยาวๆ หน่อย

# base/main.tf

provider "aws" {
  access_key = var.access_key_id
  secret_key = var.secret_access_id
  region     = var.region
}

variable "access_key_id" {
  type        = string
  description = "AWS Access Key ID"
}

variable "secret_access_id" {
  type        = string
  description = "AWS Secret"
}

variable "region" {
  type        = string
  default     = "ap-southeast-1"
  description = "AWS Region"
}

variable "product_area" {
  type        = string
  default     = "lost-in-space"
  description = "Product Area"
}

variable "environment" {
  type        = string
  default     = "dev"
  description = "Product Environment"
}

resource "aws_vpc" "lost_in_space" {
  assign_generated_ipv6_cidr_block = true
  cidr_block                       = "10.30.0.0/21"
  enable_dns_hostnames             = true
  enable_dns_support               = true

  tags = {
    Name         = var.product_area
    environment  = var.environment
    product-area = var.product_area
  }
}

resource "aws_internet_gateway" "lost_in_space_internet_gateway" {
  vpc_id = aws_vpc.lost_in_space.id

  tags = {
    Name         = "${var.product_area}-internet-gateway"
    environment  = var.environment
    product-area = var.product_area
  }
}

resource "aws_subnet" "lost_in_space_public_subnet_zone_a" {
  vpc_id = aws_vpc.lost_in_space.id

  assign_ipv6_address_on_creation = true
  availability_zone               = "${var.region}a"
  cidr_block                      = "10.30.0.0/24"
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.lost_in_space.ipv6_cidr_block, 8, 0)

  tags = {
    Name         = "${var.product_area}-public-subnet-zone-a"
    environment  = var.environment
    product-area = var.product_area
  }
}

resource "aws_route_table" "lost_in_space_public_route_table" {
  vpc_id = aws_vpc.lost_in_space.id

  route {
    cidr_block     = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.lost_in_space_internet_gateway.id
  }

  tags = {
    Name         = "${var.product_area}-public-route-table"
    environment  = var.environment
    product-area = var.product_area
  }
}

resource "aws_route_table_association" "lost_in_space_public_route_table_association_zone_a" {
  subnet_id      = aws_subnet.lost_in_space_public_subnet_zone_a.id
  route_table_id = aws_route_table.lost_in_space_public_route_table.id
}

resource "aws_security_group" "lost_in_space" {
  name        = "lost-in-space-dev"
  description = "Security group for Lost In Space (dev)"
  vpc_id      = aws_vpc.lost_in_space.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow SSH inbound traffic"
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTP inbound traffic"
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTPS inbound traffic"
  }

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow PostgreSQL inbound traffic"
  }

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

  tags = {
    Name         = "lost-in-space-dev"
    environment  = var.environment
    product-area = var.product_area
  }
}

resource "aws_alb" "lost_in_space" {
  name                       = "lost-in-space-dev"
  security_groups            = [aws_security_group.lost_in_space.id]
  subnets                    = [aws_subnet.lost_in_space_public_subnet_zone_a.id, aws_subnet.lost_in_space_public_subnet_zone_b.id]
  enable_deletion_protection = true
  idle_timeout               = 300

  tags = {
    Name         = "lost-in-space-dev"
    environment  = var.environment
    product-area = var.product_area
  }
}

resource "aws_alb_target_group" "lost_in_space" {
  name     = "lost-in-space-alb-target"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.lost_in_space.id
  stickiness {
    type            = "lb_cookie"
    cookie_duration = 3600
  }
  health_check {
    path = "/"
    port = 80
  }
}

resource "aws_alb_listener" "listener_http" {
  load_balancer_arn = aws_alb.lost_in_space.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    target_group_arn = aws_alb_target_group.lost_in_space.arn
    type             = "forward"
  }
}

ก็จะมีการสร้าง VPC, Internet Gateway, Subnet, Route Table, Security Group, Application Load Balancer (ALB) ซึ่งของพวกนี้เราไม่จำเป็นต้อง deploy ใหม่ทุกครั้งครับ เรา define ไว้ได้เลย

ต่อไปมาดู app/main.tf กัน ซึ่งไฟล์นี้แหละ เราจะเอาไว้ทำ blue-green deployment

# app/main.tf

provider "aws" {
  access_key = var.access_key_id
  secret_key = var.secret_access_id
  region     = var.region
}

variable "access_key_id" {
  type        = string
  description = "AWS Access Key ID"
}

variable "secret_access_id" {
  type        = string
  description = "AWS Secret"
}

variable "region" {
  type        = string
  default     = "ap-southeast-1"
  description = "AWS Region"
}

variable "product_area" {
  type        = string
  default     = "lost-in-space"
  description = "Product Area"
}

variable "environment" {
  type        = string
  default     = "dev"
  description = "Product Environment"
}

variable "infrastructure_version" {
  type        = string
  description = "Infrastructure Version"
}

data "aws_alb_target_group" "lost_in_space" {
  name = "lost-in-space-alb-target"
}

data "aws_subnet" "selected" {
  filter {
    name   = "tag:Name"
    values = ["${var.product_area}-public-subnet-zone-a"]
  }
}

data "aws_security_group" "selected" {
  filter {
    name   = "tag:Name"
    values = ["lost-in-space-dev"]
  }
}

resource "aws_alb_target_group_attachment" "lost_in_space_target_group_attachment" {
  target_group_arn = data.aws_alb_target_group.lost_in_space.arn
  target_id        = aws_instance.lost_in_space.id
  port             = 80
}

resource "aws_instance" "lost_in_space" {
  associate_public_ip_address = true
  subnet_id                   = data.aws_subnet.selected.id
  security_groups             = [data.aws_security_group.selected.id]
  ami                         = "ami-09a4a9ce71ff3f20b"
  instance_type               = "t2.medium"
  key_name                    = "rocket-dev"
  user_data                   = file("bootstrap.sh")

  root_block_device {
    volume_type = "gp2"
    volume_size = 30
  }

  volume_tags = {
    Name         = "lost-in-space-dev"
    environment  = var.environment
    product-area = var.product_area
  }

  tags = {
    Name                  = "lost-in-space-dev"
    InfrastructureVersion = var.infrastructure_version
    environment           = var.environment
    product-area          = var.product_area
  }

  lifecycle {
    create_before_destroy = true
  }
}

ไฟล์ app/main.tf จะมีจุดสำคัญอยู่ 3 จุดที่เราจำเป็นต้องรู้คือ

  1. การใช้ Data Source สาเหตุที่เราต้องใช้เพราะว่าเราจำเป็นต้องรู้ว่าตอนที่เราจะ deploy เราจะเอา EC2 instance ของเราไปผูกกับ ALB target group อะไร Subnet อะไร และ Security Group อะไร ที่เราสร้างไว้ตอนแรกใน base/main.tf ซึ่งตัว Data Source นี่แหละ เหมือนให้เราสามารถไปดึงค่า AWS service ที่เราเคยสร้างเอาไว้แล้วมาใช้ในสคริปนี้ต่อได้
  2. การใช้ variable ที่ชื่อ InfrastructureVersion ซึ่งตรงนี้ จะเป็นจุดที่ผมใช้เพื่อให้ Terraform ตรวจจับการเปลี่ยนแปลงเพื่อที่มันจะสร้าง stack ใหม่ให้ผมได้ ซึ่งตรงนี้ก็มีหลากหลายเทคนิคนะครับ จะแก้ตัวไฟล์ app/main.tf เลย หรือจะมีอีกสคริปหนึ่งมา search & replace ค่าอะไรสักอย่างใน app/main.tf ก็ได้ ใครชอบแบบไหนก็เลือกเอาได้เลย
  3. จุดสำคัญที่สุดที่จะทำให้เราลด downtime เวลาที่ deploy ได้คือตรง lifecycle ครับ ในที่นี้ผมเซต create_before_destroy = true จะหมายความว่าให้สร้าง stack ใหม่ให้เสร็จก่อน แล้วค่อยลบ stack เก่า ซึ่งถ้าไม่ได้เซตตรงนี้ไว้ stack เก่าจะโดนลบทันทีที่เรากำลังสร้าง stack ใหม่ครับ แนะนำให้อ่าน Zero Downtime Updates with HashiCorp Terraform ต่อ เค้าจะบอกวิธีแก้ในกรณีที่ application ของเรายังไม่ได้รันขึ้นมาจริงๆ (รัน instance ขึ้นมาได้ ไม่ได้หมายความว่า application ของเราจะรันเสร็จ)

จบ! 😎

Posted on by:

zkan profile

Kan Ouivirach

@zkan

Passionate in software engineering, data engineering, and data science. ♥

Discussion

pic
Editor guide