Extracting a Proxmox VMA Backup Without Proxmox
No Proxmox host left, but a vzdump .vma.zst holds the database you need. How I parsed the VMA format in Python and streamed the disk out of it.
I needed one database out of a Proxmox VM backup. Small thing, a few megabytes of SQL sitting in a home directory inside the VM. The backup was a vzdump-qemu-*.vma.zst on offsite storage. The problem: the Proxmox host that made it no longer existed, and neither did any other Proxmox box I could use.
On Proxmox this is a one-liner: vma extract. Outside Proxmox the vma binary isn't something you just apt install on a stock Ubuntu. And the archive was also slightly truncated at the end, which the standard tool isn't designed to be friendly about. So I wrote my own extractor. It took three attempts to get the offsets right, so here's the shape that works, and the traps.
TL;DR: A VMA file is a header followed by a stream of "extents". Each extent has a 512-byte header with 59 block-info entries, followed by the 4 KiB blocks those entries describe. Parse that, write the blocks to a sparse raw image per disk, losetup -P, mount read-only, copy what you need. Stream it straight from the storage through zstd -dc into the parser so you don't need space for the intermediate file.
The commands
# stream from the offsite storage, decompress, parse — no intermediate .vma on disk
ssh -p 23 u123456@u123456.your-storagebox.de "cat backups/vzdump-qemu-NNN-XXXX.vma.zst" \
| zstd -dc --long=31 \
| python3 vma_extract.py /srv/restore/
# attach the resulting raw image with partition scanning
losetup -fP --show /srv/restore/disk-dev1.raw # prints e.g. /dev/loop0
lsblk /dev/loop0
# check a COPY or accept the risk, then mount read-only
fsck -y /dev/loop0p1
mount -o ro /dev/loop0p1 /mnt/vm
cp /mnt/vm/root/app-dump.sql /srv/restore/
umount /mnt/vm && losetup -d /dev/loop0
On my run the VM had an 80 GiB virtual disk; the sparse raw image ended up taking about 26 GB of real space, because zero blocks aren't stored in the backup and aren't written by the extractor. Streaming mattered: the box I restored onto had about 33 GB free, so the image or the decompressed .vma would have fit, not both.
The format, as far as I needed it
All integers are big-endian. The layout below is what my extractor relied on when it worked on a real vzdump archive. Proxmox documents the format in its source tree (a vma_spec.txt in their QEMU patches) if you want the full picture.
Header (starts at offset 0, magic VMA\0):
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic VMA\0 |
| 4 | 4 | version |
| 8 | 16 | uuid |
| 24 | 8 | ctime |
| 32 | 16 | md5sum |
| 48 | 4 | blob_buffer_offset |
| 52 | 4 | blob_buffer_size |
| 56 | 4 | header_size |
| 4096 | 256 × 32 | dev_info array |
Each dev_info entry is 32 bytes: a 4-byte pointer to the device name in the blob buffer, 4 reserved bytes, an 8-byte device size, then reserved bytes. The data stream starts right after header_size bytes. The VM config (qemu-server.conf) is in the blob buffer too, but I didn't need it.
Extent header (512 bytes, magic VMAE):
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic VMAE |
| 4 | 2 | reserved |
| 6 | 2 | block_count |
| 8 | 16 | uuid |
| 24 | 16 | md5sum |
| 40 | 59 × 8 | blockinfo entries |
Each 64-bit blockinfo entry packs three things:
mask = (bi >> 48) & 0xffff # which of the 16 x 4 KiB blocks are present
dev_id = (bi >> 32) & 0xff # which disk
cluster = bi & 0xffffffff # 64 KiB cluster number on that disk
A cluster is 64 KiB = 16 blocks of 4 KiB. A set bit in the mask means "this 4 KiB block follows in the stream". A clear bit means the block is all zeros and is not stored. After the 512-byte extent header come the present blocks, in order, entry by entry.
The extractor
This is the core of it, stripped of logging. Honest disclaimer: the original was a throwaway script on the rescue box and didn't survive, so this is a clean rewrite of the same logic. Run it against a copy of your backup first and sanity-check the result (fdisk -l, mount read-only) before you trust it.
#!/usr/bin/env python3
# vma_extract.py <outdir> -- reads an uncompressed VMA stream on stdin
import os, struct, sys
CLUSTER = 64 * 1024
BLOCK = 4096
src = sys.stdin.buffer
outdir = sys.argv[1]
os.makedirs(outdir, exist_ok=True)
def readn(n):
buf = bytearray()
while len(buf) < n:
chunk = src.read(n - len(buf))
if not chunk:
break
buf += chunk
return bytes(buf)
head = readn(60)
if head[:4] != b"VMA\0":
sys.exit("not a VMA stream")
header_size = struct.unpack(">I", head[56:60])[0]
head += readn(header_size - 60)
# device sizes from the dev_info array at 4096, 32 bytes per entry
sizes = {}
for dev in range(1, 256):
off = 4096 + dev * 32
size = struct.unpack(">Q", head[off + 8:off + 16])[0]
if size:
sizes[dev] = size
files = {}
def out(dev):
if dev not in files:
f = open(os.path.join(outdir, f"disk-dev{dev}.raw"), "wb")
if dev in sizes:
f.truncate(sizes[dev]) # sparse file of the full disk size
files[dev] = f
return files[dev]
extents = 0
while True:
eh = readn(512)
if len(eh) < 512:
break # clean end, or truncated archive
if eh[:4] != b"VMAE":
sys.exit(f"lost sync after {extents} extents")
infos = struct.unpack(">59Q", eh[40:512])
for bi in infos:
if bi == 0:
continue # unused slot
mask = (bi >> 48) & 0xFFFF
dev = (bi >> 32) & 0xFF
cluster = bi & 0xFFFFFFFF
f = out(dev) # write it even if dev is "unexpected"
for i in range(16):
if mask & (1 << i):
data = readn(BLOCK)
if len(data) < BLOCK:
print("stream ended mid-extent (truncated archive)", file=sys.stderr)
break
f.seek(cluster * CLUSTER + i * BLOCK)
f.write(data)
extents += 1
for f in files.values():
f.close()
print(f"done: {extents} extents, devices {sorted(files)}", file=sys.stderr)
Pure Python, one seek and write per 4 KiB block — not fast, but good enough for a one-off rescue over SSH.
Gotcha: blockinfo starts at offset 40, not 16
One of my early attempts read the blockinfo array from offset 16. Wrong — the extent header carries a 16-byte uuid and a 16-byte md5 before the array. Read from the wrong place and you get nonsense cluster numbers, and the parser loses sync with the stream almost immediately. 4 + 2 + 2 + 16 + 16 = 40, and 40 + 59 × 8 = 512 exactly. If your numbers don't add up to 512, your layout is wrong.
Gotcha: header_size is at 56
Three adjacent 32-bit fields, easy to mix up. Take the wrong one as the header length and you start parsing extents somewhere inside the header. blob_buffer_offset is at 48, blob_buffer_size at 52, header_size at 56. The data stream starts after header_size — not after 4096 and not after the dev_info array.
Gotcha: never skip blocks for a device you don't recognize
This one is subtle. On my archive, the device ID in the blockinfo entries didn't match the device slot I expected from the header (1 vs. 2). My first instinct was "unknown device, skip it". But skipping means not reading the data blocks for that entry — and then every following block is read from the wrong position. The whole stream shifts and everything after it is garbage.
The extractor above handles that by always consuming the blocks and writing them to disk-dev<N>.raw for whatever ID shows up. You sort out which file is which afterwards with lsblk and file -s. Side effect: if the IDs don't line up with the header, the pre-sizing from dev_info won't apply to that file, and the image is only as long as its last written block. For pulling files out that didn't matter; if fsck complains about the filesystem being larger than the device, truncate -s the image to the real disk size.
Gotcha: truncated archives
If the upload of the backup was cut off, zstd -dc will complain at the end ("truncated input" or similar) and stop. That's fine — everything before the cut has already gone through the pipe. The extractor stops at a short read and keeps what it has. What's lost is whatever was written to the stream after the cut. In my case the SQL dump I needed was in the surviving part. In another case (a Nextcloud VM, see my backup post-mortem) a big chunk of the filesystem ended up in lost+found after fsck and the tail of the disk was simply gone.
Gotcha: --long=31
I decompress with zstd -dc --long=31. It allows a large decompression window; if an archive was compressed with long-distance matching and you omit it, zstd can refuse with a window-size error. With the flag it just works, so I always pass it for vzdump archives.
Would I do it again?
If a Proxmox host is available — even a throwaway VM with Proxmox installed — use vma extract. It's the real tool and it's faster. Writing a parser makes sense when you have no Proxmox, a truncated file, or not enough disk for the intermediate .vma. Mine was all three.
The better fix is upstream: Proxmox Backup Server stores backups as verified chunks and lets you mount or restore individual files without this kind of archaeology. And dump your databases separately with mysqldump/pg_dump and rsync them offsite — a 5 MB SQL file is much easier to rescue than an 80 GB disk image. If the restore target is tight on space, finding what's eating your disk first saves you a failed run halfway through.