Pure Go library for Linux userspace block devices via the ublk driver and io_uring.
Application
│
▼
/dev/ublkbN (block device)
│
▼
kernel ublk driver
│ io_uring
▼
ublk-go (this library)
│
▼
Backend.ReadAt / Backend.WriteAt
- Linux 6.0+ (tested on 6.17)
CAP_SYS_ADMIN(root)ublk_drvkernel module loaded (modprobe ublk_drv)
go get github.com/e2b-dev/ublk-go@latestIn code, import the subpackage:
import "github.com/e2b-dev/ublk-go/ublk"The only symbols you need from day one are Backend, New, and
(*Device).Path / (*Device).Close. The lower-level io_uring wrapper
at github.com/e2b-dev/ublk-go/ublk/uring is intentionally separate —
most users don't touch it.
Important
The kernel default is only 64 ublk devices system-wide. Raise it before doing anything that creates more:
echo 'options ublk_drv ublks_max=4096' | sudo tee /etc/modprobe.d/ublk.conf
sudo rmmod ublk_drv && sudo modprobe ublk_drv
cat /sys/module/ublk_drv/parameters/ublks_max # verify: 4096Also: each ublk.Device holds 3 fds internally, so ulimit -n 65536
(or systemd LimitNOFILE=65536) is recommended for any process
creating many devices.
See Production setup below for udev tuning and the drop-in config files.
Three limits to raise for any non-trivial deployment. With the defaults you will hit one of them well before you get to a hundred devices:
| Limit | Default | Recommended | Where |
|---|---|---|---|
ublks_max (module parameter) |
64 | 4096 | /etc/modprobe.d/ublk.conf |
| udev CHANGE-event inotify | on | off | /etc/udev/rules.d/97-ublk-device.rules |
RLIMIT_NOFILE (fd limit) of the process using the library |
1024–4096 | 65536+ | shell / systemd unit / ulimit |
The last one matters because each ublk.Device holds three fds
internally (control fd, char fd, io_uring fd) — 500 devices = ~1500 fds
just from the library, plus one per open /dev/ublkbN you do from user
code. Crossing the default ulimit -n surfaces as ublk.New returning
"too many open files" or the io_uring setup failing partway through
New, which is very confusing if you don't know to look at it.
Drop-in config files are tracked in etc/:
# Raise the kernel-side limit (ublks_max=4096).
sudo install -m0644 etc/ublk.conf /etc/modprobe.d/
sudo install -m0644 etc/97-ublk-device.rules /etc/udev/rules.d/
sudo rmmod ublk_drv && sudo modprobe ublk_drv
sudo udevadm control --reload-rules && sudo udevadm trigger
# Raise the process-side fd limit. Shell session:
ulimit -n 65536
# or persistently for a systemd service, add to the unit:
# [Service]
# LimitNOFILE=65536Verify:
cat /sys/module/ublk_drv/parameters/ublks_max # 4096
ulimit -n # 65536 (or higher)Theoretical kernel ceiling is UBLK_MINORS = 1 << MINORBITS ≈ 1 M;
practical ublks_max is whatever your workload needs. Each idle
device costs a handful of KB of kernel memory and one minor number.
package main
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"github.com/e2b-dev/ublk-go/ublk"
)
type memBackend struct {
mu sync.RWMutex
data []byte
}
func (m *memBackend) ReadAt(p []byte, off int64) (int, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return copy(p, m.data[off:off+int64(len(p))]), nil
}
func (m *memBackend) WriteAt(p []byte, off int64) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return copy(m.data[off:off+int64(len(p))], p), nil
}
func main() {
const size = 256 * 1024 * 1024
dev, err := ublk.New(&memBackend{data: make([]byte, size)}, size)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer dev.Close()
fmt.Println(dev.Path())
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
}Run with sudo go run ./example. The block device appears at /dev/ublkbN and can be used like any other block device (mkfs, mount, dd, etc.).
For an end-to-end demo that formats, mounts, writes a file, and prints backend I/O counts at each phase, run sudo go run ./example/fsdemo.
// Backend is the storage backing the block device. It embeds io.ReaderAt
// and io.WriterAt; their standard concurrency contract (disjoint ranges
// are safe) applies here.
type Backend interface {
io.ReaderAt
io.WriterAt
}
func New(backend Backend, size uint64) (*Device, error)
func (*Device) Path() string
func (*Device) Close() errorsize must be a positive multiple of 512. The block device uses
512-byte logical blocks, 128KB max IO, and a single queue with depth 128.
Close any fd you've opened to /dev/ublkbN before calling
Device.Close(). Close() internally issues UBLK_CMD_DEL_DEV,
which is backed by the kernel's del_gendisk() — and that blocks
until every open fd on the block device has been released. This is
standard Linux block-device teardown, not a ublk quirk, but it means
the following deadlocks:
fd, _ := unix.Open(dev.Path(), unix.O_RDWR, 0)
// ... some work ...
dev.Close() // ← hangs forever; del_gendisk waits for `fd`
unix.Close(fd) // never reachedCorrect order:
fd, _ := unix.Open(dev.Path(), unix.O_RDWR, 0)
// ... some work ...
unix.Close(fd) // release the block device first
dev.Close() // now Close can proceedIf you have many fds spread across goroutines, close them all before
calling Device.Close(). A running mount also counts; unmount before
closing the device.
- Control plane: Commands to
/dev/ublk-controlvia io_uring passthrough (URING_CMD) manage device lifecycle: ADD_DEV → SET_PARAMS → START_DEV → STOP_DEV → DEL_DEV. - Data plane: One worker goroutine pinned to an OS thread (required by the ublk protocol), processing FETCH_REQ / COMMIT_AND_FETCH_REQ commands on its own io_uring on
/dev/ublkcN. IO descriptors are memory-mapped from the char device. - No CGO: All syscalls are made directly via
golang.org/x/sys/unix. - Shutdown:
Close()triggersublk_ch_releasein the kernel, cancels the worker via eventfd+epoll, sends STOP+DEL, releases all fds.
github.com/e2b-dev/ublk-go/ublk— main library (New,Device,Backend)github.com/e2b-dev/ublk-go/ublk/uring— standalone io_uring wrapper used by the library
make test # unit + integration (integration uses sudo)
make test-unit # unit tests only (no root needed)
make test-integration # integration tests only (requires root + ublk_drv)
make cover # unit + integration with coverage profiles in ./coverage/
make cover-html # open HTML coverage report in your browser
make lint # gofmt check, go mod tidy check, golangci-lint, go mod verify
make fmt # format code and tidy go.mod
make hooks # install the repo's pre-commit hook (optional)Integration tests live behind //go:build integration. If your editor / gopls
hides ublk_integration_test.go, tell the Go toolchain about the tag once:
go env -w GOFLAGS=-tags=integrationFor an isolated development environment, this repo now ships a
dev container that installs the same
core tooling the project expects (golangci-lint, e2fsprogs, Go 1.25,
and GOFLAGS=-tags=integration).
The dev container runs --privileged and binds the host /dev and
/lib/modules so integration tests can talk to the host kernel's
ublk_drv. That makes make build, make test-unit, and make lint
work out of the box inside the container, and also lets make test-integration work when the host is Linux and exposes ublk_drv.
Host requirements still apply — a container cannot emulate the kernel side of ublk on its own:
sudo modprobe ublk_drv
ls -l /dev/ublk-controlFor GitHub Copilot cloud agent sessions, the repo also ships
.github/workflows/copilot-setup-steps.yml,
which preinstalls the same tooling, enables the integration build tag,
warms the Go module cache, and loads ublk_drv on the runner when
available.
See TODO.md for planned features (zero-copy, user recovery, zoned devices, etc.).
Apache License 2.0 © 2026 E2B