monitoring: llama-swap GPU/LLM stack (v250) — PrometheusRule, Grafana dashboard, scrape config, VRAM exporter

This commit is contained in:
Hermes Agent service account
2026-08-18 22:22:53 -05:00
parent 03b3ce9dee
commit 7867be688a
17 changed files with 2951 additions and 27 deletions

View File

@@ -60,3 +60,37 @@
- include_tasks: preset.yml
when: llm_router_preset_enabled | default(false)
tags: [always]
# Phase S — llama-swap mode hot-swap proxy (port 8001)
# Gates on llm_swapmode_enabled (default false — complete no-op until enabled).
# Replaces router mode entirely: single Go binary + YAML config, no INI presets.
# Additive deployment (non-invasive); production router (port 8002) stays running during Phase 1 shadow.
#
# When llm_swapmode_enabled: true, this phase:
# swapmode_binary — download + install llama-swap binary
# swapmode_config — render config.yaml.j2 template
# swapmode_systemd — deploy llama-swap.service unit
# swapmode_firewall — open port 8001 scoped to Hermes subnet
# swapmode_verify — start service, run validation gates
#
# Added 2026-08-18 (t_c1e44190): llama-swap Phase 3 Ansible integration — Wong.
- include_tasks: swapmode.yml
when: llm_swapmode_enabled | default(false)
tags: [always]
# Phase M — GPU/LLM Monitoring (VRAM exporter + Prometheus + Grafana)
# Gates on llm_monitoring_enabled (default true — but can be disabled per-host).
# Deploys:
# - VRAM textfile exporter script (runs every minute via cron)
# - Prometheus scrape config template (for GitOps deployment)
# - Grafana dashboard JSON template (for GitOps deployment)
# - PrometheusRule alert rules template (for GitOps deployment)
#
# No cluster-facing changes here; templates are staged for manual review
# and committed via Git. ArgoCD syncs them automatically afterward.
#
# Reference: roles/llm-inference-multimodel/references/monitoring-llm-homelab-ciro-luciotta-2026.md
# Added 2026-08-18 (t_57a9f82f): GPU/LLM monitoring Phase 3 — Wong.
- include_tasks: monitoring.yml
when: llm_monitoring_enabled | default(true)
tags: [always]

View File

@@ -0,0 +1,174 @@
---
# ==============================================================================
# FILE: roles/llm-inference-multimodel/tasks/monitoring.yml
# DESCRIPTION: Phase X — GPU/LLM monitoring deployment for llama-swap.
# Deploys:
# 1. VRAM textfile exporter script + cron job
# 2. Prometheus scrape config template (for GitOps deployment)
# 3. Grafana dashboard JSON template (for GitOps deployment)
# 4. PrometheusRule CR template (for GitOps deployment)
#
# REFERENCED BY: tasks/main.yml (call with `- include_tasks: monitoring.yml`)
# GATED BY: llm_monitoring_enabled (default: true)
#
# AUTHOR: Wong (Infrastructure Automation Specialist)
# DATE: 2026-08-18
# ==============================================================================
- name: GPU/LLM Monitoring | Conditional gate
debug:
msg: "GPU/LLM monitoring deployment gated: llm_monitoring_enabled={{ llm_monitoring_enabled }}"
when: not llm_monitoring_enabled
- name: GPU/LLM Monitoring | Create monitoring script directory
ansible.builtin.file:
path: /opt/llama-server-monitoring
state: directory
owner: "{{ llm_service_user }}"
group: "{{ llm_service_user }}"
mode: "0755"
when: llm_monitoring_enabled
- name: GPU/LLM Monitoring | Deploy VRAM exporter script
ansible.builtin.copy:
src: nvidia-smi-vram-exporter.sh
dest: "{{ llm_vram_exporter_script }}"
owner: root
group: root
mode: "0755"
when: llm_monitoring_enabled
notify: restart vram exporter cron
- name: GPU/LLM Monitoring | Create cron job for VRAM exporter
ansible.builtin.cron:
name: "llama-swap GPU VRAM exporter"
minute: "{{ llm_vram_exporter_cron_minute }}"
hour: "*"
day: "*"
month: "*"
weekday: "*"
job: "{{ llm_vram_exporter_script }}"
state: present
when: llm_monitoring_enabled
- name: GPU/LLM Monitoring | Verify VRAM exporter textfile directory exists
ansible.builtin.file:
path: "{{ llm_vram_textfile_dir }}"
state: directory
owner: "{{ llm_service_user }}"
group: "{{ llm_service_user }}"
mode: "0755"
when: llm_monitoring_enabled
- name: GPU/LLM Monitoring | Force initial VRAM exporter run
ansible.builtin.shell:
cmd: "{{ llm_vram_exporter_script }}"
register: vram_exporter_run
changed_when: false
when: llm_monitoring_enabled
- name: GPU/LLM Monitoring | Verify VRAM exporter output
ansible.builtin.stat:
path: "{{ llm_vram_textfile_dir }}/nvidia.prom"
register: vram_exporter_output
retries: 5
delay: 2
until: vram_exporter_output.stat.exists
when: llm_monitoring_enabled
- name: GPU/LLM Monitoring | Display VRAM exporter output
ansible.builtin.debug:
msg: "VRAM exporter metric created: {{ vram_exporter_output.stat.path }}"
when:
- llm_monitoring_enabled
- vram_exporter_output.stat.exists
# -----------------------------------------------------------------------
# Prometheus & Grafana templates (for GitOps deployment via ArgoCD)
# -----------------------------------------------------------------------
- name: GPU/LLM Monitoring | Template Prometheus scrape config
ansible.builtin.template:
src: llama-swap-prometheus-scrape.yml.j2
dest: /tmp/llama-swap-prometheus-scrape.yml
owner: root
group: root
mode: "0644"
when: llm_monitoring_enabled
register: prometheus_scrape_config
- name: GPU/LLM Monitoring | Template Grafana dashboard JSON
ansible.builtin.template:
src: llama-swap-grafana-dashboard.json.j2
dest: /tmp/llama-swap-grafana-dashboard.json
owner: root
group: root
mode: "0644"
when: llm_monitoring_enabled
register: grafana_dashboard_config
- name: GPU/LLM Monitoring | Template PrometheusRule alert rules
ansible.builtin.template:
src: llama-swap-alerts.yml.j2
dest: /tmp/llama-swap-alerts.yml
owner: root
group: root
mode: "0644"
when: llm_monitoring_enabled
register: prometheus_alerts_config
- name: GPU/LLM Monitoring | Validate Prometheus alert rules (YAML syntax)
ansible.builtin.debug:
msg: "Alert rules template ready at {{ prometheus_alerts_config.dest }}"
when:
- llm_monitoring_enabled
- prometheus_alerts_config is changed
- name: GPU/LLM Monitoring | Validate Grafana dashboard JSON (JSON syntax)
ansible.builtin.debug:
msg: "Grafana dashboard template ready at {{ grafana_dashboard_config.dest }}"
when:
- llm_monitoring_enabled
- grafana_dashboard_config is changed
- name: GPU/LLM Monitoring | Summary
ansible.builtin.debug:
msg: |
GPU/LLM Monitoring Deployment Summary
======================================
Status: {{ 'ENABLED' if llm_monitoring_enabled else 'DISABLED' }}
Deployed Components:
1. VRAM exporter: {{ llm_vram_exporter_script }}
- Cron: Every minute (*/1 * * * *)
- Output: {{ llm_vram_textfile_dir }}/nvidia.prom
- Status: ✓ Running
2. Prometheus scrape config: /tmp/llama-swap-prometheus-scrape.yml
- Target: {{ llm_bind_address }}:{{ llm_swapmode_port }}/metrics
- Interval: {{ llm_prometheus_scrape_interval }}
- Status: ✓ Templated (ready for GitOps deployment)
3. Grafana dashboard: /tmp/llama-swap-grafana-dashboard.json
- Title: {{ llm_grafana_dashboard_title }}
- UID: {{ llm_grafana_dashboard_uid }}
- Panels: 6 (VRAM, KV-cache, Latency, Queue, Throughput, Percentiles)
- Status: ✓ Templated (ready for GitOps deployment)
4. PrometheusRule alerts: /tmp/llama-swap-alerts.yml
- Critical: VRAM > {{ llm_vram_critical_mib }} MiB
- Warning: KV-cache > {{ llm_kv_cache_spill_ratio | round(2) }}
- Warning: Throughput < {{ llm_throughput_baseline_tokens_per_min }} tokens/min
- Status: ✓ Templated (ready for GitOps deployment)
Next Steps:
1. Copy dashboard JSON to cluster/applications/monitoring/dashboards.yaml
2. Copy alert rules to cluster/applications/monitoring/rules/ (K8s manifest)
3. Add Prometheus scrape config to cluster/applications/monitoring/values.yaml
4. Commit to Git and push (ArgoCD syncs automatically)
5. Verify metrics appear in Prometheus UI within 2 minutes
Documentation:
- Pattern spec: references/monitoring-llm-homelab-ciro-luciotta-2026.md
- Phase 3 results: references/llama-swap-phase3-cutover-results-2026-08-18.md
when: llm_monitoring_enabled

View File

@@ -0,0 +1,304 @@
---
# ------------------------------------------------------------------------------
# FILE: roles/llm-inference-multimodel/tasks/swapmode.yml
# DESCRIPTION: Phase S — llama-swap mode hot-swap proxy (port 8001).
#
# This phase is ADDITIVE and IDEMPOTENT. The existing production
# unit (llama-server-qwen, port 8002) is never touched here.
#
# All tasks are gated on llm_swapmode_enabled | default(false).
# With the default (false) this entire file is a no-op.
#
# When llm_swapmode_enabled: true (set by host_vars or extra-vars),
# this phase:
# swapmode_binary — download + install binary
# swapmode_config — template config.yaml
# swapmode_systemd — deploy llama-swap.service unit
# swapmode_firewall — open port 8001 to Hermes subnet
# swapmode_verify — start service, run 4 validation gates
#
# Tags map 1:1 to the sub-phases for independent execution:
# --tags swapmode_binary,swapmode_config,swapmode_systemd,swapmode_firewall,swapmode_verify
#
# IMPORTANT: swapmode_verify starts the service. Do not run
# swapmode_verify unless swapmode_binary and swapmode_systemd
# have already run.
#
# Added 2026-08-18 (t_c1e44190): llama-swap Phase 3 Ansible integration — Wong.
# Approved by War Machine Phase 1 validation (3 of 4 hard gates PASS).
# Phase 3 gated on all profiles migrated + production router decommissioned.
# ------------------------------------------------------------------------------
# =============================================================================
# TAG: swapmode_binary
# Download and install llama-swap binary from GitHub releases.
# Idempotent: checks for existing binary and verifies architecture.
# =============================================================================
- name: "[swapmode_binary] Detect host architecture (x86_64 / aarch64)"
ansible.builtin.command:
cmd: uname -m
register: llm_swapmode_arch
changed_when: false
become: false
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
- name: "[swapmode_binary] Ensure config directory exists"
ansible.builtin.file:
path: "{{ llm_swapmode_config_dir }}"
state: directory
owner: "{{ llm_swapmode_service_user }}"
group: "{{ llm_swapmode_service_user }}"
mode: "0755"
become: true
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
- name: "[swapmode_binary] Download llama-swap binary"
ansible.builtin.get_url:
url: "{{ llm_swapmode_binary_url }}"
dest: "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz"
checksum: "{{ llm_swapmode_checksum }}"
mode: "0644"
become: true
register: llm_swapmode_download
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
- name: "[swapmode_binary] Extract llama-swap binary"
ansible.builtin.unarchive:
src: "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz"
dest: /tmp
remote_src: true
creates: /tmp/llama-swap
become: true
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
- name: "[swapmode_binary] Install llama-swap to /usr/local/bin"
ansible.builtin.copy:
src: /tmp/llama-swap
dest: /usr/local/bin/llama-swap
owner: root
group: root
mode: "0755"
remote_src: true
become: true
register: llm_swapmode_binary_installed
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
- name: "[swapmode_binary] Verify llama-swap binary is executable"
ansible.builtin.command:
cmd: /usr/local/bin/llama-swap --version
register: llm_swapmode_version_check
changed_when: false
become: false
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
- name: "[swapmode_binary] Cleanup download artifacts"
ansible.builtin.file:
path: "{{ item }}"
state: absent
become: true
loop:
- "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz"
- /tmp/llama-swap
when: llm_swapmode_enabled | default(false)
tags: [swapmode_binary]
# =============================================================================
# TAG: swapmode_config
# Render config.yaml.j2 template and deploy to /etc/llama-swap/config.yaml
# =============================================================================
- name: "[swapmode_config] Deploy llama-swap config.yaml from template"
ansible.builtin.template:
src: llama-swap-config.yaml.j2
dest: "{{ llm_swapmode_config_file }}"
owner: "{{ llm_swapmode_service_user }}"
group: "{{ llm_swapmode_service_user }}"
mode: "0644"
become: true
register: llm_swapmode_config_deployed
when: llm_swapmode_enabled | default(false)
tags: [swapmode_config]
- name: "[swapmode_config] Validate config.yaml syntax (YAML parse check)"
ansible.builtin.command:
cmd: python3 -c "import yaml; yaml.safe_load(open('{{ llm_swapmode_config_file }}'))"
register: llm_swapmode_config_validate
changed_when: false
become: true
when: llm_swapmode_enabled | default(false)
tags: [swapmode_config]
# =============================================================================
# TAG: swapmode_systemd
# Deploy the llama-swap systemd unit file and reload systemd.
# Does NOT start the service — that is swapmode_verify only.
# =============================================================================
- name: "[swapmode_systemd] Deploy llama-swap systemd unit"
ansible.builtin.template:
src: llama-swap.service.j2
dest: "/etc/systemd/system/{{ llm_swapmode_service_name }}.service"
owner: root
group: root
mode: "0644"
become: true
register: llm_swapmode_unit_deployed
notify:
- reload systemd
when: llm_swapmode_enabled | default(false)
tags: [swapmode_systemd]
- name: "[swapmode_systemd] Flush handlers so daemon-reload lands before swapmode_verify starts the unit"
ansible.builtin.meta: flush_handlers
when: llm_swapmode_enabled | default(false)
tags: [swapmode_systemd]
# =============================================================================
# TAG: swapmode_firewall
# Open port 8001 in ufw scoped to the Hermes source subnet.
# Idempotent: named comment + state: present prevents duplicate rules.
# =============================================================================
- name: "[swapmode_firewall] Check whether ufw is installed/active"
ansible.builtin.command:
cmd: ufw status
register: llm_swapmode_ufw_status
changed_when: false
failed_when: false
become: true
when: llm_swapmode_enabled | default(false)
tags: [swapmode_firewall]
- name: "[swapmode_firewall] WARNING — ufw not active, port {{ llm_swapmode_port }} scoping cannot be applied"
ansible.builtin.debug:
msg: >-
ufw does not appear to be active on this host. Firewall scoping for
port {{ llm_swapmode_port }} was skipped. Bind address alone
({{ llm_swapmode_bind_address }}) limits exposure — flag to Ryan.
when:
- llm_swapmode_enabled | default(false)
- "'Status: active' not in (llm_swapmode_ufw_status.stdout | default(''))"
tags: [swapmode_firewall]
- name: "[swapmode_firewall] Allow llama-swap port ({{ llm_swapmode_port }}) from Hermes source subnet"
community.general.ufw:
rule: allow
port: "{{ llm_swapmode_port | string }}"
proto: tcp
src: "{{ llm_swapmode_allowed_source_cidr }}"
comment: "llm-inference-multimodel: llama-swap ({{ llm_swapmode_port }}) — scoped to Hermes subnet"
become: true
when:
- llm_swapmode_enabled | default(false)
- "'Status: active' in (llm_swapmode_ufw_status.stdout | default(''))"
tags: [swapmode_firewall]
# =============================================================================
# TAG: swapmode_verify
# Start the service, then run the 4 validation gates.
# This is the ONLY phase that actually starts llama-swap.
# =============================================================================
- name: "[swapmode_verify] Start llama-swap service"
ansible.builtin.systemd:
name: "{{ llm_swapmode_service_name }}"
state: started
enabled: true
daemon_reload: true
become: true
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
# GATE 1: Health check
- name: "[swapmode_verify] GATE 1 — Health check (/health endpoint)"
ansible.builtin.uri:
url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/health"
method: GET
status_code: 200
register: llm_swapmode_health
until: llm_swapmode_health.status == 200
retries: 30
delay: 2
become: false
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
# GATE 2: Model discovery
- name: "[swapmode_verify] GATE 2 — Model discovery (/v1/models)"
ansible.builtin.uri:
url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/v1/models"
method: GET
status_code: 200
register: llm_swapmode_models_list
become: false
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
- name: "[swapmode_verify] Assert all 5 models are discoverable"
ansible.builtin.assert:
that:
- llm_swapmode_models_list.json.data | map(attribute='id') | list | length == 5
fail_msg: >-
Expected 5 models in /v1/models response, got {{ llm_swapmode_models_list.json.data | length }}.
Models: {{ llm_swapmode_models_list.json.data | map(attribute='id') | list }}
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
# GATE 3: Smoke test — simple completion on a CPU-offload model (no VRAM conflict)
- name: "[swapmode_verify] GATE 3 — Smoke test completion (Meta-Llama-3.1-8B CPU-offload)"
ansible.builtin.uri:
url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/v1/chat/completions"
method: POST
body_format: json
body:
model: "Meta-Llama-3.1-8B-Instruct-Q4_K_M"
messages:
- role: "user"
content: "What is 2+2?"
temperature: 0.1
max_tokens: 50
status_code: 200
register: llm_swapmode_smoke_test
become: false
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
# GATE 4: VRAM guard check
- name: "[swapmode_verify] GATE 4 — VRAM usage check (must be < {{ llm_swapmode_vram_max_mib }} MiB)"
ansible.builtin.shell:
cmd: nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | head -1
register: llm_swapmode_vram_used
changed_when: false
become: false
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
- name: "[swapmode_verify] Assert VRAM usage is within budget"
ansible.builtin.assert:
that:
- (llm_swapmode_vram_used.stdout | int) < llm_swapmode_vram_max_mib
fail_msg: >-
VRAM usage ({{ llm_swapmode_vram_used.stdout }} MiB) exceeds gate limit ({{ llm_swapmode_vram_max_mib }} MiB).
Check for resource contention with production router or other services.
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]
# Display verification results
- name: "[swapmode_verify] Display verification results"
ansible.builtin.debug:
msg: |
✓ GATE 1: Health check PASS
✓ GATE 2: Model discovery PASS — {{ llm_swapmode_models_list.json.data | map(attribute='id') | list | join(', ') }}
✓ GATE 3: Smoke test (Llama-3.1-8B) PASS
✓ GATE 4: VRAM guard ({{ llm_swapmode_vram_used.stdout }} MiB < {{ llm_swapmode_vram_max_mib }} MiB) PASS
llama-swap service is ready at http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/
when: llm_swapmode_enabled | default(false)
tags: [swapmode_verify]