Idempotent vLLM OpenAI-compatible serving role, staged-first (does not
start/enable the systemd unit or touch production traffic by default).
Validated end-to-end against astro-orbiter in a brief shadow window
(llama-swap stopped ~5 min, per homelab-llm-inference skill's documented
shadow-validation pattern):
- /health 200, /v1/models returns Qwen2.5-32B-Instruct-AWQ,
/v1/completions live smoke test passes, clean journalctl
- 3 consecutive full-role runs confirmed changed=0 (idempotent)
- production restored: llama-swap active, /v1/embeddings against
nomic-embed-text-v1.5 confirmed still working (Hindsight retain path)
Deviates from the original spec's model choices (Qwen2.5-32B-Instruct /
Qwen3-8B-Instruct bf16) to use the official Qwen AWQ pre-quantized variants
instead -- vLLM does not do safe on-the-fly quantization on this host
(bitsandbytes OOM history) and unquantized bf16 32B does not fit 24GB VRAM.
Two real bugs found+fixed during first-start validation (systemd-only
repro, not visible via interactive SSH testing):
1. ninja not on systemd's minimal PATH -> vLLM torch.compile
FileNotFoundError. Fixed via explicit PATH env in the unit.
2. FlashInfer sampler JIT fails to compile on RTX 3090 (SM86) --
known upstream issue class (vLLM GH #23023, #44305). Fixed via
VLLM_USE_FLASHINFER_SAMPLER=0 (falls back to native sampler).
Also fixed a real idempotency bug: force-upgrading setuptools to latest
fought with vLLM's own setuptools<81.0.0 pin, causing an install/downgrade
flip-flop (changed:true) on every run.
vllm_service_enabled defaults to false -- a host reboot must not
auto-start vLLM and VRAM-collide with the still-live llama-swap production
service. Cutover (enabling + starting + migrating consumers) is an
explicit, separate step outside this role, gated on adding embedding-mode
support (--task embed) for nomic-embed-text-v1.5, which this role does
not yet implement (Hindsight retain still depends on llama-swap's
nomic-embed until that follow-up lands).
Role: roles/deploy-vllm/ (defaults/handlers/meta/tasks/templates/README)
Playbook: playbooks/day1_deploy_vllm.yml
118 lines
4.2 KiB
YAML
118 lines
4.2 KiB
YAML
# ------------------------------------------------------------------------------
|
|
# FILE: roles/deploy-vllm/tasks/verify.yml
|
|
# PHASE 5: Verification.
|
|
#
|
|
# Only runs when vllm_service_state == 'started' (main.yml gate) — staging a
|
|
# stopped service is a valid, intentional end state during the deploy-first-
|
|
# validate-before-cutover sequencing, and there is nothing to verify yet.
|
|
#
|
|
# Pitfall (homelab-llm-inference skill): vLLM torch.compile takes 4+ minutes
|
|
# AFTER weights load before /health returns 200. retries=30, delay=10 (5 min
|
|
# ceiling) — do not shrink this or health checks will false-negative on a
|
|
# perfectly healthy but still-warming-up service.
|
|
# ------------------------------------------------------------------------------
|
|
|
|
- name: Wait for each enabled model's systemd unit to be active
|
|
ansible.builtin.systemd:
|
|
name: "{{ 'vllm.service' if item.role == 'primary' else 'vllm-' + item.id + '.service' }}"
|
|
loop: "{{ vllm_enabled_models }}"
|
|
loop_control:
|
|
label: "{{ item.id }}"
|
|
register: vllm_unit_status
|
|
become: true
|
|
|
|
- name: Report systemd unit status
|
|
ansible.builtin.debug:
|
|
msg: "{{ item.item.id }}: {{ item.status.ActiveState }} ({{ item.status.SubState }})"
|
|
loop: "{{ vllm_unit_status.results }}"
|
|
loop_control:
|
|
label: "{{ item.item.id }}"
|
|
|
|
- name: Fail if any unit is not active
|
|
ansible.builtin.fail:
|
|
msg: "{{ item.item.id }} systemd unit is {{ item.status.ActiveState }}, expected active."
|
|
loop: "{{ vllm_unit_status.results }}"
|
|
loop_control:
|
|
label: "{{ item.item.id }}"
|
|
when: item.status.ActiveState != 'active'
|
|
|
|
- name: Poll /health until 200 (torch.compile warmup can take 4-5 minutes)
|
|
ansible.builtin.uri:
|
|
url: "http://127.0.0.1:{{ item.port }}/health"
|
|
status_code: 200
|
|
timeout: 15
|
|
loop: "{{ vllm_enabled_models }}"
|
|
loop_control:
|
|
label: "{{ item.id }}"
|
|
register: vllm_health_check
|
|
until: vllm_health_check is succeeded
|
|
retries: "{{ vllm_health_check_retries }}"
|
|
delay: "{{ vllm_health_check_delay }}"
|
|
|
|
- name: Query /v1/models on each enabled instance
|
|
ansible.builtin.uri:
|
|
url: "http://127.0.0.1:{{ item.port }}/v1/models"
|
|
headers:
|
|
Authorization: "Bearer {{ vllm_api_key_lookup.stdout }}"
|
|
return_content: true
|
|
loop: "{{ vllm_enabled_models }}"
|
|
loop_control:
|
|
label: "{{ item.id }}"
|
|
register: vllm_models_response
|
|
no_log: true
|
|
|
|
- name: Assert /v1/models returns the expected served model name
|
|
ansible.builtin.assert:
|
|
that:
|
|
- item.item.id in (item.content)
|
|
fail_msg: "/v1/models on port {{ item.item.port }} did not list expected model id {{ item.item.id }}"
|
|
success_msg: "/v1/models confirmed {{ item.item.id }} is served on port {{ item.item.port }}"
|
|
loop: "{{ vllm_models_response.results }}"
|
|
loop_control:
|
|
label: "{{ item.item.id }}"
|
|
|
|
- name: Run a live completion smoke test against each enabled instance
|
|
ansible.builtin.uri:
|
|
url: "http://127.0.0.1:{{ item.port }}/v1/completions"
|
|
method: POST
|
|
headers:
|
|
Authorization: "Bearer {{ vllm_api_key_lookup.stdout }}"
|
|
Content-Type: "application/json"
|
|
body_format: json
|
|
body:
|
|
model: "{{ item.id }}"
|
|
prompt: "The capital of France is"
|
|
max_tokens: 8
|
|
temperature: 0
|
|
timeout: 60
|
|
status_code: 200
|
|
loop: "{{ vllm_enabled_models }}"
|
|
loop_control:
|
|
label: "{{ item.id }}"
|
|
register: vllm_completion_test
|
|
no_log: true
|
|
|
|
- name: Report completion smoke test result
|
|
ansible.builtin.debug:
|
|
msg: "{{ item.item.id }}: HTTP {{ item.status }} — completion smoke test passed"
|
|
loop: "{{ vllm_completion_test.results }}"
|
|
loop_control:
|
|
label: "{{ item.item.id }}"
|
|
|
|
- name: Check journalctl for the primary unit is free of ERROR/Traceback since last start
|
|
ansible.builtin.shell: |
|
|
set -o pipefail
|
|
journalctl -u vllm.service --since "10 min ago" | grep -iE "error|traceback" | grep -v "no entries" || true
|
|
args:
|
|
executable: /bin/bash
|
|
register: vllm_journal_errors
|
|
changed_when: false
|
|
become: true
|
|
|
|
- name: Report journalctl scan result
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ 'journalctl clean — no error/traceback lines in the last 10 minutes'
|
|
if vllm_journal_errors.stdout | trim | length == 0
|
|
else 'WARNING — journalctl lines matched error/traceback: ' + vllm_journal_errors.stdout }}
|