Orchestrating Zero-Downtime Deployments with Kubernetes, GitOps, and PHP 8.2 on AWS ECS
AWS ECS Service Configuration for Zero-Downtime Deployments
Achieving zero-downtime deployments on AWS Elastic Container Service (ECS) hinges on a well-configured service definition. This involves leveraging ECS’s built-in deployment strategies, specifically the rolling update mechanism, and ensuring your application is designed to handle graceful shutdowns and health checks. We’ll focus on the `minimumHealthyPercent` and `maximumPercent` parameters, which are critical for controlling the deployment process.
A typical ECS service definition, expressed in JSON, would look something like this. Note the `deploymentConfiguration` block. For zero-downtime, `minimumHealthyPercent` is usually set to 100% of the desired count, meaning at least all existing tasks must remain healthy before new ones are registered. `maximumPercent` is often set to 200%, allowing for a temporary doubling of tasks during the deployment to ensure capacity and smooth transition.
ECS Service Definition Snippet
{
"serviceName": "my-php-app-service",
"cluster": "arn:aws:ecs:us-east-1:123456789012:cluster/my-ecs-cluster",
"taskDefinition": "my-php-app-task:3",
"desiredCount": 3,
"deploymentConfiguration": {
"minimumHealthyPercent": 100,
"maximumPercent": 200
},
"loadBalancers": [
{
"targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-app-tg/abcdef1234567890",
"containerName": "php-app-container",
"containerPort": 80
}
],
"healthCheckGracePeriodSeconds": 60,
"networkConfiguration": {
"awsvpcConfiguration": {
"subnets": [
"subnet-0123456789abcdef0",
"subnet-fedcba9876543210f"
],
"securityGroups": [
"sg-0123456789abcdef0"
],
"assignPublicIp": "DISABLED"
}
}
}
PHP 8.2 Application Design for Graceful Shutdowns
Your PHP application, running within its Docker container, must be designed to respond to termination signals gracefully. When ECS initiates a task stop, it sends a SIGTERM signal to the main process (your web server or PHP-FPM). Your application needs to catch this signal and initiate a controlled shutdown. This typically involves:
- Finishing in-flight requests: Allow active HTTP requests to complete.
- Stopping new request processing: Prevent new requests from being accepted.
- Draining connections: If using persistent connections (e.g., to databases or message queues), gracefully close them.
- Exiting cleanly: Terminate the process with an exit code of 0.
For applications using PHP-FPM, this is often handled by PHP-FPM itself if configured correctly. However, if you’re running a monolithic application server (like Swoole or RoadRunner), you’ll need to implement signal handling. Here’s a conceptual example using Swoole, which is a powerful coroutine-based concurrency framework for PHP.
Swoole Signal Handling Example
<?php
// Assuming this is your main application entry point for Swoole
use Swoole\Coroutine\Http\Server;
use Swoole\Coroutine\Scheduler;
// ... (other Swoole setup)
$http = new Server("0.0.0.0", 9501); // Or your configured port
$http->on('request', function ($request, $response) {
// Your application logic here
// ...
$response->end("Hello from Swoole!");
});
// Register signal handlers
// SIGTERM is sent by ECS to stop the task
Swoole\Process::signal(SIGTERM, function ($signo) use ($http) {
echo "Received SIGTERM. Initiating graceful shutdown...\n";
// Stop accepting new connections
$http->shutdown();
// Allow existing requests to finish (Swoole handles this to some extent)
// You might add custom logic here if needed, e.g., waiting for specific tasks
echo "Shutdown complete. Exiting.\n";
exit(0); // Exit cleanly
});
// SIGINT (Ctrl+C) for local development
Swoole\Process::signal(SIGINT, function ($signo) use ($http) {
echo "Received SIGINT. Shutting down...\n";
$http->shutdown();
exit(0);
});
echo "Swoole server started on http://0.0.0.0:9501\n";
$http->start();
?>
Crucially, ensure your Dockerfile’s `CMD` or `ENTRYPOINT` executes your PHP application in a way that it can receive signals. For example, if you’re using `docker-php-entrypoint` and `php-fpm`, ensure it’s not running in a sub-process that detaches from the main process.
GitOps Workflow with Argo CD
GitOps provides an automated and auditable way to manage deployments. We’ll use Argo CD, a popular GitOps continuous delivery tool for Kubernetes, but the principles apply to other tools like FluxCD. The core idea is that your Git repository is the single source of truth for your desired application state, including your ECS service definitions (or Kubernetes manifests if you were using EKS).
For ECS, a direct GitOps approach to managing the *service* itself is less common than with Kubernetes. Typically, GitOps is used to manage the Kubernetes cluster configuration. However, you can integrate GitOps into your CI pipeline to *trigger* ECS deployments. A more direct GitOps approach for ECS would involve managing the Task Definition and Service definitions via Infrastructure as Code (IaC) tools like Terraform or AWS CDK, and then using GitOps to manage the IaC code itself.
Let’s illustrate a common pattern where GitOps manages Kubernetes resources, and a CI pipeline orchestrates ECS deployments based on Git commits. If you were using EKS, Argo CD would directly manage your ECS Service (or Kubernetes Deployment) manifests.
Argo CD Application Manifest (Conceptual for EKS)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-php-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/your-gitops-repo.git
targetRevision: HEAD
path: k8s/my-php-app # Directory containing your Kubernetes manifests
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
In a pure ECS context without EKS, your CI pipeline (e.g., AWS CodePipeline, GitHub Actions, GitLab CI) would be the orchestrator. A commit to your application’s Git repository triggers a build. Upon successful build and testing, the CI pipeline updates the Docker image tag in your ECS Task Definition and then updates the ECS Service to use the new Task Definition revision. This update process inherently uses ECS’s rolling update strategy.
CI Pipeline for ECS Deployments (GitHub Actions Example)
This GitHub Actions workflow demonstrates a simplified CI/CD pipeline for deploying a PHP application to AWS ECS. It assumes you have a `Dockerfile` in your repository and your ECS cluster/service are already set up.
GitHub Actions Workflow (`.github/workflows/deploy.yml`)
name: Deploy to AWS ECS
on:
push:
branches:
- main # Deploy when pushing to the main branch
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production # Use GitHub Environments for secrets
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push Docker image
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: my-php-app-repo
IMAGE_TAG: ${{ github.sha }} # Use Git commit SHA as the image tag
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
- name: Create new Task Definition revision
id: task-def
run: |
aws ecs register-task-definition --cli-input-json file://task-definition.json \
--region us-east-1 \
--output json | jq -r '.taskDefinition.taskDefinitionArn' \
| sed 's/:revision\/[0-9]\+/:revision\/$(jq -r .revision task-definition.json)/' \
> task_definition_arn.txt
# This assumes task-definition.json is in your repo and you'll update the image URI dynamically
# A more robust approach would be to use a tool like 'sed' or 'jq' to update the image URI in task-definition.json
# For simplicity, let's assume task-definition.json has a placeholder like "image": "placeholder-image:latest"
# and we'll update it before registering.
# Example using jq to update the image URI:
jq --arg IMAGE_URI "${{ steps.build-image.outputs.image }}" '.containerDefinitions[0].image = $IMAGE_URI' task-definition.json > updated-task-definition.json
aws ecs register-task-definition --cli-input-json file://updated-task-definition.json \
--region us-east-1 \
--output json | jq -r '.taskDefinition.taskDefinitionArn' \
> task_definition_arn.txt
echo "::set-output name=task_definition_arn::$(cat task_definition_arn.txt)"
- name: Update ECS Service
run: |
aws ecs update-service --cluster my-ecs-cluster \
--service my-php-app-service \
--task-definition ${{ steps.task-def.outputs.task_definition_arn }} \
--region us-east-1
In this workflow:
- We check out the code.
- Configure AWS credentials using secrets stored in GitHub.
- Log in to Amazon ECR (Elastic Container Registry).
- Build the Docker image and tag it with the Git commit SHA.
- Push the image to ECR.
- Register a new revision of the ECS Task Definition, dynamically updating the container image URI to the newly built one. This step requires a `task-definition.json` file in your repository.
- Update the ECS Service to use the new Task Definition revision. ECS then automatically initiates a rolling deployment based on the `deploymentConfiguration` defined in the service.
Health Checks and Load Balancer Configuration
Robust health checks are paramount for zero-downtime deployments. Your application must expose a health check endpoint that the load balancer (ALB in this case) can query. This endpoint should return a 200 OK status code only when the application is fully ready to serve traffic. During a rolling update, ECS will only consider a new task healthy after it passes these health checks.
The `healthCheckGracePeriodSeconds` in the ECS service definition provides a buffer for new tasks to start and pass their health checks before ECS considers them unhealthy. This is distinct from the load balancer’s health check configuration.
Example ALB Target Group Health Check Configuration
This can be configured via the AWS console, AWS CLI, or IaC tools. Here’s a conceptual AWS CLI command:
aws elbv2 register-targets \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-app-tg/abcdef1234567890 \
--targets Id=i-0123456789abcdef0,Port=80 # Assuming your container port is 80
aws elbv2 modify-target-group-attributes \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/my-app-tg/abcdef1234567890 \
--attributes Key=healthCheckPath,Value=/health \
Key=healthCheckIntervalSeconds,Value=30 \
Key=healthCheckTimeoutSeconds,Value=5 \
Key=healthyThresholdCount,Value=3 \
Key=unhealthyThresholdCount,Value=2 \
Key=matcher,Value=200-299
Your PHP application should have an endpoint (e.g., `/health`) that checks its own internal state, database connectivity, and any other critical dependencies before returning a 200 OK. If any dependency is unhealthy, it should return a non-2xx status code.
PHP Health Check Endpoint Example
<?php
// Example for a simple PHP-FPM setup with a framework like Laravel or Symfony
// Or a custom script
header('Content-Type: application/json');
// Simulate checking a critical dependency, e.g., database connection
$isDbConnected = false;
try {
// Replace with your actual database connection logic
// $pdo = new PDO(...);
// $pdo->query('SELECT 1');
$isDbConnected = true; // Assume connected for this example
} catch (PDOException $e) {
$isDbConnected = false;
error_log("Database connection failed: " . $e->getMessage());
}
if ($isDbConnected) {
http_response_code(200);
echo json_encode(['status' => 'ok', 'message' => 'Application is healthy']);
} else {
http_response_code(503); // Service Unavailable
echo json_encode(['status' => 'error', 'message' => 'Database connection failed']);
}
?>
Monitoring and Rollback Strategy
Effective monitoring is crucial for detecting deployment issues early and enabling quick rollbacks. Key metrics to watch include:
- ECS Service Health: Monitor the number of running, pending, and stopped tasks.
- ALB Request Counts and Latency: Track overall traffic and response times.
- ALB Target Group Health Status: Ensure tasks are passing health checks.
- Application-level Metrics: Error rates, request durations, and custom business metrics.
- CloudWatch Logs: Centralized logging for debugging.
AWS provides CloudWatch Container Insights for ECS, which aggregates metrics and logs. Set up CloudWatch Alarms on critical metrics (e.g., high error rates, low healthy host count) to notify your team immediately.
Your rollback strategy should be as automated as possible. If a deployment fails (e.g., new tasks don’t pass health checks, or error rates spike), the CI pipeline should ideally trigger an automatic rollback. In the GitHub Actions example, this could involve a separate workflow or logic within the existing one that, upon detecting failure conditions via CloudWatch alarms or API checks, updates the ECS service to the previous stable Task Definition revision.
Manual Rollback Command (Example)
# First, list previous task definition revisions aws ecs list-task-definitions --family-prefix my-php-app-task --sort DESC --region us-east-1 # Assuming the previous stable revision is 'my-php-app-task:2' aws ecs update-service --cluster my-ecs-cluster \ --service my-php-app-service \ --task-definition my-php-app-task:2 \ --region us-east-1
Integrating this rollback logic into your CI/CD pipeline, perhaps triggered by external monitoring alerts, transforms a manual process into an automated safety net, ensuring minimal impact during deployment failures.