Quay lại

Triển Khai Không Gián Đoạn với AWS CodeDeploy và Amazon ECS Chuyên mục Devops    2025-08-12    6 Lượt xem    5 Lượt thích    comment-3 Created with Sketch Beta. 0 Bình luận

Bạn đã bao giờ lo lắng về việc cập nhật ứng dụng mà không làm gián đoạn trải nghiệm người dùng chưa? Nếu câu trả lời là có, bạn đang đứng trước bài toán của việc triển khai không gián đoạn (zero-downtime deployment). Trong thế giới của AWS, một trong những giải pháp mạnh mẽ nhất cho bài toán này là sự kết hợp giữa AWS CodeDeployAmazon ECS (Elastic Container Service).

Hãy cùng tìm hiểu sâu hơn về công cụ này qua bài viết chi tiết dưới đây.


What: CodeDeploy là gì và vì sao cần nó?

AWS CodeDeploy là gì?

AWS CodeDeploy là một dịch vụ triển khai tự động giúp bạn tự động hóa việc triển khai ứng dụng lên nhiều loại dịch vụ AWS như Amazon EC2, AWS Lambda và đặc biệt là Amazon ECS. Điều khiến CodeDeploy trở nên đặc biệt là khả năng thực hiện các chiến lược triển khai phức tạp, chẳng hạn như Blue/Green deployment, giúp bạn cập nhật ứng dụng mà không cần phải tắt dịch vụ hiện tại.

Tại sao nên sử dụng CodeDeploy với ECS?

Khi bạn sử dụng ECS để chạy các container, việc cập nhật dịch vụ một cách an toàn là rất quan trọng. Thay vì đơn giản là thay thế các container cũ bằng container mới (một quá trình có thể gây gián đoạn), CodeDeploy cho phép bạn:

  • Triển khai không gián đoạn (Zero-downtime deployment): Cung cấp một bộ container mới (Green) song song với bộ cũ (Blue) và chỉ chuyển đổi lưu lượng truy cập khi bộ mới đã sẵn sàng.

  • Phục hồi tự động: Nếu có lỗi xảy ra trong quá trình triển khai, CodeDeploy có thể tự động rollback về phiên bản cũ một cách nhanh chóng và an toàn.

  • Kiểm soát linh hoạt: Bạn có thể tùy chỉnh các bước trong quá trình triển khai, chẳng hạn như chạy các kịch bản kiểm tra hoặc migrate cơ sở dữ liệu.

Tóm lại, CodeDeploy mang lại sự an toàn và tin cậy cho quá trình cập nhật ứng dụng của bạn trên ECS.


How: Cách thiết lập CodeDeploy với ECS

Để kết hợp CodeDeploy với ECS, bạn cần thực hiện một số bước cấu hình quan trọng.

Các Khái Niệm Quan Trọng

Trước khi đi sâu vào các bước, hãy nắm vững một số khái niệm chính:

  • ECS Service: Đây là thành phần chính trong ECS, chịu trách nhiệm duy trì số lượng và phiên bản của các task (container).

  • Target Group: Trong AWS Application Load Balancer (ALB), Target Group là nơi đăng ký các task (container) để nhận lưu lượng truy cập.

  • AppSpec: Đây là một file YAML hoặc JSON định nghĩa cách CodeDeploy thực hiện triển khai. Đây là "kịch bản" mà CodeDeploy sẽ làm theo.

  • Deployment Group: Trong CodeDeploy, Deployment Group là tập hợp các tài nguyên triển khai, bao gồm một ECS Service, một hoặc nhiều Target Group, và các hook vòng đời (lifecycle hooks).

  • Deployment: Một triển khai là một lần chạy của CodeDeploy để cập nhật ứng dụng.

Vòng Đời Triển Khai Blue/Green qua ALB

Đây là cách một triển khai Blue/Green hoạt động với ALB:

  1. Giai đoạn ban đầu (Blue): Lưu lượng truy cập được gửi đến các container cũ, được liên kết với một Target Group cũ (Target Group Blue).

  2. Khởi tạo (Green): CodeDeploy triển khai một bộ container mới (Green) với Task Definition mới và liên kết chúng với một Target Group mới (Target Group Green). Lưu lượng truy cập vẫn đang ở Target Group Blue.

  3. Kiểm tra và chuẩn bị: Đây là nơi các hook của CodeDeploy phát huy tác dụng. Bạn có thể chạy các kịch bản để kiểm tra các container mới, đảm bảo chúng đã sẵn sàng.

  4. Chuyển đổi lưu lượng: Sau khi các hook hoàn thành, CodeDeploy sẽ chuyển lưu lượng truy cập từ Target Group Blue sang Target Group Green.

  5. Kết thúc: Sau một khoảng thời gian nhất định, nếu không có lỗi nào được phát hiện, các container cũ (Blue) có thể được tắt.

Thiết Lập AppSpec và Hooks

Để điều khiển vòng đời này, bạn cần tạo một file appspec.yaml. Đây là một ví dụ:

version: 1.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: arn:aws:ecs:ap-northeast-1:<ACCOUNT_ID>:task-definition/my-app:20
        LoadBalancerInfo:
          ContainerName: "my-app-container"
          ContainerPort: 8000
Hooks:
  - BeforeAllowTraffic: "arn:aws:lambda:ap-northeast-1:<ACCOUNT_ID>:function:my-db-migrate-hook"
  - AfterAllowTraffic: "arn:aws:lambda:ap-northeast-1:<ACCOUNT_ID>:function:my-post-deployment-hook"

 

Giải Thích Từng Hook Vòng Đời (Lifecycle Hooks)

Khi triển khai ECS với CodeDeploy, các hook phải là các hàm AWS Lambda.

  1. BeforeInstall: (Ít dùng với ECS Blue/Green) Chạy trước khi CodeDeploy tạo các task mới. Bạn có thể sử dụng hook này để dọn dẹp hoặc chuẩn bị môi trường.

  2. AfterInstall: Chạy sau khi các task mới (Green) đã được tạo. Đây là thời điểm tuyệt vời để thực hiện các bài kiểm tra sức khỏe sâu (deep healthcheck) trên các container mới, đảm bảo chúng hoạt động đúng như mong đợi. Nếu một container không khởi động, quá trình triển khai sẽ bị hủy ngay lập tức.

  3. BeforeAllowTraffic: Đây là hook quan trọng nhất. Nó chạy ngay trước khi CodeDeploy chuyển lưu lượng truy cập sang các container mới. Đây là nơi bạn nên đặt các tác vụ migrate cơ sở dữ liệu.

    • Bạn sẽ viết một hàm Lambda để kích hoạt một task ECS độc lập.

    • Task này sẽ chạy các lệnh migrate và sau đó dừng lại.

    • Hàm Lambda sẽ chờ đợi task này hoàn thành và kiểm tra mã thoát (exit code).

    • Nếu task migrate thất bại, hàm Lambda sẽ trả về lỗi, và CodeDeploy sẽ không bao giờ chuyển lưu lượng truy cập.

  4. AfterAllowTraffic: Chạy sau khi toàn bộ lưu lượng truy cập đã được chuyển thành công sang các container mới. Đây là nơi lý tưởng để:

    • Gửi thông báo thành công tới team của bạn (qua Slack, Teams, email).

    • Thực hiện các tác vụ dọn dẹp, chẳng hạn như xóa các tài nguyên cũ hoặc nhóm Target Group cũ không còn cần thiết.

Các Khái Niệm Quan Trọng Khác

  • Rollback Tự Động: CodeDeploy có thể tự động rollback về phiên bản Blue nếu quá trình triển khai thất bại ở bất kỳ hook nào.

  • Traffic Shifting: Bạn có thể cấu hình cách lưu lượng được chuyển đổi. Tùy chọn CodeDeployDefault.ECSAllAtOnce chuyển ngay lập tức, trong khi các tùy chọn khác cho phép chuyển từ từ (Linear hoặc Canary).

Kết luận

Sự kết hợp giữa AWS CodeDeploy và Amazon ECS mang lại một giải pháp triển khai mạnh mẽ, linh hoạt và an toàn cho các ứng dụng container. Bằng cách tận dụng mô hình Blue/Green và các hook vòng đời linh hoạt, bạn có thể tự tin cập nhật ứng dụng của mình mà không cần lo lắng về việc gián đoạn dịch vụ. Đây là một nền tảng vững chắc để xây dựng các quy trình CI/CD hiệu quả và chuyên nghiệp.

AfterInstall

import boto3
import json

def lambda_handler(event, context):
    try:
        # Lấy thông tin về deployment từ sự kiện
        deployment_id = event['DeploymentId']
        lifecycle_event_hook_execution_id = event['LifecycleEventHookExecutionId']

        # Chạy các kiểm tra tự động hoặc tác vụ khác
        print(f"Bắt đầu hook after_install cho deployment: {deployment_id}")

        # Thực hiện các tác vụ cần thiết
        # ...

        # Nếu thành công, gửi phản hồi thành công đến CodeDeploy
        codedeploy_client = boto3.client('codedeploy')
        codedeploy_client.put_lifecycle_event_hook_execution_status(
            deploymentId=deployment_id,
            lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
            status='Succeeded'
        )
        
        return {
            'statusCode': 200,
            'body': json.dumps('Hook after_install hoàn tất thành công')
        }

    except Exception as e:
        print(f"Lỗi trong hook after_install: {e}")
        # Nếu thất bại, gửi phản hồi thất bại đến CodeDeploy
        codedeploy_client = boto3.client('codedeploy')
        codedeploy_client.put_lifecycle_event_hook_execution_status(
            deploymentId=deployment_id,
            lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
            status='Failed'
        )
        raise e

BeforeAllowTraffic

# lambda_migrate_hook.py
import os
import json
import time
import logging
import boto3
import urllib.request
import urllib.error

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ecs = boto3.client("ecs")
cd = boto3.client("codedeploy")

# ---- Config from environment (set these in Lambda)
CLUSTER = os.getenv("ECS_CLUSTER_NAME")                          # required
SERVICE = os.getenv("ECS_SERVICE_NAME")                         # required
MIGRATE_CONTAINER = os.getenv("ECS_CONTAINER_NAME")
MIGRATE_COMMANDS_RAW = os.getenv(
    "MIGRATE_COMMANDS",
    '["python manage.py migrate --no-input", "python manage.py migrate --database=analytics"]'
)
# optional: you can explicitly provide a taskDefinition (family:revision or ARN)
TASK_DEFINITION_OVERRIDE = os.getenv("TASK_DEFINITION")     # optional
# optional: provide explicit network config; if empty, will read from service's networkConfiguration
RUN_TASK_SUBNETS = os.getenv("RUN_TASK_SUBNETS", "")        # comma separated
RUN_TASK_SECURITY_GROUPS = os.getenv("RUN_TASK_SECURITY_GROUPS", "")  # comma separated
# optional role ARNs to pass to run_task (requires iam:PassRole)
RUN_TASK_TASK_ROLE_ARN = os.getenv("RUN_TASK_TASK_ROLE_ARN") or None
RUN_TASK_EXECUTION_ROLE_ARN = os.getenv("RUN_TASK_EXECUTION_ROLE_ARN") or None

PLATFORM_VERSION = os.getenv("RUN_TASK_PLATFORM_VERSION", "LATEST")
HEALTHCHECK_URL = os.getenv("HEALTHCHECK_URL", "http://liveapp-prod-alb-2046781318.ap-northeast-1.elb.amazonaws.com:8080/health")
HEALTHCHECK_RETRIES = int(os.getenv("HEALTHCHECK_RETRIES", "6"))
HEALTHCHECK_INTERVAL = int(os.getenv("HEALTHCHECK_INTERVAL_SECONDS", "5"))

HOOK_TIMEOUT = int(os.getenv("HOOK_TIMEOUT_SECONDS", "600"))    # total time Lambda will wait
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL_SECONDS", "5"))

# ---- Helpers
def parse_commands(raw):
    """Accept JSON array string or 'cmd1 && cmd2' style string."""
    try:
        val = json.loads(raw)
        if isinstance(val, list):
            return val
    except Exception:
        pass
    # fallback split by &&
    return [c.strip() for c in raw.split("&&") if c.strip()]

MIGRATE_COMMANDS = parse_commands(MIGRATE_COMMANDS_RAW)

def http_ok(url, timeout=5):
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            code = resp.getcode()
            logger.info("Healthcheck %s -> %s", url, code)
            return 200 <= code < 300
    except urllib.error.HTTPError as e:
        logger.warning("Healthcheck HTTPError: %s", e.code)
        return False
    except Exception as e:
        logger.warning("Healthcheck error: %s", e)
        return False

def get_service_description(cluster, service):
    resp = ecs.describe_services(cluster=cluster, services=[service])
    failures = resp.get("failures", [])
    if failures:
        raise RuntimeError(f"describe_services failures: {failures}")
    services = resp.get("services", [])
    if not services:
        raise RuntimeError(f"Service not found: {service}")
    return services[0]

def run_migrate_task(cluster, task_def_arn, container_name, commands, subnets, security_groups,
                     task_role_arn=None, execution_role_arn=None, platform_version="LATEST"):
    if not subnets or not security_groups:
        raise RuntimeError("subnets and security_groups must be provided for awsvpc run_task")

    subnet_list = [s.strip() for s in subnets.split(",") if s.strip()]
    sg_list = [s.strip() for s in security_groups.split(",") if s.strip()]

    # Build shell command to run all commands sequentially; `sh -c "<joined>"` ensures proper exit code
    joined = " && ".join(commands)
    override_command = ["sh", "-c", joined]

    overrides = {
        "containerOverrides": [
            {
                "name": container_name,
                "command": override_command
            }
        ]
    }

    # Optionally pass role ARNs in run_task overrides (iam:PassRole needed)
    if task_role_arn:
        overrides["taskRoleArn"] = task_role_arn
    if execution_role_arn:
        overrides["executionRoleArn"] = execution_role_arn

    params = dict(
        cluster=cluster,
        taskDefinition=task_def_arn,
        launchType="FARGATE",
        platformVersion=platform_version,
        networkConfiguration={
            "awsvpcConfiguration": {
                "subnets": subnet_list,
                "securityGroups": sg_list,
                "assignPublicIp": "DISABLED"
            }
        },
        overrides=overrides,
        count=1
    )

    logger.info("run_task params: cluster=%s taskDefinition=%s", cluster, task_def_arn)
    resp = ecs.run_task(**params)
    failures = resp.get("failures", [])
    if failures:
        raise RuntimeError(f"run_task failures: {failures}")
    tasks = resp.get("tasks", [])
    if not tasks:
        raise RuntimeError("No task started by run_task")
    task_arn = tasks[0]["taskArn"]
    logger.info("Started run_task: %s", task_arn)
    return task_arn

def wait_for_task_stop(cluster, task_arn, timeout_seconds, poll_interval):
    start = time.time()
    while True:
        resp = ecs.describe_tasks(cluster=cluster, tasks=[task_arn])
        tasks = resp.get("tasks", [])
        if not tasks:
            raise RuntimeError("Task not found in describe_tasks")
        t = tasks[0]
        last_status = t.get("lastStatus")
        logger.info("Task %s status: %s", task_arn, last_status)
        if last_status == "STOPPED":
            return t
        if time.time() - start > timeout_seconds:
            raise RuntimeError("Timeout waiting for run_task to stop")
        time.sleep(poll_interval)

def inspect_task_result(task_desc):
    containers = task_desc.get("containers", [])
    if not containers:
        return False, "No containers info in stopped task"
    for c in containers:
        exit_code = c.get("exitCode")
        reason = c.get("reason")
        name = c.get("name")
        logger.info("Container %s exitCode=%s reason=%s", name, exit_code, reason)
        if exit_code is None:
            return False, f"Container {name} has no exitCode (reason={reason})"
        if exit_code != 0:
            return False, f"Container {name} failed with exitCode={exit_code} reason={reason}"
    return True, "All containers exited 0"

# ---- Lambda handler
def lambda_handler(event, context):
    logger.info("Received event: %s", json.dumps(event))
    deployment_id = event.get("DeploymentId")
    hook_exec_id = event.get("LifecycleEventHookExecutionId")
    start_time = time.time()

    try:
        # 1) Determine taskDefinition to use (prefer override, else read from service)
        if TASK_DEFINITION_OVERRIDE:
            task_def = TASK_DEFINITION_OVERRIDE
            logger.info("Using TASK_DEFINITION override: %s", task_def)
        else:
            svc = get_service_description(CLUSTER, SERVICE)
            task_def = svc.get("taskDefinition")
            logger.info("Service %s uses taskDefinition %s", SERVICE, task_def)

        # 2) Determine network configuration: service -> networkConfiguration.awsvpcConfiguration OR fallback to env
        svc_network = svc.get("networkConfiguration", {}).get("awsvpcConfiguration", {})
        subnets = ",".join(svc_network.get("subnets", [])) if svc_network.get("subnets") else RUN_TASK_SUBNETS
        sgs = ",".join(svc_network.get("securityGroups", [])) if svc_network.get("securityGroups") else RUN_TASK_SECURITY_GROUPS

        if not subnets or not sgs:
            raise RuntimeError("Network configuration missing: provide RUN_TASK_SUBNETS and RUN_TASK_SECURITY_GROUPS or ensure service has awsvpcConfiguration")

        # 3) Run one-off migrate task
        task_arn = run_migrate_task(
            cluster=CLUSTER,
            task_def_arn=task_def,
            container_name=MIGRATE_CONTAINER,
            commands=MIGRATE_COMMANDS,
            subnets=subnets,
            security_groups=sgs,
            task_role_arn=RUN_TASK_TASK_ROLE_ARN,
            execution_role_arn=RUN_TASK_EXECUTION_ROLE_ARN,
            platform_version=PLATFORM_VERSION
        )

        # 4) Wait for task to STOP
        remaining_timeout = max(30, HOOK_TIMEOUT - (time.time() - start_time) - 30)
        task_desc = wait_for_task_stop(CLUSTER, task_arn, timeout_seconds=remaining_timeout, poll_interval=POLL_INTERVAL)

        # 5) Inspect exit codes
        ok, msg = inspect_task_result(task_desc)
        if not ok:
            raise RuntimeError("Migrate task failed: " + msg)
        logger.info("Migrate task succeeded: %s", msg)

        # 6) Healthcheck (if provided)
        if HEALTHCHECK_URL:
            success = False
            for attempt in range(HEALTHCHECK_RETRIES):
                if http_ok(HEALTHCHECK_URL):
                    success = True
                    break
                logger.info("Healthcheck attempt %d/%d failed; sleep %s sec", attempt+1, HEALTHCHECK_RETRIES, HEALTHCHECK_INTERVAL)
                time.sleep(HEALTHCHECK_INTERVAL)
            if not success:
                raise RuntimeError("Healthcheck failed after retries: " + str(HEALTHCHECK_URL))
            logger.info("Healthcheck OK")

        # 7) Report success to CodeDeploy
        cd.put_lifecycle_event_hook_execution_status(
            deploymentId=deployment_id,
            lifecycleEventHookExecutionId=hook_exec_id,
            status="Succeeded"
        )
        logger.info("Reported Succeeded to CodeDeploy for deployment %s", deployment_id)

    except Exception as e:
        logger.exception("Hook failed: %s", e)
        # Try to notify CodeDeploy (may already be timed out); ignore errors on this call
        try:
            cd.put_lifecycle_event_hook_execution_status(
                deploymentId=deployment_id,
                lifecycleEventHookExecutionId=hook_exec_id,
                status="Failed"
            )
        except Exception as inner:
            logger.warning("Failed to put lifecycle status (ignored): %s", inner)
        raise

AfterAllowTraffic

import os
import json
import urllib.request
import urllib.error
import logging
import botocore.exceptions
import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

codedeploy_client = boto3.client('codedeploy')

def send_teams_notification(message, theme_color="0076D7"):
    """Gửi thông báo tới Microsoft Teams."""
    
    teams_webhook_url = os.environ.get('TEAMS_WEBHOOK_URL')
    if not teams_webhook_url:
        logger.error("TEAMS_WEBHOOK_URL environment variable is not set.")
        return

    payload = {
        "@type": "MessageCard",
        "@context": "https://schema.org/extensions",
        "themeColor": theme_color,
        "summary": "AWS CodeDeploy Notification",
        "sections": [{
            "activityTitle": "AWS CodeDeploy Deployment Status",
            "text": message
        }]
    }

    data = json.dumps(payload).encode('utf-8')
    
    try:
        req = urllib.request.Request(
            teams_webhook_url,
            data=data,
            headers={'Content-Type': 'application/json'}
        )
        with urllib.request.urlopen(req) as response:
            logger.info(f"Teams notification sent. Status Code: {response.getcode()}")
    except urllib.error.HTTPError as e:
        logger.error(f"Failed to send Teams notification. HTTPError: {e.code} - {e.reason}")
    except Exception as e:
        logger.error(f"An error occurred: {e}")


def lambda_handler(event, context):
    logger.info("Received event: %s", json.dumps(event))
    
    deployment_id = event.get('DeploymentId')
    lifecycle_event_hook_execution_id = event.get('LifecycleEventHookExecutionId')
    
    message = "Deployment failed to start."
    theme_color = "ff0000"

    if not deployment_id:
        logger.error("Deployment ID not found in event.")
        send_teams_notification(message, theme_color)
        return {
            'statusCode': 400,
            'body': json.dumps('Deployment ID is missing.')
        }

    try:
        deployment_info = codedeploy_client.get_deployment(deploymentId=deployment_id)
        deployment = deployment_info.get('deploymentInfo')
        
        status = deployment.get('status', 'UNKNOWN')
        application_name = deployment.get('applicationName', 'N/A')
        deployment_group_name = deployment.get('deploymentGroupName', 'N/A')
        description = deployment.get('description', 'N/A')
        
        aws_region = os.environ.get('AWS_REGION', 'ap-northeast-1')
        aws_account_id = context.invoked_function_arn.split(":")[4]
        console_url = (
            f"https://{aws_region}.console.aws.amazon.com/codesuite/codedeploy/deployments/"
            f"{deployment_id}?region={aws_region}"
        )
        
        if status == 'SUCCEEDED':
            message = (
                f"✅ Deployment **`{deployment_id}`** for application `{application_name}` "
                f"to `{deployment_group_name}` has **succeeded**.\n\n"
                f"**Description**: {description}\n\n"
                f"**View details**: [AWS CodeDeploy Console]({console_url})"
            )
            theme_color = "228B22"
        elif status == 'FAILED':
            error_message = deployment.get('errorInformation', {}).get('message', 'No specific error message.')
            message = (
                f"❌ Deployment **`{deployment_id}`** for application `{application_name}` "
                f"to `{deployment_group_name}` has **failed**.\n\n"
                f"**Error**: {error_message}\n\n"
                f"**View details**: [AWS CodeDeploy Console]({console_url})"
            )
            theme_color = "ff0000"
        else:
            message = (
                f"Deployment **`{deployment_id}`** for application `{application_name}` "
                f"to `{deployment_group_name}` has changed status to **`{status}`**.\n\n"
                f"**Description**: {description}\n\n"
                f"**View details**: [AWS CodeDeploy Console]({console_url})"
            )
            theme_color = "FFA500"

        send_teams_notification(message, theme_color)
        
        if lifecycle_event_hook_execution_id:
            codedeploy_client.put_lifecycle_event_hook_execution_status(
                deploymentId=deployment_id,
                lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
                status="Succeeded"
            )
            
    except botocore.exceptions.ClientError as e:
        logger.error(f"AWS API Error: {e}")
        message = f"An AWS API error occurred while getting deployment info: {e.response['Error']['Message']}"
        send_teams_notification(message, "ff0000")
    except Exception as e:
        logger.error(f"An unexpected error occurred: {e}")
        message = f"An unexpected error occurred in the notification Lambda: {e}"
        send_teams_notification(message, "ff0000")

    return {
        'statusCode': 200,
        'body': json.dumps('Notification process completed!')
    }

Bình luận (0)

Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough
Michael Gough

Bài viết liên quan

Learning English Everyday