Orchestrating Microservices with Kubernetes and Laravel: A Deep Dive into Service Discovery, CI/CD, and Observability
Service Discovery with Kubernetes and Laravel
When orchestrating microservices, especially those built with frameworks like Laravel, effective service discovery is paramount. Kubernetes, as our chosen orchestrator, provides robust mechanisms for this. Instead of hardcoding IP addresses or relying on external DNS, we leverage Kubernetes’ internal DNS and Service objects.
Consider a scenario with two Laravel microservices: `user-service` and `order-service`. The `order-service` needs to communicate with the `user-service` to fetch user details. In Kubernetes, we define a Service object for user-service. This Service object gets a stable DNS name within the cluster, typically in the format <service-name>.<namespace>.svc.cluster.local.
Defining the Kubernetes Service for user-service
Here’s a typical Kubernetes Service definition for our user-service:
apiVersion: v1
kind: Service
metadata:
name: user-service
namespace: production
spec:
selector:
app: user-service
ports:
- protocol: TCP
port: 80
targetPort: 8000 # The port your Laravel app listens on inside the container
type: ClusterIP
With this definition, any pod within the production namespace (or any other namespace if configured for cross-namespace access) can reach the user-service by simply using the hostname user-service (or the fully qualified domain name user-service.production.svc.cluster.local). Kubernetes’ internal DNS (like CoreDNS) resolves this name to the cluster IP of the user-service Service, which then load-balances requests to the healthy pods backing it.
Laravel Configuration for Service Discovery
Within your Laravel application (e.g., order-service), you’ll configure your HTTP client (like Guzzle) to use these Kubernetes service names. Environment variables are the idiomatic way to manage this configuration.
In your .env file (or better, through Kubernetes ConfigMaps or Secrets mounted as environment variables):
USER_SERVICE_HOST=user-service USER_SERVICE_PORT=80
And in your Laravel code, you’d construct the full URL:
use Illuminate\Support\Facades\Http;
$userServiceUrl = "http://{$_ENV['USER_SERVICE_HOST']}:{$_ENV['USER_SERVICE_PORT']}";
try {
$response = Http::get("{$userServiceUrl}/api/users/123");
$userData = $response->json();
// Process user data
} catch (\Illuminate\Http\Client\RequestException $e) {
// Handle connection errors or service unavailability
Log::error("Failed to connect to user service: " . $e->getMessage());
}
This approach decouples your services from the underlying infrastructure. If the IP addresses of the user-service pods change, or if you scale them up or down, the Service object and its DNS name remain constant, ensuring seamless communication.
CI/CD Pipeline for Laravel Microservices on Kubernetes
A robust CI/CD pipeline is critical for managing multiple microservices. For Laravel applications deployed on Kubernetes, we aim for automated builds, testing, containerization, and deployment.
Pipeline Stages and Tools
- Code Commit: Triggered by pushes to Git repositories (e.g., GitHub, GitLab).
- Build & Test: Run PHPUnit tests, static analysis (PHPStan, Psalm), and dependency checks.
- Docker Image Build: Create a Docker image for the Laravel application.
- Image Push: Push the Docker image to a container registry (e.g., Docker Hub, AWS ECR, Google GCR).
- Kubernetes Deployment: Update the Kubernetes Deployment object to use the new image.
We’ll use GitLab CI/CD as an example, but the principles apply to Jenkins, GitHub Actions, or CircleCI.
Example GitLab CI/CD Configuration (.gitlab-ci.yml)
variables:
DOCKER_REGISTRY: registry.gitlab.com
IMAGE_NAME: $CI_PROJECT_PATH
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
KUBE_NAMESPACE: production
stages:
- build
- test
- deploy
build_docker_image:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $DOCKER_REGISTRY
- docker build -t $DOCKER_REGISTRY/$IMAGE_NAME:$IMAGE_TAG .
- docker push $DOCKER_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
only:
- main
run_tests:
stage: test
image: php:8.2-cli # Or your specific PHP version
before_script:
- apt-get update && apt-get install -y git zip unzip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-interaction --prefer-dist
- cp .env.example .env
- php artisan key:generate
- php artisan cache:clear
script:
- php artisan test
only:
- main
deploy_to_kubernetes:
stage: deploy
image: google/cloud-sdk:latest # Or an image with kubectl configured
script:
- echo "Deploying $IMAGE_NAME:$IMAGE_TAG to Kubernetes..."
# Authenticate with your Kubernetes cluster (e.g., using gcloud or kubeconfig)
# Example for GKE:
# gcloud auth activate-service-account --key-file=$GCP_SERVICE_ACCOUNT_KEY
# gcloud container clusters get-credentials $GKE_CLUSTER_NAME --zone $GKE_CLUSTER_ZONE --project $GCP_PROJECT_ID
# Or if using kubeconfig:
# export KUBECONFIG=/path/to/your/kubeconfig
# Update the Kubernetes Deployment
- kubectl set image deployment/$CI_PROJECT_SLUG $CI_PROJECT_SLUG=$DOCKER_REGISTRY/$IMAGE_NAME:$IMAGE_TAG --namespace $KUBE_NAMESPACE
- kubectl rollout status deployment/$CI_PROJECT_SLUG --namespace $KUBE_NAMESPACE
environment:
name: production
url: https://your-app-url.com # Optional: Link to your deployed application
only:
- main
In this pipeline:
- The
build_docker_imagejob logs into the GitLab container registry, builds the Docker image using theDockerfilein the project root, and pushes it with a tag derived from the commit SHA. - The
run_testsjob uses a PHP Docker image, installs dependencies, sets up the Laravel environment, and executes PHPUnit tests. - The
deploy_to_kubernetesjob (which requires appropriate Kubernetes credentials configured in GitLab CI/CD variables) useskubectlto update the image of the existing Kubernetes Deployment. It then waits for the rollout to complete.
The CI_PROJECT_SLUG variable is often used for naming Kubernetes resources (like Deployments) to match the project name. Ensure your Kubernetes Deployment YAML uses this naming convention or adjust the script accordingly.
Kubernetes Deployment Manifest
A corresponding Kubernetes Deployment manifest would look something like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service # Matches CI_PROJECT_SLUG in the example
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: registry.gitlab.com/your-group/user-service:latest # This will be updated by CI/CD
ports:
- containerPort: 8000
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: db-credentials
key: host
# ... other environment variables for database, cache, etc.
# Service discovery variables would be injected here or via ConfigMap
- name: USER_SERVICE_HOST
value: "user-service" # For inter-service communication if this were a client service
- name: USER_SERVICE_PORT
value: "80"
# ... other configurations like resource limits, volumes, etc.
The CI/CD pipeline dynamically updates the image field in this Deployment. The name of the container within the pod (user-service in this example) should also match the name used in the kubectl set image command.
Observability: Logging, Metrics, and Tracing
For microservices, especially in a dynamic environment like Kubernetes, observability is not optional. It’s the key to understanding system behavior, debugging issues, and optimizing performance.
Centralized Logging with Fluentd/Fluent Bit and Elasticsearch/Loki
Laravel applications typically log to standard output (stdout) and standard error (stderr) when running in containers. Kubernetes can then collect these logs. A common pattern involves deploying a log forwarder like Fluentd or Fluent Bit as a DaemonSet on each Kubernetes node. These agents collect logs from all containers on the node and forward them to a centralized logging backend like Elasticsearch or Loki.
Laravel Logging Configuration (config/logging.php):
return [
// ... other log channels
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'slack'], // Example: single for file, slack for notifications
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => env('LOG_SINGLE_PATH', storage_path('logs/laravel.log')),
'level' => env('LOG_LEVEL', 'debug'),
],
// For containerized environments, logging to stdout/stderr is preferred.
// Laravel's default 'single' driver can be configured to log to stdout.
// Alternatively, use a custom driver or a library that directs logs to stdout.
// A common approach is to let the default 'stack' driver handle it,
// and ensure your Dockerfile's CMD/ENTRYPOINT writes to stdout/stderr.
// If you need structured logging, consider libraries like Monolog's JSON formatter.
],
// ...
];
Ensure your Dockerfile for the Laravel app doesn’t redirect logs to a file that isn’t accessible or collected by the node’s log agent. The default behavior of most PHP-FPM/Apache/Nginx containers is to output logs to stdout/stderr, which is ideal.
Kubernetes DaemonSet for Fluent Bit:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluent-bit
namespace: logging # Or kube-system
spec:
selector:
matchLabels:
app: fluent-bit
template:
metadata:
labels:
app: fluent-bit
spec:
containers:
- name: fluent-bit
image: fluent/fluent-bit:latest
ports:
- containerPort: 2020 # For HTTP input, if needed
volumeMounts:
- name: varlog
mountPath: /var/log
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
- name: fluent-bit-config
mountPath: /fluent-bit/etc
volumes:
- name: varlog
hostPath:
path: /var/log
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
- name: fluent-bit-config
configMap:
name: fluent-bit-config
The fluent-bit-config ConfigMap would contain the Fluent Bit configuration to parse logs and send them to Elasticsearch or Loki.
Metrics with Prometheus and Grafana
Prometheus is the de facto standard for metrics collection in Kubernetes. We can expose application-level metrics from Laravel and collect system-level metrics from pods and nodes.
Exposing Laravel Metrics:
For application-specific metrics, you can use libraries like prometheus-client-php. You’d create an endpoint in your Laravel app that exposes these metrics in Prometheus format.
use Prometheus\Render\RenderTextFormat;
use Prometheus\Storage\InMemory;
use Prometheus\CollectorRegistry;
use Illuminate\Support\Facades\Route;
// In a dedicated route file or service provider
Route::get('/metrics', function () {
$registry = new CollectorRegistry(new InMemory());
// Example: Register a counter for API requests
$counter = $registry->registerCounter(
'myapp', 'api_requests_total', 'Total number of API requests', ['method', 'endpoint']
);
$counter->incBy(1, ['GET', '/api/users']); // Increment when a user is fetched
// You can also expose metrics about queue jobs, database queries, etc.
$renderer = new RenderTextFormat();
return response($renderer->render($registry->getMetricFamilySamples()), 200, ['Content-Type' => $renderer->getMimeType()]);
});
Then, configure Prometheus to scrape this endpoint. This involves creating a ServiceMonitor or PodMonitor custom resource if you’re using the Prometheus Operator, or configuring Prometheus directly.
Prometheus Configuration Snippet (if not using Operator):
scrape_configs:
- job_name: 'laravel-app'
kubernetes_sd_configs:
- role: pod
relabel_configs:
# Only scrape pods with the 'app=user-service' label
- source_labels: [__meta_kubernetes_pod_label_app]
action: keep
regex: user-service
# Scrape the /metrics endpoint on port 80
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: (\d+)
replacement: $1
- source_labels: [__meta_kubernetes_pod_name]
action: replace
target_label: instance
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: namespace
- source_labels: [__meta_kubernetes_pod_label_app]
action: replace
target_label: app
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
You would add annotations to your Laravel pod’s metadata to enable Prometheus scraping:
# ... inside the pod template spec ...
spec:
containers:
- name: user-service
image: ...
ports:
- containerPort: 8000
# Add annotations for Prometheus
env:
- name: PROMETHEUS_SCRAPE_ENABLED
value: "true"
- name: PROMETHEUS_METRICS_PATH
value: "/metrics"
- name: PROMETHEUS_METRICS_PORT
value: "80" # The port Prometheus will scrape
# ...
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "80" # This port is for the metrics endpoint, not the app's main port
Grafana can then be configured to use Prometheus as a data source, allowing you to build dashboards visualizing these metrics.
Distributed Tracing with Jaeger/Tempo
For understanding request flows across multiple microservices, distributed tracing is essential. Tools like Jaeger or Grafana Tempo can be integrated.
Instrumenting Laravel:
You’ll need to instrument your Laravel application to generate and propagate trace spans. Libraries like OpenTelemetry or specific integrations for Jaeger/Zipkin can be used. This often involves:
- Setting up a tracing client in your Laravel app.
- Generating a trace ID and span ID for incoming requests.
- Propagating these IDs in outgoing requests (e.g., via HTTP headers).
- Recording spans for significant operations (e.g., database queries, external API calls, controller actions).
Consider using a package like open-telemetry/opentelemetry-php. You would typically configure this in a service provider:
use OpenTelemetry\API\Trace\TracerProviderInterface;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\SpanExporter\OtlpExporter;
use OpenTelemetry\Contrib\Jaeger\JaegerExporter; // Or Tempo exporter
// In your AppServiceProvider or a dedicated tracing service provider
public function register()
{
$this->app->singleton(TracerProviderInterface::class, function ($app) {
$exporter = new JaegerExporter('my-laravel-app', 'http://jaeger-collector.observability.svc.cluster.local:14268/api/traces');
// Or for Tempo:
// $exporter = new OtlpExporter('http://tempo-distributor.observability.svc.cluster.local:4317');
$tracerProvider = new TracerProvider(
new BatchSpanProcessor($exporter)
);
return $tracerProvider;
});
}
public function boot()
{
// Middleware to trace incoming requests
$this->app['router']->pushMiddlewareToExistingRoutes(TraceRequests::class);
}
You would then need to ensure the Jaeger collector or Tempo distributor is deployed within your Kubernetes cluster and accessible by your Laravel pods. The CI/CD pipeline should also ensure that the necessary tracing libraries are included in the Docker image.
By combining Kubernetes’ orchestration capabilities with Laravel’s development framework, and implementing robust service discovery, CI/CD, and observability patterns, you can build and manage complex microservice architectures effectively.