D4.2 Docker Compose Files με Jinja2 🎨

Γιατί Jinja2 για Compose Files;

┌─────────────────────────────────────────────────────────┐
│  Στατικό docker-compose.yml                             │
│  ❌ Hardcoded values                                    │
│  ❌ Ένα αρχείο για κάθε environment                     │
│  ❌ Passwords σε plaintext                              │
├─────────────────────────────────────────────────────────┤
│  docker-compose.yml.j2 (Jinja2 Template)                │
│  ✅ Dynamic values από Ansible vars                     │
│  ✅ Ένα template για όλα τα environments                │
│  ✅ Secrets από Ansible Vault                           │
│  ✅ Προσαρμογή βάσει facts (CPU, RAM)                   │
└─────────────────────────────────────────────────────────┘

Βασικό Compose Template

mkdir -p ~/ansible/roles/myapp/templates

cat > ~/ansible/roles/myapp/templates/docker-compose.yml.j2 << 'EOF'
{# docker-compose.yml.j2 #}
{# Managed by Ansible — DO NOT EDIT MANUALLY! #}
{# Generated: {{ ansible_facts['date_time']['iso8601'] }} #}
{# Host: {{ inventory_hostname }} #}

version: "3.8"

{# ── Υπολογισμός resources βάσει facts ──────── #}
{% set total_ram_mb = ansible_facts['memtotal_mb'] | int %}
{% set cpu_count    = ansible_facts['processor_vcpus'] | int %}

{# ── Memory allocations ───────────────────────── #}
{% set nginx_mem    = '64m'   %}
{% set app_mem      = [256, (total_ram_mb * 0.25) | int] | min ~ 'm' %}
{% set postgres_mem = [512, (total_ram_mb * 0.30) | int] | min ~ 'm' %}
{% set redis_mem    = [128, (total_ram_mb * 0.10) | int] | min ~ 'm' %}

services:

  # ══════════════════════════════════════════
  # NGINX — Reverse Proxy
  # ══════════════════════════════════════════
  nginx:
    image: {{ nginx_image | default('nginx:1.25.3-alpine') }}
    container_name: {{ app_name }}_nginx
    restart: unless-stopped
    ports:
      - "{{ nginx_host_port | default(80) }}:80"
      {% if nginx_ssl_enabled | default(false) %}
      - "{{ nginx_ssl_port | default(443) }}:443"
      {% endif %}
    volumes:
      - {{ app_config_dir }}/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - {{ app_logs_dir }}/nginx:/var/log/nginx
      {% if nginx_ssl_enabled | default(false) %}
      - {{ app_config_dir }}/ssl:/etc/nginx/ssl:ro
      {% endif %}
      - /etc/localtime:/etc/localtime:ro
    networks:
      - frontend
    depends_on:
      app:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: {{ nginx_mem }}
          cpus:   "0.25"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test:         ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/health"]
      interval:     30s
      timeout:      10s
      retries:      3
      start_period: 10s
    labels:
      app:       {{ app_name }}
      component: nginx
      env:       {{ app_env }}
      version:   "{{ app_version }}"

  # ══════════════════════════════════════════
  # APPLICATION
  # ══════════════════════════════════════════
  app:
    image: {{ app_image | default('nginx:1.25.3-alpine') }}
    container_name: {{ app_name }}_app
    restart: unless-stopped
    env_file:
      - {{ app_config_dir }}/.env
    volumes:
      - app_data:/app/data
      - {{ app_logs_dir }}/app:/app/logs
      - /etc/localtime:/etc/localtime:ro
    networks:
      - frontend
      - backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: {{ app_mem }}
          cpus:   "{{ [1.0, (cpu_count * 0.5)] | min }}"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test:         ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:{{ app_port | default(3000) }}/"]
      interval:     30s
      timeout:      10s
      retries:      3
      start_period: 30s
    labels:
      app:       {{ app_name }}
      component: app
      env:       {{ app_env }}
      version:   "{{ app_version }}"

  # ══════════════════════════════════════════
  # POSTGRESQL
  # ══════════════════════════════════════════
  postgres:
    image: {{ postgres_image | default('postgres:15.4-alpine') }}
    container_name: {{ app_name }}_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB:       {{ postgres_db }}
      POSTGRES_USER:     {{ postgres_user }}
      POSTGRES_PASSWORD: {{ vault_postgres_password }}
      PGDATA:            /var/lib/postgresql/data/pgdata
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - /etc/localtime:/etc/localtime:ro
    networks:
      - backend
    deploy:
      resources:
        limits:
          memory: {{ postgres_mem }}
          cpus:   "{{ [1.0, (cpu_count * 0.5)] | min }}"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test:         ["CMD-SHELL", "pg_isready -U {{ postgres_user }} -d {{ postgres_db }}"]
      interval:     10s
      timeout:      5s
      retries:      5
      start_period: 30s
    labels:
      app:       {{ app_name }}
      component: postgres
      env:       {{ app_env }}

  # ══════════════════════════════════════════
  # REDIS
  # ══════════════════════════════════════════
  redis:
    image: {{ redis_image | default('redis:7.2-alpine') }}
    container_name: {{ app_name }}_redis
    restart: unless-stopped
    command: >
      redis-server
      --maxmemory {{ redis_maxmem | default('128mb') }}
      --maxmemory-policy allkeys-lru
      --appendonly yes
      --requirepass {{ vault_redis_password }}
    volumes:
      - redis_data:/data
      - /etc/localtime:/etc/localtime:ro
    networks:
      - backend
    deploy:
      resources:
        limits:
          memory: {{ redis_mem }}
          cpus:   "0.25"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test:         ["CMD", "redis-cli", "--no-auth-warning", "-a", "{{ vault_redis_password }}", "ping"]
      interval:     10s
      timeout:      5s
      retries:      5
      start_period: 10s
    labels:
      app:       {{ app_name }}
      component: redis
      env:       {{ app_env }}

  # ══════════════════════════════════════════
  # MONITORING (μόνο αν enabled)
  # ══════════════════════════════════════════
  {% if monitoring_enabled | default(false) %}
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    container_name: {{ app_name }}_cadvisor
    restart: unless-stopped
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
    ports:
      - "{{ cadvisor_port | default(8080) }}:8080"
    networks:
      - frontend
    deploy:
      resources:
        limits:
          memory: 128m
          cpus:   "0.25"
  {% endif %}

# ══════════════════════════════════════════
# VOLUMES
# ══════════════════════════════════════════
volumes:
  postgres_data:
    name: {{ app_name }}_postgres_data
    labels:
      app: {{ app_name }}
      env: {{ app_env }}

  redis_data:
    name: {{ app_name }}_redis_data
    labels:
      app: {{ app_name }}
      env: {{ app_env }}

  app_data:
    name: {{ app_name }}_app_data
    labels:
      app: {{ app_name }}
      env: {{ app_env }}

# ══════════════════════════════════════════
# NETWORKS
# ══════════════════════════════════════════
networks:
  frontend:
    name: {{ app_name }}_frontend
    driver: bridge

  backend:
    name: {{ app_name }}_backend
    driver: bridge
    internal: true        {# ← χωρίς πρόσβαση στο internet #}
EOF

Environment File Template (.env.j2)

cat > ~/ansible/roles/myapp/templates/app.env.j2 << 'EOF'
{# app.env.j2 — Application Environment Variables #}
{# Managed by Ansible Vault — DO NOT EDIT MANUALLY! #}

# ── Application ───────────────────────────────
NODE_ENV={{ app_env }}
APP_VERSION={{ app_version }}
PORT={{ app_port | default(3000) }}
LOG_LEVEL={{ 'debug' if app_env != 'production' else 'info' }}

# ── Database ──────────────────────────────────
DB_HOST=postgres
DB_PORT={{ postgres_port | default(5432) }}
DB_NAME={{ postgres_db }}
DB_USER={{ postgres_user }}
DB_PASSWORD={{ vault_postgres_password }}
DATABASE_URL=postgresql://{{ postgres_user }}:{{ vault_postgres_password }}@postgres:{{ postgres_port | default(5432) }}/{{ postgres_db }}

# ── Redis ─────────────────────────────────────
REDIS_HOST=redis
REDIS_PORT={{ redis_port | default(6379) }}
REDIS_PASSWORD={{ vault_redis_password }}
REDIS_URL=redis://:{{ vault_redis_password }}@redis:{{ redis_port | default(6379) }}/0

# ── Security ──────────────────────────────────
SECRET_KEY={{ vault_app_secret_key }}
JWT_SECRET={{ vault_jwt_secret | default(vault_app_secret_key) }}

# ── Feature flags ─────────────────────────────
{% if app_env == 'production' %}
DEBUG=false
SENTRY_ENABLED=true
CACHE_ENABLED=true
{% else %}
DEBUG=true
SENTRY_ENABLED=false
CACHE_ENABLED=false
{% endif %}

# ── Server info ───────────────────────────────
SERVER_HOSTNAME={{ inventory_hostname }}
SERVER_IP={{ ansible_facts['default_ipv4']['address'] }}
EOF

Nginx Config Template

cat > ~/ansible/roles/myapp/templates/nginx_compose.conf.j2 << 'EOF'
{# nginx_compose.conf.j2 #}
{# Managed by Ansible — DO NOT EDIT! #}

user nginx;
worker_processes {{ ansible_facts['processor_vcpus'] | default(1) }};
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
    multi_accept on;
}

http {
    sendfile    on;
    tcp_nopush  on;
    keepalive_timeout 65;
    server_tokens off;

    include      /etc/nginx/mime.types;
    default_type application/octet-stream;

    # ── Logging ───────────────────────────────
    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

    access_log /var/log/nginx/access.log main;
    error_log  /var/log/nginx/error.log warn;

    # ── Gzip ──────────────────────────────────
    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/json
               application/javascript text/xml;

    # ── Upstream ──────────────────────────────
    upstream app_backend {
        least_conn;
        server app:{{ app_port | default(3000) }};
        keepalive 32;
    }

    server {
        listen 80;
        server_name {{ inventory_hostname }} _;

        client_max_body_size {{ nginx_client_max_body | default('10M') }};

        # ── Health check ──────────────────────
        location /health {
            access_log off;
            return 200 "OK\n";
            add_header Content-Type text/plain;
        }

        # ── Static files ──────────────────────
        {% if nginx_static_enabled | default(false) %}
        location /static/ {
            alias   /var/www/static/;
            expires 1y;
            add_header Cache-Control "public, immutable";
            gzip_static on;
        }
        {% endif %}

        # ── API rate limiting ─────────────────
        {% if nginx_rate_limit | default(true) %}
        limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://app_backend;
            proxy_set_header Host            $host;
            proxy_set_header X-Real-IP       $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
        {% endif %}

        # ── Application ───────────────────────
        location / {
            proxy_pass         http://app_backend;
            proxy_http_version 1.1;
            proxy_set_header   Upgrade    $http_upgrade;
            proxy_set_header   Connection "upgrade";
            proxy_set_header   Host       $host;
            proxy_set_header   X-Real-IP  $remote_addr;

            # ── Security headers ──────────────
            add_header X-Frame-Options        "SAMEORIGIN"    always;
            add_header X-Content-Type-Options "nosniff"       always;
            add_header X-XSS-Protection       "1; mode=block" always;

            proxy_connect_timeout 60s;
            proxy_read_timeout    60s;
        }
    }
}
EOF

Tasks που χρησιμοποιούν τα Templates

# roles/myapp/tasks/compose_deploy.yml
---
# ============================================================
# Docker Compose Deployment με Templates
# ============================================================

# ── Δημιουργία directories ────────────────────
- name: Create app directories
  ansible.builtin.file:
    path:  "{{ item }}"
    state: directory
    mode:  '0755'
  loop:
    - "{{ app_base_dir }}"
    - "{{ app_config_dir }}"
    - "{{ app_config_dir }}/nginx"
    - "{{ app_logs_dir }}"
    - "{{ app_logs_dir }}/nginx"
    - "{{ app_logs_dir }}/app"

# ── Render templates ──────────────────────────
- name: Render docker-compose.yml
  ansible.builtin.template:
    src:   docker-compose.yml.j2
    dest:  "{{ app_base_dir }}/docker-compose.yml"
    mode:  '0644'
    owner: root
  register: compose_file

- name: Render .env file
  ansible.builtin.template:
    src:   app.env.j2
    dest:  "{{ app_config_dir }}/.env"
    mode:  '0600'          # ← strict permissions για secrets!
    owner: root
  no_log: true             # ← κρύψε secrets από output
  register: env_file

- name: Render nginx config
  ansible.builtin.template:
    src:   nginx_compose.conf.j2
    dest:  "{{ app_config_dir }}/nginx/nginx.conf"
    mode:  '0644'
  register: nginx_config

# ── Validate compose file ─────────────────────
- name: Validate docker-compose.yml
  ansible.builtin.command:
    cmd:   docker compose -f {{ app_base_dir }}/docker-compose.yml config
    chdir: "{{ app_base_dir }}"
  register:     compose_validate
  changed_when: false

- name: Validation result
  ansible.builtin.debug:
    msg: " docker-compose.yml is valid"

# ── Deploy stack ──────────────────────────────
- name: Deploy Docker Compose stack
  community.docker.docker_compose_v2:
    project_src:  "{{ app_base_dir }}"
    project_name: "{{ app_name }}"
    state:        present
    pull:         "{{ compose_pull | default('missing') }}"
  register: compose_result
  no_log: true             # ← .env περιέχει secrets

- name: Compose deployment result
  ansible.builtin.debug:
    msg:
      - "Stack   : {{ app_name }}"
      - "Changed : {{ compose_result.changed }}"

# ── Wait for healthy ──────────────────────────
- name: Wait for stack to be healthy
  ansible.builtin.command:
    cmd: >
      docker compose
      -f {{ app_base_dir }}/docker-compose.yml
      -p {{ app_name }}
      ps --format json
    chdir: "{{ app_base_dir }}"
  register:     stack_status
  changed_when: false
  retries:      10
  delay:        10
  until: >
    stack_status.stdout | from_json |
    selectattr('Health', 'equalto', 'unhealthy') |
    list | length == 0
  ignore_errors: true

- name: Stack status
  ansible.builtin.debug:
    msg: "{{ stack_status.stdout_lines }}"

Override Files ανά Environment

# Production override
cat > ~/ansible/roles/myapp/templates/docker-compose.prod.yml.j2 << 'EOF'
{# Production overrides #}
version: "3.8"

services:
  app:
    deploy:
      resources:
        limits:
          memory: {{ app_memory_prod | default('1g') }}
          cpus:   "{{ app_cpus_prod  | default('2.0') }}"

  postgres:
    deploy:
      resources:
        limits:
          memory: {{ postgres_memory_prod | default('2g') }}
          cpus:   "1.0"

# Logging to external service in production
{% if log_aggregator_url is defined %}
x-logging: &default-logging
  driver: syslog
  options:
    syslog-address: "{{ log_aggregator_url }}"
    tag:            "{{ app_name }}"
{% endif %}
EOF

# Development override
cat > ~/ansible/roles/myapp/templates/docker-compose.dev.yml.j2 << 'EOF'
{# Development overrides #}
version: "3.8"

services:
  app:
    environment:
      DEBUG:     "true"
      LOG_LEVEL: debug
    volumes:
      - ./src:/app/src    # ← hot reload!

  postgres:
    ports:
      - "5432:5432"       # ← expose για local access

  redis:
    ports:
      - "6379:6379"       # ← expose για local access
EOF
# Χρήση override files
tasks:

  # ── Render override files ─────────────────
  - name: Render production override
    ansible.builtin.template:
      src:  docker-compose.prod.yml.j2
      dest: "{{ app_base_dir }}/docker-compose.prod.yml"
      mode: '0644'
    when: app_env == 'production'

  # ── Deploy με overrides ───────────────────
  - name: Deploy με production overrides
    community.docker.docker_compose_v2:
      project_src:  "{{ app_base_dir }}"
      project_name: "{{ app_name }}"
      files:
        - docker-compose.yml
        - "docker-compose.{{ app_env }}.yml"
      state: present
    when: app_env == 'production'

Diff Mode — Τι θα αλλάξει

# Δες τις αλλαγές στο compose file ΠΡΙΝ deploy
ansible-playbook playbooks/compose-deploy.yml \
    --check --diff \
    --limit nextcloud

# Output:
# TASK [Render docker-compose.yml]
# --- before: /opt/myapp/docker-compose.yml
# +++ after: /opt/myapp/docker-compose.yml
# @@ -10,7 +10,7 @@
#  services:
#    app:
# -    image: myapp:1.0.0
# +    image: myapp:1.1.0    ← αλλαγή version!

Σύνοψη D4.2

Compose Files με Jinja2
├── Template: docker-compose.yml.j2
│   ├── Dynamic values από Ansible vars
│   ├── Resource limits βάσει facts
│   ├── Conditional services (monitoring)
│   └── Environment-specific config
│
├── .env.j2 (secrets template):
│   ├── Vault variables
│   ├── no_log: true
│   └── mode: '0600'
│
├── Override files:
│   ├── docker-compose.prod.yml.j2
│   ├── docker-compose.dev.yml.j2
│   └── docker_compose_v2: files=[...]
│
├── Validation:
│   └── docker compose config (πριν deploy)
│
└── Workflow:
    template → render → validate → deploy