D3.3 Πρακτικό Παράδειγμα — Multi-container Application 🚀

Τι θα φτιάξουμε

┌─────────────────────────────────────────────────────────┐
│  Multi-container Application Stack                      │
│                                                         │
│  Internet                                               │
│      │ :80                                              │
│      ▼                                                  │
│  ┌─────────┐    :3000    ┌─────────┐                    │
│  │  nginx  │────────────►│  myapp  │                    │
│  │ (proxy) │             │ (node)  │                    │
│  └─────────┘             └────┬────┘                    │
│                               │ :5432                   │
│                          ┌────▼────┐                    │
│                          │postgres │                    │
│                          │  (db)   │                    │
│                          └────┬────┘                    │
│                               │                         │
│                          ┌────▼────┐                    │
│                          │  redis  │                    │
│                          │ (cache) │                    │
│                          └─────────┘                    │
│                                                         │
│  Networks:                                              │
│  ├── frontend: nginx ↔ myapp                            │
│  └── backend:  myapp ↔ postgres ↔ redis                 │
└─────────────────────────────────────────────────────────┘

Δομή αρχείων

# Δημιουργία δομής
mkdir -p ~/ansible/roles/myapp/{tasks,templates,defaults,handlers,files}
mkdir -p ~/ansible/roles/myapp/files/html

tree ~/ansible/roles/myapp/
# roles/myapp/
# ├── defaults/
# │   └── main.yml
# ├── files/
# │   └── html/
# │       └── index.html
# ├── handlers/
# │   └── main.yml
# ├── tasks/
# │   ├── main.yml
# │   ├── networks.yml
# │   ├── volumes.yml
# │   ├── postgres.yml
# │   ├── redis.yml
# │   ├── app.yml
# │   └── nginx.yml
# └── templates/
#     ├── nginx.conf.j2
#     └── app.env.j2

defaults/main.yml

cat > ~/ansible/roles/myapp/defaults/main.yml << 'EOF'
---
# ============================================================
# MyApp Role Defaults
# ============================================================

# ── Application ───────────────────────────────
app_name:        myapp
app_version:     "1.0.0"
app_env:         production
app_port:        3000
app_replicas:    1

# ── Nginx ─────────────────────────────────────
nginx_image:     nginx:1.25.3-alpine
nginx_host_port: 80
nginx_ssl_port:  443

# ── PostgreSQL ────────────────────────────────
postgres_image:   postgres:15.4-alpine
postgres_db:      "{{ app_name }}_db"
postgres_user:    "{{ app_name }}_user"
postgres_port:    5432

# ── Redis ─────────────────────────────────────
redis_image:   redis:7.2-alpine
redis_port:    6379
redis_maxmem:  "128mb"

# ── Networks ──────────────────────────────────
frontend_network: "{{ app_name }}_frontend"
backend_network:  "{{ app_name }}_backend"

# ── Volumes ───────────────────────────────────
postgres_volume: "{{ app_name }}_postgres_data"
redis_volume:    "{{ app_name }}_redis_data"
app_volume:      "{{ app_name }}_app_data"

# ── Resource Limits ───────────────────────────
nginx_memory:    "64m"
nginx_cpus:      "0.25"
app_memory:      "256m"
app_cpus:        "0.5"
postgres_memory: "256m"
postgres_cpus:   "0.5"
redis_memory:    "128m"
redis_cpus:      "0.25"

# ── Paths ─────────────────────────────────────
app_base_dir:   "/opt/{{ app_name }}"
app_config_dir: "/opt/{{ app_name }}/config"
app_data_dir:   "/opt/{{ app_name }}/data"
app_logs_dir:   "/opt/{{ app_name }}/logs"
EOF

tasks/main.yml

cat > ~/ansible/roles/myapp/tasks/main.yml << 'EOF'
---
# ============================================================
# MyApp Role — Main Entry Point
# ============================================================

- name: Create app directories
  ansible.builtin.file:
    path:  "{{ item }}"
    state: directory
    mode:  '0755'
    owner: root
    group: docker
  loop:
    - "{{ app_base_dir }}"
    - "{{ app_config_dir }}"
    - "{{ app_data_dir }}"
    - "{{ app_logs_dir }}"
  tags: [setup, directories]

- name: Setup Docker networks
  ansible.builtin.import_tasks:
    file: networks.yml
  tags: [setup, networks]

- name: Setup Docker volumes
  ansible.builtin.import_tasks:
    file: volumes.yml
  tags: [setup, volumes]

- name: Deploy PostgreSQL
  ansible.builtin.import_tasks:
    file: postgres.yml
  tags: [deploy, postgres, database]

- name: Deploy Redis
  ansible.builtin.import_tasks:
    file: redis.yml
  tags: [deploy, redis, cache]

- name: Deploy Application
  ansible.builtin.import_tasks:
    file: app.yml
  tags: [deploy, app]

- name: Deploy Nginx
  ansible.builtin.import_tasks:
    file: nginx.yml
  tags: [deploy, nginx, proxy]
EOF

tasks/networks.yml

cat > ~/ansible/roles/myapp/tasks/networks.yml << 'EOF'
---
# ============================================================
# Docker Networks Setup
# ============================================================

- name: Create frontend network
  community.docker.docker_network:
    name:   "{{ frontend_network }}"
    state:  present
    driver: bridge
    ipam_config:
      - subnet: "172.20.0.0/24"

- name: Create backend network
  community.docker.docker_network:
    name:   "{{ backend_network }}"
    state:  present
    driver: bridge
    internal: true          # ← δεν έχει πρόσβαση στο internet!
    ipam_config:
      - subnet: "172.21.0.0/24"

- name: Verify networks
  ansible.builtin.command:
    cmd: "docker network ls --filter name={{ app_name }}"
  register:     network_list
  changed_when: false

- name: Network status
  ansible.builtin.debug:
    msg: "{{ network_list.stdout_lines }}"
EOF

tasks/volumes.yml

cat > ~/ansible/roles/myapp/tasks/volumes.yml << 'EOF'
---
# ============================================================
# Docker Volumes Setup
# ============================================================

- name: Create PostgreSQL volume
  community.docker.docker_volume:
    name:  "{{ postgres_volume }}"
    state: present
    labels:
      app:       "{{ app_name }}"
      component: postgres
      env:       "{{ app_env }}"

- name: Create Redis volume
  community.docker.docker_volume:
    name:  "{{ redis_volume }}"
    state: present
    labels:
      app:       "{{ app_name }}"
      component: redis

- name: Create App volume
  community.docker.docker_volume:
    name:  "{{ app_volume }}"
    state: present
    labels:
      app:       "{{ app_name }}"
      component: app

- name: Verify volumes
  ansible.builtin.command:
    cmd: "docker volume ls --filter name={{ app_name }}"
  register:     volume_list
  changed_when: false

- name: Volume status
  ansible.builtin.debug:
    msg: "{{ volume_list.stdout_lines }}"
EOF

tasks/postgres.yml

cat > ~/ansible/roles/myapp/tasks/postgres.yml << 'EOF'
---
# ============================================================
# PostgreSQL Container
# ============================================================

- name: Pull PostgreSQL image
  community.docker.docker_image:
    name:   postgres
    tag:    15.4-alpine
    source: pull

- name: Deploy PostgreSQL container
  community.docker.docker_container:
    name:            "{{ app_name }}_postgres"
    image:           "{{ postgres_image }}"
    state:           started
    restart_policy:  unless-stopped

    env:
      POSTGRES_DB:       "{{ postgres_db }}"
      POSTGRES_USER:     "{{ postgres_user }}"
      POSTGRES_PASSWORD: "{{ vault_postgres_password }}"
      PGDATA:            /var/lib/postgresql/data/pgdata
    no_log: true

    volumes:
      - "{{ postgres_volume }}:/var/lib/postgresql/data"
      - /etc/localtime:/etc/localtime:ro

    networks:
      - name:    "{{ backend_network }}"
        aliases:
          - postgres
          - db              # ← alias για εύκολη σύνδεση

    memory:   "{{ postgres_memory }}"
    cpus:     "{{ postgres_cpus }}"

    labels:
      app:       "{{ app_name }}"
      component: postgres
      env:       "{{ app_env }}"

    log_driver: json-file
    log_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

- name: Wait for PostgreSQL to be healthy
  community.docker.docker_container_info:
    name: "{{ app_name }}_postgres"
  register: pg_info
  until: >
    pg_info.container.State.Health.Status == 'healthy'
  retries: 12
  delay:   5

- name: PostgreSQL status
  ansible.builtin.debug:
    msg: "✅ PostgreSQL: {{ pg_info.container.State.Health.Status }}"
EOF

tasks/redis.yml

cat > ~/ansible/roles/myapp/tasks/redis.yml << 'EOF'
---
# ============================================================
# Redis Container
# ============================================================

- name: Pull Redis image
  community.docker.docker_image:
    name:   redis
    tag:    7.2-alpine
    source: pull

- name: Deploy Redis container
  community.docker.docker_container:
    name:           "{{ app_name }}_redis"
    image:          "{{ redis_image }}"
    state:          started
    restart_policy: unless-stopped

    command: >
      redis-server
      --maxmemory {{ redis_maxmem }}
      --maxmemory-policy allkeys-lru
      --appendonly yes
      --requirepass {{ vault_redis_password }}

    volumes:
      - "{{ redis_volume }}:/data"
      - /etc/localtime:/etc/localtime:ro

    networks:
      - name:    "{{ backend_network }}"
        aliases:
          - redis
          - cache           # ← alias

    memory: "{{ redis_memory }}"
    cpus:   "{{ redis_cpus }}"

    labels:
      app:       "{{ app_name }}"
      component: redis

    log_driver: json-file
    log_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
  no_log: true

- name: Wait for Redis to be healthy
  community.docker.docker_container_info:
    name: "{{ app_name }}_redis"
  register: redis_info
  until: >
    redis_info.container.State.Health.Status == 'healthy'
  retries: 6
  delay:   5

- name: Redis status
  ansible.builtin.debug:
    msg: "✅ Redis: {{ redis_info.container.State.Health.Status }}"
EOF

tasks/app.yml

cat > ~/ansible/roles/myapp/tasks/app.yml << 'EOF'
---
# ============================================================
# Application Container
# (Προσομοιώνουμε με nginx που επιστρέφει JSON)
# ============================================================

- name: Pull app image
  community.docker.docker_image:
    name:   nginx
    tag:    1.25.3-alpine
    source: pull

- name: Deploy app environment file
  ansible.builtin.template:
    src:   app.env.j2
    dest:  "{{ app_config_dir }}/.env"
    mode:  '0600'
    owner: root
  no_log: true

- name: Deploy application container
  community.docker.docker_container:
    name:           "{{ app_name }}_app"
    image:          "nginx:1.25.3-alpine"
    state:          started
    restart_policy: unless-stopped

    env_file:
      - "{{ app_config_dir }}/.env"

    volumes:
      - "{{ app_volume }}:/app/data"
      - "{{ app_config_dir }}/.env:/app/.env:ro"
      - /etc/localtime:/etc/localtime:ro

    networks:
      - name:    "{{ frontend_network }}"
        aliases:
          - app
          - "{{ app_name }}"
      - name:    "{{ backend_network }}"

    memory: "{{ app_memory }}"
    cpus:   "{{ app_cpus }}"

    labels:
      app:       "{{ app_name }}"
      component: app
      version:   "{{ app_version }}"
      env:       "{{ app_env }}"

    log_driver: json-file
    log_options:
      max-size: "10m"
      max-file: "3"

    healthcheck:
      test:         ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/"]
      interval:     30s
      timeout:      10s
      retries:      3
      start_period: 20s
  no_log: true

- name: Wait for app to be healthy
  community.docker.docker_container_info:
    name: "{{ app_name }}_app"
  register: app_info
  until: >
    app_info.container.State.Health.Status in ['healthy', 'starting']
  retries: 12
  delay:   5

- name: App status
  ansible.builtin.debug:
    msg: "✅ App: {{ app_info.container.State.Status }}"
EOF

tasks/nginx.yml

cat > ~/ansible/roles/myapp/tasks/nginx.yml << 'EOF'
---
# ============================================================
# Nginx Reverse Proxy Container
# ============================================================

- name: Create nginx config directory
  ansible.builtin.file:
    path:  "{{ app_config_dir }}/nginx"
    state: directory
    mode:  '0755'

- name: Deploy nginx configuration
  ansible.builtin.template:
    src:  nginx.conf.j2
    dest: "{{ app_config_dir }}/nginx/nginx.conf"
    mode: '0644'
  notify: Reload nginx container

- name: Pull nginx image
  community.docker.docker_image:
    name:   nginx
    tag:    1.25.3-alpine
    source: pull

- name: Deploy nginx reverse proxy container
  community.docker.docker_container:
    name:           "{{ app_name }}_nginx"
    image:          "{{ nginx_image }}"
    state:          started
    restart_policy: unless-stopped

    ports:
      - "{{ nginx_host_port }}:80"

    volumes:
      - "{{ app_config_dir }}/nginx/nginx.conf:/etc/nginx/nginx.conf:ro"
      - "{{ app_logs_dir }}:/var/log/nginx"
      - /etc/localtime:/etc/localtime:ro

    networks:
      - name: "{{ frontend_network }}"

    memory: "{{ nginx_memory }}"
    cpus:   "{{ nginx_cpus }}"

    labels:
      app:       "{{ app_name }}"
      component: nginx-proxy
      env:       "{{ app_env }}"

    log_driver: json-file
    log_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

- name: Wait for nginx to be healthy
  community.docker.docker_container_info:
    name: "{{ app_name }}_nginx"
  register: nginx_info
  until: >
    nginx_info.container.State.Health is not defined or
    nginx_info.container.State.Health.Status in ['healthy', 'starting']
  retries: 6
  delay:   5

- name: Nginx status
  ansible.builtin.debug:
    msg: "✅ Nginx proxy: {{ nginx_info.container.State.Status }}"
EOF

templates/nginx.conf.j2

cat > ~/ansible/roles/myapp/templates/nginx.conf.j2 << 'EOF'
{# Nginx Reverse Proxy Configuration #}
{# 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;
    tcp_nodelay on;
    keepalive_timeout 65;
    server_tokens off;

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

    # ── Logging ───────────────────────────────
    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log warn;

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

    # ── Rate limiting ─────────────────────────
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    # ── Upstream ──────────────────────────────
    upstream {{ app_name }}_backend {
        least_conn;
        server app:{{ app_port }};
        # ← 'app' = container name/alias στο frontend network
        keepalive 32;
    }

    # ── Health check endpoint ─────────────────
    server {
        listen 80;
        server_name {{ inventory_hostname }} _;

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

        # ── Static files ──────────────────────
        location /static/ {
            alias   /var/www/static/;
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # ── API endpoints ─────────────────────
        location /api/ {
            limit_req zone=api burst=20 nodelay;

            proxy_pass         http://{{ app_name }}_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;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout    60s;
            proxy_read_timeout    60s;
        }

        # ── Application ───────────────────────
        location / {
            proxy_pass         http://{{ app_name }}_backend;
            proxy_http_version 1.1;
            proxy_set_header   Host            $host;
            proxy_set_header   X-Real-IP       $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;

            # ── 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;
        }
    }
}
EOF

templates/app.env.j2

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

NODE_ENV={{ app_env }}
APP_VERSION={{ app_version }}
PORT={{ app_port }}

# Database
DB_HOST=db
DB_PORT={{ postgres_port }}
DB_NAME={{ postgres_db }}
DB_USER={{ postgres_user }}
DB_PASSWORD={{ vault_postgres_password }}
DATABASE_URL=postgresql://{{ postgres_user }}:{{ vault_postgres_password }}@db:{{ postgres_port }}/{{ postgres_db }}

# Redis
REDIS_HOST=cache
REDIS_PORT={{ redis_port }}
REDIS_PASSWORD={{ vault_redis_password }}
REDIS_URL=redis://:{{ vault_redis_password }}@cache:{{ redis_port }}

# App
SECRET_KEY={{ vault_app_secret_key }}
EOF

handlers/main.yml

cat > ~/ansible/roles/myapp/handlers/main.yml << 'EOF'
---
- name: Reload nginx container
  community.docker.docker_container:
    name:    "{{ app_name }}_nginx"
    state:   started
    restart: true
  ignore_errors: "{{ ansible_check_mode }}"

- name: Restart app container
  community.docker.docker_container:
    name:    "{{ app_name }}_app"
    state:   started
    restart: true
  ignore_errors: "{{ ansible_check_mode }}"
EOF

Vault Variables

# Δημιουργία vault secrets
cat > /tmp/app_vault.yml << 'EOF'
---
vault_postgres_password: "PgPass@2026#Secure!"
vault_redis_password:    "RedisPass@2026!"
vault_app_secret_key:    "AppSecret@2026#SuperLong!"
EOF

cp /tmp/app_vault.yml \
   ~/ansible/inventory/group_vars/all/vault.yml

ansible-vault encrypt \
    ~/ansible/inventory/group_vars/all/vault.yml

rm /tmp/app_vault.yml

Deployment Playbook

cat > ~/ansible/playbooks/deploy-multicontainer.yml << 'EOF'
---
# ============================================================
# Multi-container Application Deployment
# ============================================================

- name: Deploy Multi-container Application
  hosts: "{{ target | default('all_managed') }}"
  become: true
  gather_facts: true

  pre_tasks:

    - name: Pre-deployment info
      ansible.builtin.debug:
        msg:
          - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
          - "Multi-container Deployment"
          - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
          - "Host   : {{ inventory_hostname }}"
          - "App    : {{ app_name }} v{{ app_version }}"
          - "Env    : {{ app_env }}"
          - "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
      tags: always

    - name: Verify Docker is running
      ansible.builtin.service_facts:

    - name: Assert Docker is running
      ansible.builtin.assert:
        that:
          - ansible_facts.services['docker.service'].state == 'running'
        fail_msg: "❌ Docker is not running!"
      tags: always

  roles:
    - role: myapp
      vars:
        app_name:    mywebapp
        app_version: "1.0.0"
        app_env:     production

  post_tasks:

    - name: Verify all containers running
      ansible.builtin.command:
        cmd: "docker ps --filter name=mywebapp --format '{{ '{{' }}.Names{{ '}}' }}\t{{ '{{' }}.Status{{ '}}' }}'"
      register:     containers_status
      changed_when: false
      tags: verify

    - name: Show container status
      ansible.builtin.debug:
        msg: "{{ containers_status.stdout_lines }}"
      tags: verify

    - name: Verify HTTP response
      ansible.builtin.uri:
        url:         "http://localhost:80/health"
        status_code: 200
      register:      http_check
      retries:       5
      delay:         5
      ignore_errors: true
      tags: verify

    - name: Deployment Report
      ansible.builtin.debug:
        msg:
          - "╔══════════════════════════════════════╗"
          - "║  DEPLOYMENT COMPLETE! 🎉             ║"
          - "╠══════════════════════════════════════╣"
          - "║ App    : mywebapp v1.0.0             ║"
          - "║ Host   : {{ inventory_hostname }}"
          - "║ URL    : http://{{ ansible_facts['default_ipv4']['address'] }}"
          - "║ HTTP   : {{ '✅ OK' if not http_check.failed else '❌ Check manually' }}"
          - "╠══════════════════════════════════════╣"
          - "║ Containers:                          ║"
          - "{{ containers_status.stdout_lines | join('\n') }}"
          - "╚══════════════════════════════════════╝"
      tags: always
EOF

Εκτέλεση

# ── Syntax check ──────────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --syntax-check

# ── List tasks ────────────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --list-tasks

# ── Dry run ───────────────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --check \
    --limit nextcloud

# ── Full deployment ───────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --limit nextcloud \
    -v

# ── Μόνο database ─────────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --tags postgres \
    --limit nextcloud

# ── Update μόνο app ───────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --tags app \
    --limit nextcloud \
    -e "app_version=1.1.0"

# ── Verify deployment ─────────────────────────
ansible-playbook playbooks/deploy-multicontainer.yml \
    --tags verify \
    --limit nextcloud

Χρήσιμες Ad-hoc εντολές

# ── Status όλων των containers ────────────────
ansible nextcloud -m ansible.builtin.command \
    -a "docker ps --filter name=mywebapp" \
    --become

# ── Logs από postgres ─────────────────────────
ansible nextcloud -m ansible.builtin.command \
    -a "docker logs mywebapp_postgres --tail 20" \
    --become

# ── Logs από app ──────────────────────────────
ansible nextcloud -m ansible.builtin.command \
    -a "docker logs mywebapp_app --tail 20" \
    --become

# ── Resource usage ────────────────────────────
ansible nextcloud -m ansible.builtin.command \
    -a "docker stats --no-stream" \
    --become

# ── Network inspect ───────────────────────────
ansible nextcloud -m ansible.builtin.command \
    -a "docker network inspect mywebapp_backend" \
    --become

# ── Exec σε container ────────────────────────
ansible nextcloud -m ansible.builtin.command \
    -a "docker exec mywebapp_postgres pg_isready -U mywebapp_user" \
    --become

# ── HTTP test ─────────────────────────────────
ansible nextcloud -m ansible.builtin.uri \
    -a "url=http://localhost/health status_code=200"