Orchestrating Zero-Downtime Deployments with Laravel, Docker Swarm, and AWS ECS: A Deep Dive into GitOps Workflows
Dockerizing the Laravel Application
The foundation of our zero-downtime deployment strategy is a robust Docker image for our Laravel application. This image must be self-contained, including the PHP runtime, necessary extensions, and the application code itself. We’ll leverage a multi-stage build to keep the final image lean.
First, let’s define our Dockerfile. This example assumes a standard PHP 8.2 FPM setup with common extensions. We’ll also include a lightweight web server like Nginx to serve static assets and proxy requests to PHP-FPM.
# Stage 1: Build the application
FROM composer:latest AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
FROM php:8.2-fpm AS php_builder
WORKDIR /app
COPY --from=composer /app/vendor ./vendor
COPY . .
# Install PHP extensions
RUN apt-get update && apt-get install -y \
libzip-dev \
unzip \
git \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libssl-dev \
libonig-dev \
libxml2-dev \
zip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo_mysql zip exif pcntl \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy vendor and application code
COPY --from=composer /app/vendor ./vendor
COPY . .
# Set permissions for storage and bootstrap/cache
RUN chown -R www-data:www-data storage bootstrap/cache && chmod -R 775 storage bootstrap/cache
# Expose port
EXPOSE 9000
# Command to run PHP-FPM
CMD ["php-fpm"]
# Stage 2: Production image with Nginx
FROM nginx:alpine AS nginx_builder
COPY --from=php_builder /app /var/www/html
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache supervisor
# Copy PHP-FPM binary and configuration
COPY --from=php_builder /usr/local/sbin/php-fpm /usr/local/sbin/php-fpm
COPY --from=php_builder /usr/local/etc/php-fpm.conf /usr/local/etc/php-fpm.conf
COPY --from=php_builder /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf
# Copy supervisor configuration
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Expose port
EXPOSE 80
# Start supervisor
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
We also need a minimal Nginx configuration to proxy requests to PHP-FPM and serve static assets. Create a docker/nginx/default.conf file:
server {
listen 80;
index index.php index.html index.htm;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
root /var/www/html/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php-fpm:9000; # Assuming php-fpm service is named 'php-fpm'
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.ht {
deny all;
}
# Serve static assets directly
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
}
}
And a docker/supervisor/supervisord.conf to manage both Nginx and PHP-FPM:
[supervisord] nodaemon=true user=root [program:nginx] command=/usr/sbin/nginx -g "daemon off;" autostart=true autorestart=true priority=10 stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:php-fpm] command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.conf autostart=true autorestart=true priority=20 stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0
Build the Docker image:
docker build -t your-dockerhub-username/your-laravel-app:latest .
Push the image to a registry (e.g., Docker Hub, AWS ECR). For this example, we’ll assume Docker Hub.
docker push your-dockerhub-username/your-laravel-app:latest
Setting up Docker Swarm for Orchestration
Docker Swarm provides a straightforward way to manage a cluster of Docker nodes. We’ll use it to deploy our Laravel application and manage rolling updates.
First, initialize a Swarm on your manager node:
docker swarm init --advertise-addr
This will output a command to join worker nodes. Execute this command on your worker nodes.
Next, we define our application services using a docker-compose.yml file. This file will describe our Laravel application service, a database service (e.g., MySQL), and potentially a Redis service.
version: '3.8'
services:
app:
image: your-dockerhub-username/your-laravel-app:latest
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
restart_policy:
condition: on-failure
ports:
- target: 80
published: 80
protocol: tcp
mode: ingress
environment:
APP_ENV: production
APP_DEBUG: false
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: laravel_db
DB_USERNAME: user
DB_PASSWORD: password
REDIS_HOST: redis
REDIS_PORT: 6379
networks:
- app-network
volumes:
- app-static-cache:/var/www/html/bootstrap/cache # For shared cache
- app-storage:/var/www/html/storage # For shared storage
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: laravel_db
MYSQL_USER: user
MYSQL_PASSWORD: password
volumes:
- db-data:/var/lib/mysql
networks:
- app-network
redis:
image: redis:alpine
networks:
- app-network
networks:
app-network:
driver: overlay
volumes:
db-data:
driver: local
app-static-cache:
driver: local
app-storage:
driver: local
Deploy the stack to your Swarm:
docker stack deploy -c docker-compose.yml my-laravel-app
This command will create a service named my-laravel-app_app, my-laravel-app_db, and my-laravel-app_redis. The deploy section in the docker-compose.yml is crucial for zero-downtime updates. It specifies that updates should happen one replica at a time (parallelism: 1) with a 10-second delay between them (delay: 10s), ensuring that at least one instance is always available.
Integrating with AWS ECS for Scalability and Managed Infrastructure
While Docker Swarm is excellent for on-premises or self-managed infrastructure, AWS Elastic Container Service (ECS) offers a fully managed container orchestration service. We can adapt our strategy to leverage ECS, particularly with the EC2 launch type for more control or Fargate for a serverless experience.
The core concept remains the same: a Docker image and a service definition. However, the deployment mechanism changes.
Prerequisites:
- An AWS account.
- AWS CLI configured with appropriate credentials.
- An ECR (Elastic Container Registry) repository to store your Docker images.
- A VPC with subnets and security groups configured for your ECS cluster.
1. Push Image to ECR:
# Authenticate Docker to your ECR registry aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin.dkr.ecr.us-east-1.amazonaws.com # Tag your image docker tag your-dockerhub-username/your-laravel-app:latest .dkr.ecr.us-east-1.amazonaws.com/your-laravel-app:latest # Push the image docker push .dkr.ecr.us-east-1.amazonaws.com/your-laravel-app:latest
2. Create an ECS Task Definition:
A task definition is a blueprint for your application. It specifies the Docker image(s) to use, CPU and memory requirements, environment variables, port mappings, and more.
{
"family": "laravel-app-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam:::role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "laravel-app",
"image": "<YOUR_AWS_ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/your-laravel-app:latest",
"portMappings": [
{
"containerPort": 80,
"hostPort": 80,
"protocol": "tcp"
}
],
"environment": [
{
"name": "APP_ENV",
"value": "production"
},
{
"name": "APP_DEBUG",
"value": "false"
},
{
"name": "DB_HOST",
"value": "your-rds-endpoint.region.rds.amazonaws.com"
},
{
"name": "DB_PORT",
"value": "3306"
},
{
"name": "DB_DATABASE",
"value": "laravel_db"
},
{
"name": "DB_USERNAME",
"value": "your_db_user"
},
{
"name": "DB_PASSWORD",
"value": "your_db_password"
},
{
"name": "REDIS_HOST",
"value": "your-elasticache-redis-endpoint.cache.amazonaws.com"
},
{
"name": "REDIS_PORT",
"value": "6379"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/laravel-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
Register the task definition:
aws ecs register-task-definition --cli-input-json file://task-definition.json
3. Create an ECS Service:
The ECS service maintains a specified number of instances of a task definition running in your cluster. It also handles rolling updates.
You can create a service using the AWS console or the AWS CLI. Here’s a conceptual CLI command (you’d typically use a CloudFormation template or Terraform for production):
aws ecs create-service \
--cluster your-ecs-cluster-name \
--service-name laravel-app-service \
--task-definition laravel-app-task:1 \
--desired-count 3 \
--load-balancer-type application \
--load-balancer-arn your-alb-arn \
--listener-arns your-alb-listener-arn \
--target-group-arns your-alb-target-group-arn \
--health-check-grace-period-seconds 60 \
--deployment-configuration "minimumHealthyPercent=50,maximumPercent=100" \
--network-configuration "awsvpcConfiguration={subnets=[subnet-xxxxxxxxxxxxxxxxx,subnet-yyyyyyyyyyyyyyyyy],securityGroups=[sg-zzzzzzzzzzzzzzzzz],assignPublicIp=ENABLED}"
Key parameters for zero-downtime with ECS:
desired-count: The number of tasks to run.deployment-configuration:minimumHealthyPercentandmaximumPercentcontrol the rolling update strategy. SettingminimumHealthyPercentto 50 ensures that at least half of your tasks are always running and healthy during an update.load-balancer-type,load-balancer-arn,listener-arns,target-group-arns: Integration with an Application Load Balancer (ALB) is essential. The ALB distributes traffic and can gracefully remove old tasks from rotation during updates.health-check-grace-period-seconds: Allows new tasks time to start up and pass health checks before being considered unhealthy.
Implementing GitOps Workflows
GitOps is a paradigm that uses Git as the single source of truth for declarative infrastructure and applications. Changes to infrastructure or application configuration are made via Git commits, which then trigger automated deployment pipelines.
Core Components:
- Git Repository: Stores your application code, Dockerfiles, Docker Compose/ECS task definitions, and infrastructure-as-code (IaC) configurations.
- CI/CD Pipeline: Triggered by Git commits. Builds Docker images, pushes them to a registry, and updates the orchestration platform (Swarm or ECS).
- Orchestration Platform: Docker Swarm or AWS ECS, which pulls the new image and performs the rolling update.
- Observability: Monitoring and logging to ensure deployments are successful and to quickly detect issues.
Workflow Example (using GitHub Actions for ECS):
1. Commit to Git: A developer pushes a new feature or bug fix to a specific branch (e.g., main or a release branch) in the Git repository.
2. CI Trigger: GitHub Actions (or your chosen CI/CD tool) detects the commit and triggers a workflow defined in .github/workflows/deploy.yml.
name: Deploy Laravel to ECS
on:
push:
branches:
- main # Or your production branch
jobs:
build-and-deploy:
runs-on: ubuntu-latest
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, tag, and push Docker image to ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: your-laravel-app
IMAGE_TAG: ${{ github.sha }}
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: Update ECS Service
env:
CLUSTER_NAME: your-ecs-cluster-name
SERVICE_NAME: laravel-app-service
TASK_DEFINITION_FAMILY: laravel-app-task
CONTAINER_NAME: laravel-app
IMAGE_URI: ${{ steps.build-and-push.outputs.image }}
run: |
# Register a new revision of the task definition
aws ecs register-task-definition --cli-input-json file://task-definition.json --region us-east-1 \
| jq -r '.taskDefinition.revision' > task_revision.txt
NEW_REVISION=$(cat task_revision.txt)
# Update the ECS service to use the new task definition revision
aws ecs update-service \
--cluster $CLUSTER_NAME \
--service $SERVICE_NAME \
--task-definition $TASK_DEFINITION_FAMILY:$NEW_REVISION \
--force-new-deployment \
--region us-east-1
echo "Updated ECS service $SERVICE_NAME to use task definition $TASK_DEFINITION_FAMILY:$NEW_REVISION"
Explanation of the GitHub Actions workflow:
- The workflow triggers on a push to the
mainbranch. - It checks out the code.
- Configures AWS credentials using secrets stored in GitHub.
- Logs into Amazon ECR.
- Builds the Docker image using the
Dockerfilein the repository, tags it with the Git commit SHA for traceability, and pushes it to ECR. - Registers a new revision of the ECS task definition, dynamically updating the image URI to the newly pushed image.
- Updates the ECS service to use this new task definition revision, triggering a rolling deployment managed by ECS. The
--force-new-deploymentflag ensures an update even if the task definition content hasn’t changed (e.g., only the image tag changed).
For Docker Swarm, the CI/CD pipeline would instead run docker stack deploy -c docker-compose.yml my-laravel-app after building and pushing the image. The docker-compose.yml would be updated to reference the new image tag (e.g., using environment variables or by directly modifying the file in the pipeline).
Zero-Downtime Deployment Strategies in Detail
The success of zero-downtime deployments hinges on a few key principles:
- Rolling Updates: Gradually replace old instances with new ones. Both Docker Swarm and ECS support this natively. The configuration of
update_configin Swarm anddeployment-configurationin ECS dictates the speed and safety of these updates. - Health Checks: The orchestrator must know when a new instance is ready to serve traffic. This is achieved through health checks defined in the load balancer (ALB for ECS, or a separate proxy/load balancer for Swarm) and potentially within the application itself (e.g., a
/healthendpoint). - Immutable Infrastructure: Treat containers as immutable. Never SSH into a running container to make changes. Instead, build a new image, deploy it, and if issues arise, roll back to a previous known-good image.
- Database Migrations: This is often the trickiest part. Migrations must be backward-compatible. Deploy the new application code that can handle both the old and new schema, run migrations, and then deploy the application version that relies on the new schema. Alternatively, use tools like Flyway or Phinx and ensure your deployment pipeline handles migration execution carefully, potentially before the new application version starts receiving traffic.
- Blue/Green Deployments: A more advanced strategy where you run two identical production environments (Blue and Green). You deploy the new version to the inactive environment (Green), test it thoroughly, and then switch traffic from Blue to Green. This offers instant rollback but requires double the infrastructure.
By combining Docker for containerization, Docker Swarm or AWS ECS for orchestration, and a GitOps workflow for automation and version control, you can build a robust, scalable, and highly available deployment pipeline for your Laravel applications.