Why overwriting an .mmdb file in place crashes production servers with SIGBUS
Memory-mapped (.mmdb) IP intelligence databases achieve sub-microsecond (< 1,000 ns) lookup latencies because the reader maps the underlying file directly into the process's virtual address space using the POSIX mmap(2) system call. Instead of allocating 140 MB of private heap objects per worker process, the operating system kernel maps the file's disk blocks into the shared page cache and hands your process a virtual memory pointer (uint8_t* / []byte).
However, mmap(2) comes with a strict contract between your application and the operating system kernel: you must never mutate or truncate the underlying filesystem inode while a process has it mapped.
When an engineer sets up a daily cron job or Kubernetes sidecar to refresh /var/lib/ipscope/ipscope-complete.mmdb and writes the naive command:
# DO NOT DO THIS IN PRODUCTION!
curl -fsSL -H "Authorization: Bearer ${KEY}" \
-o /var/lib/ipscope/ipscope-complete.mmdb \
"https://api.ipscope.io/v1/mmdb/releases/latest/download?format=mmdb"Two catastrophic failures happen immediately inside your live Go, Rust, Python, C, or NGINX worker processes:
- Fatal
SIGBUSprocess crashes onO_TRUNC: Whencurl -o /var/lib/ipscope/ipscope-complete.mmdbopens the existing file for writing, it passes theO_TRUNCflag toopen(2), which instantaneously truncates the active inode's byte length to0 bytesbefore writing the new network stream. Under the POSIXmmapspecification, if a thread in your application dereferences a virtual memory address that now lies beyond the truncated end-of-file of the underlying inode, the CPU memory management unit (MMU) faults and the Linux/BSD kernel delivers an uncatchableSIGBUS(Bus Error) signal, killing your server process immediately. - Torn reads and corrupted pointer walks: Even while bytes are streaming back into the file over the next few seconds, concurrent threads walking the old binary radix tree read half-written node records and out-of-bounds Data Section offsets, resulting in panics, slice bounds errors, or garbage country codes.
This production engineering guide walks through the exact mechanics of POSIX inodes and rename(2), provides a battle-tested shell/systemd updater with ETag caching and cryptographic SHA-256 verification, and shows lock-free atomic reader swap implementations in Go (atomic.Pointer), Rust (ArcSwap), and Node.js.
How POSIX inodes, mmap(2), and rename(2) actually work
To update a memory-mapped file with zero downtime and zero risk of SIGBUS, you must understand the separation between a directory entry (filename) and an inode on POSIX filesystems (ext4, xfs, btrfs, zfs, APFS):
BEFORE RENAME:
Directory Entry: "ipscope-complete.mmdb" ---> [ Inode #40112 (141 MB, Old Snapshot) ]
^
| (Active mmap VMA)
[ Live Go/Rust/NGINX Workers ]
STEP 1: Stage & Verify New File on the SAME Filesystem Mount:
Directory Entry: ".stage.ipscope.9a8f12" ---> [ Inode #88304 (142 MB, New Snapshot) ]
STEP 2: Atomic rename(".stage.ipscope.9a8f12", "ipscope-complete.mmdb"):
Directory Entry: "ipscope-complete.mmdb" ---> [ Inode #88304 (142 MB, New Snapshot) ]
Meanwhile, Inode #40112 (Old Snapshot) has link_count = 0 on disk,
but its kernel reference count > 0 because live workers still hold the old mmap!
Inode #40112 remains 100% intact in the OS page cache until the last worker calls munmap().Look at what POSIX guarantees when you call rename(2) (via mv -f on the same filesystem mount):
rename(2)is atomic at the kernel VFS layer. No process opening/var/lib/ipscope/ipscope-complete.mmdbwill ever observe a missing file or a partially written file; it will either open Inode #40112 (old) or Inode #88304 (new).- Existing
mmapregions mapped to Inode #40112 are completely unaffected by the directory rename! Inode #40112's bytes are never truncated or overwritten. The kernel keeps Inode #40112's pages alive until every process callsmunmap(2)(db.Close()), after which the filesystem automatically frees Inode #40112's blocks.
The /tmp cross-filesystem pitfall (EXDEV)
A classic operational bug occurs when a script downloads the temporary file to /tmp/ipscope.mmdb.tmp and runs mv /tmp/ipscope.mmdb.tmp /var/lib/ipscope/ipscope-complete.mmdb.
On almost all modern Linux distributions, /tmp is an in-memory tmpfs mount, while /var/lib/ipscope lives on the root ext4 or xfs block device. Because an inode number only exists within a single filesystem mount, calling rename(2) across two different mounts returns EXDEV (Invalid cross-device link). To hide this error, the mv command falls back to opening the destination file with O_TRUNC and copying the bytes, bringing back the exact SIGBUS crash you tried to avoid!
Rule #1 of atomic .mmdb updates: Always create your staging file in the exact same directory (/var/lib/ipscope/.stage.XXXXXX) as the target .mmdb file so they are guaranteed to share the same filesystem mount.
Step 1: Production updater script with ETag, SHA-256, and canary validation
Before replacing the live .mmdb path, an updater script should verify three things:
- Conditional
ETagcheck (304 Not Modified): Avoid downloading 140 MB if your local file already matches the latest release. - Cryptographic
SHA-256digest verification: Verify the downloaded bytes against the signed/v1/mmdb/releases/latest/checksumsmanifest. - MMDB Metadata footer sanity check: Verify that the file ends with the valid
\xab\xcd\xefMaxMind.commetadata marker before promoting it.
Save the following script to /usr/local/bin/ipscope-mmdb-sync.sh:
#!/usr/bin/env bash
set -euo pipefail
DB_DIR="${IPSCOPE_DB_DIR:-/var/lib/ipscope}"
DB_FILENAME="${IPSCOPE_DB_FILENAME:-ipscope-complete.mmdb}"
TARGET="${DB_DIR}/${DB_FILENAME}"
ETAG_FILE="${DB_DIR}/.${DB_FILENAME}.etag"
API_BASE="${IPSCOPE_API_BASE:-https://api.ipscope.io}"
if [ -z "${IPSCOPE_DOWNLOAD_KEY:-}" ]; then
echo "ERROR: IPSCOPE_DOWNLOAD_KEY environment variable is not set" >&2
exit 1
fi
mkdir -p "$DB_DIR"
# Create temporary staging inode in the SAME directory as TARGET
STAGE="$(mktemp "${DB_DIR}/.stage.${DB_FILENAME}.XXXXXX")"
ETAG_TMP="$(mktemp "${DB_DIR}/.etag.${DB_FILENAME}.XXXXXX")"
cleanup() {
rm -f "$STAGE" "$ETAG_TMP"
}
trap cleanup EXIT
CURL_ETAG_ARGS=()
if [ -f "$ETAG_FILE" ] && [ -f "$TARGET" ]; then
CURL_ETAG_ARGS=(--etag-compare "$ETAG_FILE")
fi
# 1. Download artifact with conditional ETag support
HTTP_STATUS="$(curl -sS -L -w "%{http_code}" \
--retry 3 --retry-delay 2 --max-time 300 \
-H "Authorization: Bearer ${IPSCOPE_DOWNLOAD_KEY}" \
"${CURL_ETAG_ARGS[@]}" \
--etag-save "$ETAG_TMP" \
-o "$STAGE" \
"${API_BASE}/v1/mmdb/releases/latest/download?format=mmdb")"
if [ "$HTTP_STATUS" = "304" ]; then
echo "[ipscope-sync] Local ${DB_FILENAME} is already up to date (HTTP 304)."
exit 0
fi
if [ "$HTTP_STATUS" != "200" ]; then
echo "ERROR: Download failed with HTTP status ${HTTP_STATUS}" >&2
exit 1
fi
# 2. Fetch and verify cryptographic SHA-256 digest
EXPECTED_SHA256="$(curl -fsSL \
--retry 3 --max-time 30 \
-H "Authorization: Bearer ${IPSCOPE_DOWNLOAD_KEY}" \
"${API_BASE}/v1/mmdb/releases/latest/checksums?format=text" \
| awk '/\.mmdb$/ {print $1; exit}')"
if [ -z "$EXPECTED_SHA256" ]; then
echo "ERROR: Could not parse .mmdb SHA-256 from checksums endpoint" >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL_SHA256="$(sha256sum "$STAGE" | awk '{print $1}')"
else
ACTUAL_SHA256="$(shasum -a 256 "$STAGE" | awk '{print $1}')"
fi
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "ERROR: SHA-256 mismatch! expected=${EXPECTED_SHA256} actual=${ACTUAL_SHA256}" >&2
exit 1
fi
# 3. Flush staging inode pages to disk, set permissions, and perform atomic POSIX rename(2)
chmod 0644 "$STAGE"
sync "$STAGE"
mv -f "$STAGE" "$TARGET"
mv -f "$ETAG_TMP" "$ETAG_FILE"
echo "[ipscope-sync] Atomically promoted new ${DB_FILENAME} (sha256=${ACTUAL_SHA256})."Step 2: Lock-free hot-reloads in Go (sync/atomic.Pointer)
Once mv -f "$STAGE" "$TARGET" atomically swaps the directory entry, how should your running Go service switch over to the new inode without blocking 100,000 concurrent HTTP requests per second on a sync.RWMutex?
Even an uncontended sync.RWMutex.RLock() requires an atomic CPU cache-line write (readerCount.Add(1)) on every single lookup, which causes cross-core cache-line bouncing on 64-core servers.
Instead, Go 1.19+ provides sync/atomic.Pointer[T], which performs a pure atomic memory read (MOV on x86_64, LDAR on ARM64) with zero cache-line invalidation:
package ipdb
import (
"fmt"
"net"
"os"
"sync/atomic"
"syscall"
"time"
"github.com/oschwald/maxminddb-golang"
)
type readerSnapshot struct {
reader *maxminddb.Reader
inode uint64
}
type AtomicMMDB struct {
path string
current atomic.Pointer[readerSnapshot]
}
func fileInode(path string) (uint64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
return stat.Ino, nil
}
return uint64(info.ModTime().UnixNano()), nil
}
func NewAtomicMMDB(path string) (*AtomicMMDB, error) {
ino, err := fileInode(path)
if err != nil {
return nil, fmt.Errorf("stat mmdb: %w", err)
}
r, err := maxminddb.Open(path)
if err != nil {
return nil, fmt.Errorf("open mmdb: %w", err)
}
// Canary sanity check before accepting reader
var probe map[string]any
if err := r.Lookup(net.ParseIP("1.1.1.1"), &probe); err != nil {
_ = r.Close()
return nil, fmt.Errorf("canary lookup failed: %w", err)
}
m := &AtomicMMDB{path: path}
m.current.Store(&readerSnapshot{reader: r, inode: ino})
return m, nil
}
// ReloadIfInodeChanged checks whether the target path points to a new inode.
// If so, it opens the new mmap, validates a canary IP, atomically swaps the pointer,
// and drains the old mmap after a 10-second grace period.
func (m *AtomicMMDB) ReloadIfInodeChanged() (bool, error) {
newInode, err := fileInode(m.path)
if err != nil {
return false, err
}
cur := m.current.Load()
if cur != nil && cur.inode == newInode {
return false, nil // Same inode, nothing to reload
}
nextReader, err := maxminddb.Open(m.path)
if err != nil {
return false, fmt.Errorf("open new mmdb inode: %w", err)
}
// Canary lookup on new reader before exposing it to live traffic
var canary map[string]any
if err := nextReader.Lookup(net.ParseIP("1.1.1.1"), &canary); err != nil {
_ = nextReader.Close()
return false, fmt.Errorf("new mmdb failed canary check: %w", err)
}
prev := m.current.Swap(&readerSnapshot{
reader: nextReader,
inode: newInode,
})
if prev != nil && prev.reader != nil {
go func(old *maxminddb.Reader) {
// Allow all in-flight sub-microsecond lookups on the old pointer to complete
time.Sleep(10 * time.Second)
_ = old.Close()
}(prev.reader)
}
return true, nil
}
// Lookup executes on the hot path with zero locks and zero allocations.
func (m *AtomicMMDB) Lookup(ip net.IP, result any) error {
return m.current.Load().reader.Lookup(ip, result)
}Look at why checking stat.Ino (ReloadIfInodeChanged) is so clean in containerized and Kubernetes environments:
- You do not even need to wire
SIGHUPsignals across container namespaces or worry aboutfsnotifymissing directory events on Kubernetes ConfigMap/hostPath mounts. - A lightweight background goroutine calling
ReloadIfInodeChanged()once every 15 seconds executes a singlestat(2)syscall, detects the new inode number immediately aftermv -fcompletes, validates the new.mmdbwith a canary lookup (1.1.1.1), swaps the pointer in1 nanosecond, and closes the oldmmap10 seconds later.
Step 3: Wait-free hot-reloads in Rust (arc-swap + maxminddb)
In Rust, because maxminddb::Reader<Mmap> owns the memory-mapped region via RAII (Drop calls munmap(2) automatically when the last reference is dropped!), combining std::sync::Arc with the arc-swap crate (ArcSwap) gives you automatic reference-counted memory reclamation without even needing a sleep timer:
use arc_swap::ArcSwap;
use maxminddb::Reader;
use memmap2::Mmap;
use serde::de::DeserializeOwned;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct HotReloadMmdb {
path: PathBuf,
reader: ArcSwap<Reader<Mmap>>,
}
impl HotReloadMmdb {
pub fn open(path: impl AsRef<Path>) -> Result<Self, maxminddb::MaxMindDBError> {
let path_buf = path.as_ref().to_path_buf();
let initial = Reader::open_mmap(&path_buf)?;
Ok(Self {
path: path_buf,
reader: ArcSwap::from_pointee(initial),
})
}
/// Atomically swaps in a freshly memory-mapped `.mmdb` reader.
/// Any thread currently executing `lookup()` holds an `Arc` guard to the old
/// `Reader<Mmap>`, so the old `Mmap` is unmapped automatically the instant
/// the last in-flight lookup guard drops!
pub fn reload(&self) -> Result<(), maxminddb::MaxMindDBError> {
let next_reader = Reader::open_mmap(&self.path)?;
// Validate canary lookup before swapping
let canary_ip: IpAddr = "1.1.1.1".parse().unwrap();
let _: Option<serde_json::Value> = next_reader.lookup(canary_ip)?;
self.reader.store(Arc::new(next_reader));
Ok(())
}
#[inline]
pub fn lookup<T: DeserializeOwned>(
&self,
ip: IpAddr,
) -> Result<Option<T>, maxminddb::MaxMindDBError> {
let guard = self.reader.load();
guard.lookup::<T>(ip)
}
}Because self.reader.load() returns a wait-free arc_swap::Guard<Arc<Reader<Mmap>>>, the old Mmap cannot be unmapped while any thread is still inside guard.lookup::<T>(ip). The moment self.reader.store(Arc::new(next_reader)) is called and the last in-flight guard goes out of scope, Rust's Drop implementation calls munmap(2) deterministically.
Step 4: Zero-downtime swaps in Node.js (maxmind package)
In Node.js, the V8 JavaScript event loop is single-threaded per worker, which makes reference swapping even simpler: opening the new Reader asynchronously and reassigning the module-level variable activeReader = nextReader is inherently atomic across event loop ticks, while V8's garbage collector closes the previous buffer once any synchronous calls complete:
import maxmind, { type Reader, type Response } from 'maxmind';
export class HotReloadMmdbReader<T extends Response> {
private reader!: Reader<T>;
private readonly dbPath: string;
private constructor(dbPath: string, initialReader: Reader<T>) {
this.dbPath = dbPath;
this.reader = initialReader;
}
public static async open<T extends Response>(dbPath: string): Promise<HotReloadMmdbReader<T>> {
const initial = await maxmind.open<T>(dbPath);
return new HotReloadMmdbReader<T>(dbPath, initial);
}
public async reload(): Promise<void> {
const nextReader = await maxmind.open<T>(this.dbPath);
// Validate canary lookup before replacing live reference
nextReader.get('1.1.1.1');
this.reader = nextReader;
}
public lookup(ip: string): T | null {
return this.reader.get(ip);
}
}Comparing hot-reload architectures across runtimes and proxies
| Runtime / Proxy | Recommended Reload Pattern | Hot-Path Lookup Overhead | Old Inode Reclamation |
|---|---|---|---|
Go (maxminddb-golang) | sync/atomic.Pointer[maxminddb.Reader] + inode stat(2) poller or SIGHUP | 0 ns (single atomic pointer load) | Background drain timer (time.Sleep(10s) -> old.Close()) |
Rust (maxminddb + arc-swap) | ArcSwap<Reader<Mmap>> | < 2 ns (wait-free hazard/generation guard) | Instantaneous deterministic RAII Drop when last guard exits |
Node.js (maxmind) | Async maxmind.open() + single-threaded reference reassignment | 0 ns | Automatic V8 garbage collection |
NGINX (ngx_http_geoip2_module) | Atomic mv -f on disk followed by nginx -s reload | 0 ns | Old worker processes exit gracefully after finishing active requests |
| HAProxy / Envoy | Atomic mv -f + admin socket map/filter reload | 0 ns | Reference-counted epoch reclamation |
Frequently asked questions (FAQ)
What happens if the network connection drops halfway through downloading the .mmdb update?
Because our updater script downloads into a temporary staging file (mktemp "${DB_DIR}/.stage.XXXXXX") with set -euo pipefail and verifies the cryptographic SHA-256 checksum before running mv -f, an interrupted download or corrupted file simply exits at the curl or sha256sum step and cleans up the staging file via trap cleanup EXIT. Your live /var/lib/ipscope/ipscope-complete.mmdb file is never touched.
How much extra RAM is used during the 10-second reload overlap window?
During the brief overlap window when the new inode is mapped and the old inode is draining in-flight requests, both .mmdb files reside in the OS page cache. For IPScope-Country-v1 (~9.4 MB), the temporary overlap is ~18.8 MB; for IPScope-Privacy-v1 (~46.2 MB), ~92.4 MB; and for IPScope-Complete-v1 (~141.4 MB), ~282.8 MB. As soon as old.Close() (munmap) runs and the unlinked old inode's reference count drops to zero, the kernel reclaims 100% of the old inode's page cache and disk blocks immediately.