Deploying Containerized Applications on Google Cloud Run and GKE
Google Cloud Platform offers powerful options for running containerized applications: Cloud Run for serverless containers and Google Kubernetes Engine (GKE) for full Kubernetes orchestration. In this guide, I'll walk you through both platforms, helping you choose the right solution and implement best practices.
Cloud Run vs GKE: Choosing the Right Platform
When to Use Cloud Run
Cloud Run is ideal for:
- Stateless HTTP services: APIs, web applications, webhooks
- Event-driven workloads: Processing Pub/Sub messages, Cloud Storage events
- Microservices: Independent services with variable traffic
- Rapid deployment: From code to production in minutes
- Cost optimization: Pay only for actual request processing time
When to Use GKE
GKE is better for:
- Complex applications: Multi-container pods, sidecars, init containers
- Stateful workloads: Databases, caches, persistent storage
- Custom networking: Service mesh, network policies, ingress controllers
- Long-running processes: Background jobs, streaming applications
- Full Kubernetes features: CRDs, operators, advanced scheduling
Cloud Run Implementation
Building and Deploying a Cloud Run Service
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application code
COPY . .
# Build application
RUN npm run build
# Production image
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
# Expose port (Cloud Run uses PORT env var)
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
CMD ["node", "dist/server.js"]
Cloud Run Service Configuration
# service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: my-api-service
annotations:
run.googleapis.com/ingress: all
run.googleapis.com/launch-stage: BETA
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/minScale: '1'
autoscaling.knative.dev/maxScale: '100'
run.googleapis.com/cpu-throttling: 'false'
run.googleapis.com/startup-cpu-boost: 'true'
run.googleapis.com/execution-environment: gen2
spec:
containerConcurrency: 80
timeoutSeconds: 300
serviceAccountName: my-service-account@project.iam.gserviceaccount.com
containers:
- name: api-container
image: gcr.io/project-id/my-api:latest
ports:
- name: http1
containerPort: 8080
env:
- name: NODE_ENV
value: production
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-url
key: latest
resources:
limits:
cpu: '2'
memory: 2Gi
startupProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 0
timeoutSeconds: 1
periodSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 0
timeoutSeconds: 1
periodSeconds: 10
failureThreshold: 3
Deployment with gcloud CLI
#!/bin/bash
# Build and push container image
gcloud builds submit --tag gcr.io/PROJECT_ID/my-api:latest
# Deploy to Cloud Run
gcloud run deploy my-api-service \
--image gcr.io/PROJECT_ID/my-api:latest \
--platform managed \
--region us-central1 \
--allow-unauthenticated \
--min-instances 1 \
--max-instances 100 \
--cpu 2 \
--memory 2Gi \
--timeout 300 \
--concurrency 80 \
--service-account my-service-account@PROJECT_ID.iam.gserviceaccount.com \
--set-env-vars NODE_ENV=production \
--set-secrets DATABASE_URL=database-url:latest \
--vpc-connector my-vpc-connector \
--vpc-egress all-traffic
# Get service URL
SERVICE_URL=$(gcloud run services describe my-api-service \
--platform managed \
--region us-central1 \
--format 'value(status.url)')
echo "Service deployed at: $SERVICE_URL"
Cloud Run with Cloud Build CI/CD
# cloudbuild.yaml
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'gcr.io/$PROJECT_ID/my-api:$COMMIT_SHA'
- '-t'
- 'gcr.io/$PROJECT_ID/my-api:latest'
- '.'
# Push the container image
- name: 'gcr.io/cloud-builders/docker'
args:
- 'push'
- '--all-tags'
- 'gcr.io/$PROJECT_ID/my-api'
# Run tests
- name: 'gcr.io/$PROJECT_ID/my-api:$COMMIT_SHA'
entrypoint: 'npm'
args: ['test']
# Security scan with Container Analysis
- name: 'gcr.io/cloud-builders/gcloud'
args:
- 'container'
- 'images'
- 'scan'
- 'gcr.io/$PROJECT_ID/my-api:$COMMIT_SHA'
# Deploy to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'my-api-service'
- '--image'
- 'gcr.io/$PROJECT_ID/my-api:$COMMIT_SHA'
- '--region'
- 'us-central1'
- '--platform'
- 'managed'
images:
- 'gcr.io/$PROJECT_ID/my-api:$COMMIT_SHA'
- 'gcr.io/$PROJECT_ID/my-api:latest'
options:
machineType: 'N1_HIGHCPU_8'
logging: CLOUD_LOGGING_ONLY
Google Kubernetes Engine (GKE) Implementation
GKE Cluster Creation with Terraform
# gke-cluster.tf
resource "google_container_cluster" "primary" {
name = "production-gke-cluster"
location = "us-central1"
# We can't create a cluster with no node pool defined, but we want to only use
# separately managed node pools. So we create the smallest possible default
# node pool and immediately delete it.
remove_default_node_pool = true
initial_node_count = 1
# GKE version
min_master_version = "1.28"
# Network configuration
network = google_compute_network.vpc.name
subnetwork = google_compute_subnetwork.subnet.name
# IP allocation for pods and services
ip_allocation_policy {
cluster_secondary_range_name = "pods"
services_secondary_range_name = "services"
}
# Workload Identity
workload_identity_config {
workload_pool = "${var.project_id}.svc.id.goog"
}
# Enable Autopilot features
addons_config {
http_load_balancing {
disabled = false
}
horizontal_pod_autoscaling {
disabled = false
}
network_policy_config {
disabled = false
}
gcp_filestore_csi_driver_config {
enabled = true
}
gcs_fuse_csi_driver_config {
enabled = true
}
}
# Binary Authorization
binary_authorization {
evaluation_mode = "PROJECT_SINGLETON_POLICY_ENFORCE"
}
# Maintenance window
maintenance_policy {
daily_maintenance_window {
start_time = "03:00"
}
}
# Logging and monitoring
logging_config {
enable_components = ["SYSTEM_COMPONENTS", "WORKLOADS"]
}
monitoring_config {
enable_components = ["SYSTEM_COMPONENTS", "WORKLOADS"]
managed_prometheus {
enabled = true
}
}
# Security
master_auth {
client_certificate_config {
issue_client_certificate = false
}
}
# Private cluster configuration
private_cluster_config {
enable_private_nodes = true
enable_private_endpoint = false
master_ipv4_cidr_block = "172.16.0.0/28"
}
# Master authorized networks
master_authorized_networks_config {
cidr_blocks {
cidr_block = "10.0.0.0/8"
display_name = "Internal"
}
}
}
# Node pool
resource "google_container_node_pool" "primary_nodes" {
name = "primary-node-pool"
location = "us-central1"
cluster = google_container_cluster.primary.name
node_count = 3
# Autoscaling
autoscaling {
min_node_count = 1
max_node_count = 10
}
# Management
management {
auto_repair = true
auto_upgrade = true
}
# Node configuration
node_config {
preemptible = false
machine_type = "e2-standard-4"
# Google recommends custom service accounts with minimal permissions
service_account = google_service_account.gke_nodes.email
oauth_scopes = [
"https://www.googleapis.com/auth/cloud-platform"
]
# Workload Identity
workload_metadata_config {
mode = "GKE_METADATA"
}
# Shielded instance config
shielded_instance_config {
enable_secure_boot = true
enable_integrity_monitoring = true
}
# Labels
labels = {
environment = "production"
managed-by = "terraform"
}
# Tags
tags = ["gke-node", "production"]
metadata = {
disable-legacy-endpoints = "true"
}
}
}
# Spot instance node pool for non-critical workloads
resource "google_container_node_pool" "spot_nodes" {
name = "spot-node-pool"
location = "us-central1"
cluster = google_container_cluster.primary.name
node_count = 0
autoscaling {
min_node_count = 0
max_node_count = 5
}
management {
auto_repair = true
auto_upgrade = true
}
node_config {
spot = true
machine_type = "e2-standard-4"
service_account = google_service_account.gke_nodes.email
oauth_scopes = [
"https://www.googleapis.com/auth/cloud-platform"
]
workload_metadata_config {
mode = "GKE_METADATA"
}
labels = {
environment = "production"
workload = "spot"
}
taint {
key = "spot"
value = "true"
effect = "NO_SCHEDULE"
}
}
}
Kubernetes Deployment Configuration
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: production
labels:
app: my-app
version: v1
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
version: v1
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
serviceAccountName: my-app-sa
# Security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: gcr.io/project-id/my-app:v1.0.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: NODE_ENV
value: production
- name: PORT
value: "8080"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-credentials
key: url
# Resource limits
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
# Probes
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 30
# Security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
capabilities:
drop:
- ALL
# Volume mounts
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/.cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
# Affinity rules
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- my-app
topologyKey: kubernetes.io/hostname
---
apiVersion: v1
kind: Service
metadata:
name: my-app
namespace: production
labels:
app: my-app
spec:
type: ClusterIP
selector:
app: my-app
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-app
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 4
periodSeconds: 30
selectPolicy: Max
Ingress with Google Cloud Load Balancer
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app-ingress
namespace: production
annotations:
kubernetes.io/ingress.class: "gce"
kubernetes.io/ingress.global-static-ip-name: "my-app-ip"
networking.gke.io/managed-certificates: "my-app-cert"
kubernetes.io/ingress.allow-http: "false"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /*
pathType: ImplementationSpecific
backend:
service:
name: my-app
port:
number: 80
---
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: my-app-cert
namespace: production
spec:
domains:
- api.example.com
Monitoring and Observability
Cloud Monitoring Integration
# prometheus-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: monitoring
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- 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: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
target_label: __address__
Key Takeaways
- Choose the Right Platform: Cloud Run for simplicity, GKE for complexity
- Security First: Use Workload Identity, Binary Authorization, and security contexts
- Cost Optimization: Leverage autoscaling, spot instances, and right-sizing
- Observability: Implement comprehensive monitoring and logging
- CI/CD Integration: Automate deployments with Cloud Build
- High Availability: Multi-zone deployments and proper health checks
Conclusion
Google Cloud Platform provides excellent options for containerized workloads. Cloud Run offers simplicity and cost-effectiveness for stateless services, while GKE provides full Kubernetes power for complex applications. Choose based on your requirements and scale confidently with GCP.
Want to learn more? Check out my posts on Kubernetes best practices and multi-cloud strategies!