mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat(ochre): add the vLLM serving AMI and give the daemon a route to it (#740)
* feat(images): add a vllm-serving AMI as a stacked mkosi profile
LaunchServingVM resolves its AMI by the tags spinifex:managed-by=bedrock and
spinifex:bedrock-role=vllm-serving. No image carried either, so every Ensure
failed at resolveServingAMI. The only GPU image ships the NVIDIA driver and
Docker but no Python, no vLLM and no serving unit, so tagging it would have
moved the failure from admission to first boot.
Add a vllm profile that stacks onto gpu-nvidia and owns the whole guest side
of the launcher contract: the runtime, vllm-serve.service, the weights mount
and the tags. vLLM is baked into a uv-managed venv rather than pulled as a
container at boot — a runtime docker pull of vllm/vllm-openai is known to
stall this platform's write plane, and mkosi's unprivileged build has no
daemon to bake an image from anyway.
Also bring images/mkosi.profiles/ under make test-images. Its scripts have no
.sh suffix, so the existing name glob skipped exactly the files worth linting;
sources are found by shebang instead.
* fix(images): make the vllm postinst survive the build sandbox
Two failures found by actually running the build.
uv installs to /usr/local/bin, which mkosi's postinst PATH need not carry, so
`command -v uv` reported a good install as missing. Call it by absolute path,
and check it the same way.
`vllm --version` initialises device config and dies with "Failed to infer
device type" on a builder with no GPU, so it could never have verified
anything. Assert the pinned version through package metadata instead, which
also catches a version mismatch rather than merely a broken entrypoint.
* fix(images): pin the serving AMI datasource and mirror cloud-init to the console
A serving VM boots through LaunchSystemInstance, so it comes up dual-NIC with a
fw_cfg netcfg blob and takes its user-data solely from IMDS — the same shape as
the EKS control plane. That path carries a cloud-init datasource pin; the vllm
profile never got one, and the base config deliberately leaves datasource_list
unpinned, so the serving image was the only system VM booting without it.
Reuse the EKS pin rather than copying it. The 99- prefix is load-bearing:
read_conf_d() applies cloud.cfg.d in REVERSE and mergemanydict() is
first-source-wins, so a 90- name loses to cloud-init's own 90_dpkg.cfg ('_'
sorts above '-') and the pin is discarded in silence. The postinst asserts the
MERGED datasource_list via cloud-init's own read_conf_with_confd rather than
checking the file exists, because it existed at 90- too and did nothing there.
Also mirror every cloud-init stage to the serial console. A serving VM is
headless, and a guest that wedges before handover never surrenders
/var/log/cloud-init-output.log, so the console get-console-output reads is the
only channel an operator has.
The images_test.go case covers the two tags resolveServingAMI filters on; it
matches on those alone and never on name, so a typo hides the image entirely.
* fix(images): make the dhcpcd data-NIC lease genuinely one-shot
dhcp_oneshot() promised to configure the link and exit "leaving no daemon
to fight cloud-init", but the dhcpcd branch passed only -q, which is quiet
rather than one-shot. Every Ubuntu image takes that branch, so a dhcpcd
daemon survived on the data NIC holding the control socket.
cloud-init's Ec2 datasource then runs its own ephemeral dhcpcd on the same
NIC. Instead of spawning, dhcpcd forwards the invocation to the resident
daemon as a control command and exits 0, and --dumplease returns the
resident daemon's lease, so discovery looks successful. Cleanup then waits
for a pid file at /run/dhcpcd/<iface>-4.pid that no process ever wrote,
spinning its full 30000 x 10ms budget: init-local stalls for exactly 300s
on every system-instance boot.
Adding -1 exits once the interface is configured and -p keeps the address
and routes across that exit. Both are needed; -1 alone tears the link back
down. The test now models the real clients — the stub stays resident unless
given the flag that makes its client exit, and the dhcpcd branch is forced
by dropping udhcpc from PATH rather than left to whether the host happens
to have /usr/share/udhcpc/default.script.
* fix(images): serve without a CUDA toolkit and stop vllm-serve crash-looping
vLLM picks FlashInfer for top-k/top-p sampling, and FlashInfer JIT-compiles
that kernel on first use. The serving image ships the driver and the
container toolkit but no nvcc, so the sampler warmup died with "Could not
find nvcc and default cuda_home='/usr/local/cuda' doesn't exist" minutes
into a GPU cold start, after the model had already loaded.
VLLM_USE_FLASHINFER_SAMPLER=0 selects vLLM's native top-k/top-p sampler,
which is the same operation with no CUDA toolchain at runtime. Everything
else on the serving path is already precompiled in the wheels: the attention
backend resolves to FLASH_ATTN and CUDA graph capture completes, both
without nvcc. Baking FlashInfer's AOT modules is the alternative if the
fused kernel is ever wanted, and it keeps the toolchain in the builder
rather than the appliance.
The unit had Restart=on-failure with no bound, so an unfixable start failure
restarted forever and buried its own first traceback under identical copies
on the serial console — the only view an operator has of a headless guest.
StartLimitIntervalSec/StartLimitBurst make it report instead.
The postinst asserts the installed unit carries the sampler setting, for the
same reason the datasource pin is asserted there: the alternative is finding
out at runtime, on a GPU, five minutes in.
Also corrects the shared datasource pin's header, which still claimed it
installs as 90-spinifex-ec2-imds.cfg. It is installed at 99- by the vllm
profile precisely because a 90- name loses the reverse-sorted, first-source-
wins cloud.cfg.d merge to cloud-init's own 90_dpkg.cfg.
* fix(bedrock): raise the serving-endpoint readiness bound to 15 minutes
The 5 minute bound was chosen before any cold start had been measured, and
its own comment asked to be corrected once a model actually ran on wattle.
It now has: Llama 3.2 1B on an RTX A1000 reached "Application startup
complete" 257.6s after launch, leaving ~42s of margin on the smallest model
this platform can serve, with a cold torch.compile accounting for 26.3s of
it. A bigger card and a bigger model are unmeasured.
The two failure modes are not symmetric. Too low tears down an endpoint that
was about to come up and reports it as a failed launch; too high only makes
a genuinely stuck launch take longer to give up. So the bound is set well
clear of the single data point rather than close to it.
The CLI's own wait moves to 20 minutes to stay above it. The daemon starts
its clock when the VM is launched, up to a minute after the CLI starts its
own, so leaving both at 15 would expire the client first and report a bare
"timed out" in place of the daemon's abort, which carries the reason.
* fix: give the Ochre daemon a routed port in the Bedrock system VPC
The readiness probe and every InvokeModel call dial a serving VM's
system-VPC address from inside the daemon, but that subnet lives in OVN
on br-int and the host has no route to it, so a healthy VM never reached
READY.
Each daemon now owns an ENI in the system VPC's private subnet, bound to
an OVS internal port on br-int by iface-id, exactly as ovn-controller
binds a guest tap. The ENI address is installed at the subnet's prefix
length, which is what supplies the connected route. BaseURL is unchanged:
the address it already carried is now reachable.
Describe-or-create keyed on a per-node description, since ENIs carry no
tag filter, so a restart or a second endpoint reuses one address instead
of leaking one per launch. The port is ensured before anything else the
launch creates: a VM the daemon cannot dial holds a GPU for the whole
readiness window before unwinding.
This commit is contained in:
@@ -58,7 +58,10 @@ dhcp_oneshot() {
|
||||
if command -v udhcpc >/dev/null 2>&1 && [ -x /usr/share/udhcpc/default.script ]; then
|
||||
udhcpc -i "$iface" -f -q -n -t 8 -T 2 >/dev/null 2>&1
|
||||
elif command -v dhcpcd >/dev/null 2>&1; then
|
||||
dhcpcd -q -t 20 "$iface" >/dev/null 2>&1
|
||||
# -q is only quiet; -1 is what exits, and -p keeps the address on the
|
||||
# way out. A surviving daemon owns the control socket, so cloud-init's
|
||||
# own dhcpcd is forwarded to it and never writes the pid file it waits on.
|
||||
dhcpcd -q -1 -p -t 20 "$iface" >/dev/null 2>&1
|
||||
else
|
||||
echo "[mulga-mgmt-net] no DHCP client (udhcpc/dhcpcd) for $iface" >&2
|
||||
return 1
|
||||
|
||||
@@ -11,7 +11,15 @@ set -eu
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SCRIPT="${SCRIPT_DIR}/mulga-mgmt-net.sh"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
# Reaps the stub's simulated daemons too, so a failing run does not strand
|
||||
# background processes on the test host.
|
||||
cleanup() {
|
||||
if [ -s "${WORK}/daemons" ]; then
|
||||
while read -r pid _; do kill "${pid}" 2>/dev/null || true; done < "${WORK}/daemons"
|
||||
fi
|
||||
rm -rf "${WORK}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
STUBBIN="${WORK}/bin"
|
||||
mkdir -p "${STUBBIN}"
|
||||
@@ -86,6 +94,26 @@ for a in "$@"; do
|
||||
prev="${a}"
|
||||
done
|
||||
[ -n "${iface}" ] || { for a in "$@"; do iface="${a}"; done; }
|
||||
|
||||
# Both clients keep running unless told to exit, and the flag that does it is
|
||||
# per-client: dhcpcd needs -1 (-q is only quiet), udhcpc needs -q. Leaving a
|
||||
# live process behind is what the real client does, so the stub does it too.
|
||||
oneshot=no
|
||||
case "$(basename "$0")" in
|
||||
dhcpcd) case " $* " in *" -1 "*) oneshot=yes ;; esac ;;
|
||||
udhcpc) case " $* " in *" -q "*) oneshot=yes ;; esac ;;
|
||||
esac
|
||||
if [ "${oneshot}" = no ]; then
|
||||
sleep 30 &
|
||||
echo "$! $(basename "$0")" >> "${DAEMONS}"
|
||||
fi
|
||||
|
||||
# dhcpcd de-configures the interface on exit unless -p, so a one-shot without
|
||||
# it hands back a link with no address at all.
|
||||
if [ "$(basename "$0")" = dhcpcd ] && [ "${oneshot}" = yes ]; then
|
||||
case " $* " in *" -p "*) ;; *) exit 0 ;; esac
|
||||
fi
|
||||
|
||||
echo "${iface}" >> "${LEASED}"
|
||||
# What a real lease installs, and the reason the policy exists at all: OVN serves
|
||||
# the subnet's router IP as the gateway, which is what the return path needs.
|
||||
@@ -157,10 +185,11 @@ DEFAULTS="${WORK}/defaults"; export DEFAULTS
|
||||
LEASED="${WORK}/leased"; export LEASED
|
||||
TABLES="${WORK}/tables"; export TABLES
|
||||
RULES="${WORK}/rules"; export RULES
|
||||
DAEMONS="${WORK}/daemons"; export DAEMONS
|
||||
|
||||
reset_state() {
|
||||
: > "${IP_CALLS}"; : > "${DEFAULTS}"; : > "${LEASED}"
|
||||
: > "${TABLES}"; : > "${RULES}"
|
||||
: > "${TABLES}"; : > "${RULES}"; : > "${DAEMONS}"
|
||||
}
|
||||
|
||||
called() { grep -qF "$1" "${IP_CALLS}"; }
|
||||
@@ -169,6 +198,9 @@ leased() { grep -q "^$1\$" "${LEASED}"; }
|
||||
# A route in policy table $1 for destination $2, and its full command in $3.
|
||||
has_route() { grep -q "^$1 $2 .*$3" "${TABLES}"; }
|
||||
rule_count() { grep -c "^$1 " "${RULES}"; }
|
||||
# Reports which client was left running, not just that one was: the two
|
||||
# branches fail for different reasons and the flag that fixes them differs.
|
||||
survivors() { [ -s "${DAEMONS}" ] && { echo "surviving DHCP clients: $(cat "${DAEMONS}")"; return 0; }; return 1; }
|
||||
|
||||
# --- The setup pass -------------------------------------------------------
|
||||
|
||||
@@ -194,6 +226,8 @@ want 'called "ip route replace 169.254.169.254/32 via 10.251.185.1 dev eth0"' \
|
||||
# test escaped review.
|
||||
want 'grep -q "configured eth2" "${WORK}/setup.out"' "setup reaches every NIC in the blob"
|
||||
|
||||
wantnot 'survivors' "setup leaves no DHCP client running on any NIC"
|
||||
|
||||
# --- The customer ENI's return path ---------------------------------------
|
||||
|
||||
# Deleting the customer ENI's default route is only half the policy: without a
|
||||
@@ -221,6 +255,26 @@ fi
|
||||
want '[ "$(rule_count 101)" -eq 1 ]' "a repeated setup pass leaves exactly one rule"
|
||||
want '[ "$(grep -c "^101 " "${TABLES}")" -eq 2 ]' "a repeated setup pass leaves exactly two routes in the table"
|
||||
|
||||
# --- The dhcpcd fallback --------------------------------------------------
|
||||
|
||||
# Forced by removing udhcpc from PATH rather than left to whether the host has
|
||||
# /usr/share/udhcpc/default.script: every Ubuntu image takes this branch, and it
|
||||
# is the one that stranded a daemon on the data NIC and cost cloud-init 300s.
|
||||
FALLBACKBIN="${WORK}/fallback-bin"
|
||||
mkdir -p "${FALLBACKBIN}"
|
||||
cp "${STUBBIN}/ip" "${STUBBIN}/dhcpcd" "${FALLBACKBIN}/"
|
||||
chmod +x "${FALLBACKBIN}"/*
|
||||
|
||||
reset_state
|
||||
if ! PATH="${FALLBACKBIN}:/usr/bin:/bin" sh "${SCRIPT}" > "${WORK}/fallback.out" 2>&1; then
|
||||
fail "dhcpcd fallback pass exited non-zero"
|
||||
cat "${WORK}/fallback.out"
|
||||
fi
|
||||
|
||||
wantnot 'survivors' "the dhcpcd fallback leaves no daemon on the data NIC"
|
||||
want 'leased eth0' "the dhcpcd fallback still leaves the interface addressed after it exits"
|
||||
want 'called "dhcp -q -1 -p -t 20 eth0"' "the dhcpcd fallback is invoked one-shot and persistent"
|
||||
|
||||
# --- The enforce pass -----------------------------------------------------
|
||||
|
||||
# cloud-init's network stage re-renders the ENIs from IMDS and re-DHCPs them,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Spinifex eks-node cloud-init datasource pin (installed to
|
||||
# /etc/cloud/cloud.cfg.d/90-spinifex-ec2-imds.cfg).
|
||||
# Spinifex cloud-init datasource pin, shared by every system-VM image.
|
||||
# The installed name differs by image and the prefix is load-bearing:
|
||||
# read_conf_d() applies cloud.cfg.d in REVERSE and mergemanydict() is
|
||||
# first-source-wins, so a 90- name loses to cloud-init's own 90_dpkg.cfg
|
||||
# ('_' sorts above '-') and is silently discarded. The vllm profile installs
|
||||
# it as 99-spinifex-ec2-imds.cfg for that reason.
|
||||
#
|
||||
# The system VM's user-data is delivered only via IMDS (the NoCloud seed
|
||||
# was retired in #411), and IMDS (169.254.169.254) is reachable only once
|
||||
|
||||
@@ -54,6 +54,10 @@ image_profiles() {
|
||||
case "$1" in
|
||||
ubuntu-gpu-nvidia) echo "gpu-nvidia docker" ;;
|
||||
ubuntu-gpu-amd) echo "gpu-amd docker" ;;
|
||||
# docker is deliberately absent: the serving VM runs vLLM as a systemd
|
||||
# service and never touches a Docker daemon. vllm comes second because
|
||||
# it stacks onto the GPU driver gpu-nvidia installs.
|
||||
ubuntu-vllm-serving) echo "gpu-nvidia vllm" ;;
|
||||
spinifex-eks-node-gpu) echo "gpu-nvidia eks-common eks-agent" ;;
|
||||
spinifex-ecs-node-gpu) echo "gpu-nvidia ecs" ;;
|
||||
# The CPU variant is the GPU one minus gpu-nvidia. ecs pulls nothing
|
||||
@@ -236,7 +240,7 @@ if [[ -n "${IMAGE}" ]]; then
|
||||
fi
|
||||
if ! expanded="$(image_profiles "${IMAGE}")"; then
|
||||
echo "mkosi-build: unknown image: ${IMAGE}" >&2
|
||||
echo "mkosi-build: known images: ubuntu-gpu-nvidia ubuntu-gpu-amd spinifex-eks-node-gpu spinifex-ecs-node-gpu spinifex-ecs-node spinifex-eks-node" >&2
|
||||
echo "mkosi-build: known images: ubuntu-gpu-nvidia ubuntu-gpu-amd ubuntu-vllm-serving spinifex-eks-node-gpu spinifex-ecs-node-gpu spinifex-ecs-node spinifex-eks-node" >&2
|
||||
exit 2
|
||||
fi
|
||||
read -r -a PROFILES <<< "${expanded}"
|
||||
|
||||
Reference in New Issue
Block a user