Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ Each monitor subsection supports the following options:
| `default_vcpus` | integer | `1` | Default number of virtual CPUs |
| `path` | string | (empty) | Optional custom path to the monitor binary. If not specified, urunc will search for the binary in PATH |
| `data_path` | string | (empty) | Optional custom path for the monitor's data file directory |
| `socket_path` | string | (empty) | Optional path for the monitor's control socket. If not specified, urunc uses a per-container default under `/tmp`. When a custom path is set, urunc creates its parent directory; setting it to an invalid location (a file already exists on the path) makes the monitor fail to start. Currently only used by Firecracker. |
| `boot_mode` | string | `api` | Optional: `api` drives the monitor's boot over its control socket, `config-file` lets the monitor boot itself from a config file (socket only used afterward). Currently only used by Firecracker. |

Since Qemu is the only currently supported monitor which requires extra data to
boot a VM, `urunc` will first check `/usr/local/share` and then `/usr/share` for
Expand All @@ -130,6 +132,8 @@ data_path = "/usr/local/share/"
default_memory_mb = 512
default_vcpus = 2
path = "/opt/firecracker/firecracker"
socket_path = "/run/urunc/fc.sock"
boot_mode = "config-file"
```

### Extra binaries Configuration
Expand Down
17 changes: 17 additions & 0 deletions pkg/network/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ type UnikernelNetworkInfo struct {
EthDevice Interface
}
type Manager interface {
// HasNetwork does a cheap check for whether this container has a network
// interface to configure, without doing the more expensive tap device
// creation and configuration. It lets callers learn this fact early,
// before the full NetworkSetup (which does the expensive work) finishes.
HasNetwork() (bool, error)
NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkInfo, error)
}

Expand Down Expand Up @@ -113,6 +118,18 @@ func createTapDevice(name string, mtu int, ownerUID, ownerGID uint32) (netlink.L
}
}

// LinkAdd opens the tap device's file descriptor with O_CLOEXEC and sets
// TUNSETPERSIST, so the interface survives independent of any open fd.
// We don't need to keep it open past this point (the rest of setup, and
// whatever process later attaches to this tap by name, uses netlink/its
// own independent open, not this fd) - closing it now, rather than
// relying on a future exec to do it, matters because a process that
// keeps this fd open (instead of exec-ing away) would otherwise block
// anything else from opening the same single-queue tap device.
for _, tapFd := range tapLink.Fds {
_ = tapFd.Close()
}

err = netlink.LinkSetMTU(tapLink, mtu)
if err != nil {
return nil, fmt.Errorf("failed to set tap device MTU to %d: %w", mtu, err)
Expand Down
11 changes: 11 additions & 0 deletions pkg/network/network_dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ type DynamicNetwork struct {
// FIXME: CUrrently only one tap device per netns can provide functional networking. We need to find a proper way to handle networking
// for multiple unikernels in the same pod/network namespace.
// See: https://github.com/urunc-dev/urunc/issues/13
// HasNetwork checks, cheaply, whether a container network interface exists
// in the current netns, without creating the tap device or doing any of the
// other, more expensive setup NetworkSetup does.
func (n DynamicNetwork) HasNetwork() (bool, error) {
_, err := discoverContainerIface()
if err != nil {
return false, nil
}
return true, nil
}

func (n DynamicNetwork) NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkInfo, error) {
tapIndex, err := getTapIndex()
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions pkg/network/network_static.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ func setNATRule(iface string, sourceIP string) error {
return nil
}

// HasNetwork checks, cheaply, whether a container network interface exists
// in the current netns, without creating the tap device or doing any of the
// other, more expensive setup NetworkSetup does.
func (n StaticNetwork) HasNetwork() (bool, error) {
_, err := discoverContainerIface()
if err != nil {
return false, nil
}
return true, nil
}

func (n StaticNetwork) NetworkSetup(uid uint32, gid uint32) (*UnikernelNetworkInfo, error) {
newTapName := strings.ReplaceAll(DefaultTap, "X", "0")
addTCRules := false
Expand Down
229 changes: 215 additions & 14 deletions pkg/unikontainers/hypervisors/firecracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@
package hypervisors

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

"github.com/urunc-dev/urunc/pkg/unikontainers/types"
"golang.org/x/sys/unix"
Expand Down Expand Up @@ -108,13 +114,43 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel
// options in FC, since the string return value of the Monitor related
// functions in the unikernel interface do not integrate well with FC's
// json configuration.
cmdString := fc.Path() + " --no-api --config-file "
apiSockPath := ResolveSocketPath(args)
cmdString := fc.Path() + " --api-sock " + apiSockPath
JSONConfigFile := filepath.Join("/tmp/", FCJsonFilename)
cmdString += JSONConfigFile
if args.BootMode == "config-file" {
// config-file-based: Firecracker boots itself from the JSON config file
// below; the socket stays open only for use after the guest is running.
cmdString += " --config-file " + JSONConfigFile
}
if !args.Seccomp {
cmdString += " --no-seccomp"
}

FCConfig := buildFirecrackerConfig(args, ukernel)
FCConfigJSON, err := json.Marshal(FCConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal Firecracker config: %w", err)
}
if err = os.WriteFile(JSONConfigFile, FCConfigJSON, 0o644); err != nil { //nolint: gosec
return nil, fmt.Errorf("failed to save Firecracker json config: %w", err)
}
vmmLog.WithField("Json", string(FCConfigJSON)).Debug("Firecracker json config")

exArgs := strings.Split(cmdString, " ")
return exArgs, nil
}

// PreExec performs pre-execution setup. Firecracker has no special pre-exec requirements.
func (fc *Firecracker) PreExec(_ types.ExecArgs) error {
return nil
}

// buildFirecrackerConfig builds the microVM configuration from args and
// ukernel. Used both to write the JSON config file (config-file-based boot)
// and to drive the same configuration over the API socket (API-based boot),
// so both paths always agree on what the guest actually gets configured
// with.
func buildFirecrackerConfig(args types.ExecArgs, ukernel types.Unikernel) *FirecrackerConfig {
// VM config for Firecracker
fcMem := DefaultMemory
if args.MemSizeB != 0 {
Expand Down Expand Up @@ -184,27 +220,192 @@ func (fc *Firecracker) BuildExecCmd(args types.ExecArgs, ukernel types.Unikernel
}
}

FCConfig := &FirecrackerConfig{
return &FirecrackerConfig{
Source: FCSource,
Machine: FCMachine,
Drives: FCDrives,
NetIfs: FCNet,
VSock: FCVSockDev,
}
FCConfigJSON, err := json.Marshal(FCConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal Firecracker config: %w", err)
}

// FirecrackerSession is a Firecracker child process being configured over its
// API socket in stages, while the caller performs its own setup work between
// the stages. Create it with SpawnSocketVMM, feed it configuration with the
// Configure* methods as each piece becomes available, boot the guest with
// StartGuest, then hand the calling process over with Supervise.
type FirecrackerSession struct {
cmd *exec.Cmd
client *firecrackerClient
}

// SpawnSocketVMM starts Firecracker as a child process with only its API
// socket enabled, and establishes the single persistent connection all later
// configuration stages use (it survives a later changeRoot; see
// firecrackerClient).
//
// This is called early in Exec, before the monitor rootfs is prepared and
// before changeRoot, so unlike the exec path the child keeps the caller's
// current (pre-pivot) mount view for its lifetime. It does inherit the
// sandbox's network namespace, which Exec has already joined. When uid/gid
// are non-zero the child is started directly under that credential, since
// the caller only drops its own privileges (setupUser) much later.
func (fc *Firecracker) SpawnSocketVMM(args types.ExecArgs, uid, gid uint32) (*FirecrackerSession, error) {
socketPath := ResolveSocketPath(args)
// Firecracker binds this path itself; a stale socket file left from an
// earlier run (e.g. a crash) would make its bind fail with "address
// already in use", so remove any leftover first.
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to remove stale socket %q: %w", socketPath, err)
}
if err = os.WriteFile(JSONConfigFile, FCConfigJSON, 0o644); err != nil { //nolint: gosec
return nil, fmt.Errorf("failed to save Firecracker json config: %w", err)
execCmd := []string{fc.Path(), "--api-sock", socketPath}
if !args.Seccomp {
execCmd = append(execCmd, "--no-seccomp")
}
vmmLog.WithField("Json", string(FCConfigJSON)).Debug("Firecracker json config")

exArgs := strings.Split(cmdString, " ")
return exArgs, nil
cmd := exec.Command(execCmd[0], execCmd[1:]...) //nolint: gosec
cmd.Env = args.Environment
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if uid != 0 || gid != 0 {
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{Uid: uid, Gid: gid},
}
}
vmmLog.WithField("command", execCmd).Debug("starting Firecracker as a supervised child")
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start firecracker: %w", err)
}

client := newFirecrackerClient(socketPath)
if err := client.connect(5 * time.Second); err != nil {
s := &FirecrackerSession{cmd: cmd, client: client}
s.Kill()
return nil, fmt.Errorf("firecracker socket never became ready: %w", err)
}
return &FirecrackerSession{cmd: cmd, client: client}, nil
}

// PreExec performs pre-execution setup. Firecracker has no special pre-exec requirements.
func (fc *Firecracker) PreExec(_ types.ExecArgs) error {
return nil
// ConfigureMachine sends the vCPU/memory configuration. Nothing but the
// container spec is needed for this, so it is the first stage, sent right
// after the spawn.
func (s *FirecrackerSession) ConfigureMachine(ctx context.Context, args types.ExecArgs) error {
fcMem := DefaultMemory
if args.MemSizeB != 0 {
fcMem = bytesToMiB(args.MemSizeB)
if fcMem == 0 {
fcMem = DefaultMemory
}
}
machine := FirecrackerMachine{
VcpuCount: args.VCPUs,
MemSizeMiB: fcMem,
Smt: false,
TrackDirtyPages: false,
}
vmmLog.Debug("staged boot: sending machine-config")
return s.client.putMachineConfig(ctx, machine)
}

// ConfigureNetwork attaches the container's network interface. Firecracker
// opens the tap device during this call, so it must only run once the
// network setup that creates the tap has finished. No-op without a tap.
func (s *FirecrackerSession) ConfigureNetwork(ctx context.Context, net types.NetDevParams) error {
if net.TapDev == "" {
return nil
}
iface := FirecrackerNet{
IfaceID: "net1",
GuestMAC: net.MAC,
HostIF: net.TapDev,
}
vmmLog.Debug("staged boot: sending network-interfaces")
return s.client.putNetworkIface(ctx, iface)
}

// ConfigureGuest sends everything that depends on unikernel.Init having run:
// the block devices (their IDs/paths come from the unikernel's
// MonitorBlockCli), the boot source (its boot args are the unikernel command
// line, which bakes in the resolved network), and the vsock device if any.
//
// The child was spawned before changeRoot, so it resolves paths in the
// pre-pivot mount view; monRootfs (the directory the caller will pivot into)
// is therefore prefixed onto every path the child has to open.
func (s *FirecrackerSession) ConfigureGuest(ctx context.Context, args types.ExecArgs, ukernel types.Unikernel, monRootfs string) error {
cfg := buildFirecrackerConfig(args, ukernel)

cfg.Source.ImagePath = filepath.Join(monRootfs, cfg.Source.ImagePath)
if cfg.Source.InitrdPath != "" {
cfg.Source.InitrdPath = filepath.Join(monRootfs, cfg.Source.InitrdPath)
}
for i := range cfg.Drives {
cfg.Drives[i].HostPath = filepath.Join(monRootfs, cfg.Drives[i].HostPath)
}
if cfg.VSock.UDSPath != "" {
cfg.VSock.UDSPath = filepath.Join(monRootfs, cfg.VSock.UDSPath)
}

vmmLog.Debug("staged boot: sending drives, boot-source and vsock")
if err := s.client.putDrives(ctx, cfg.Drives); err != nil {
return err
}
if err := s.client.putBootSource(ctx, cfg.Source); err != nil {
return err
}
return s.client.putVSock(ctx, cfg.VSock)
}

// StartGuest powers on the configured microVM.
func (s *FirecrackerSession) StartGuest(ctx context.Context) error {
vmmLog.Debug("staged boot: sending InstanceStart")
return s.client.startGuest(ctx)
}

// Kill terminates the child and reaps it. For error paths before Supervise.
func (s *FirecrackerSession) Kill() {
_ = s.cmd.Process.Kill()
_, _ = s.cmd.Process.Wait()
}

// Supervise hands the calling process over to the child for the rest of its
// life: it forwards SIGTERM/SIGINT and, once the child exits, exits this
// process with the child's exit code, mirroring the semantics syscall.Exec
// would have had. The caller must not exit before the child, since it is
// the container's init process.
//
// On success this function does not return: it calls os.Exit with the
// child's exit status once the child exits.
func (s *FirecrackerSession) Supervise() error {
// Forward the signals containerd would send to stop the container.
// SIGKILL cannot be caught, so it is not listed here: if it arrives,
// this process dies immediately and the child is left running, a known
// gap for this bounded experiment, not yet handled.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig, ok := <-sigCh
if !ok {
return
}
if sg, ok := sig.(syscall.Signal); ok {
_ = s.cmd.Process.Signal(sg)
}
}()

waitErr := s.cmd.Wait()
signal.Stop(sigCh)
close(sigCh)

exitCode := 0
if waitErr != nil {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
vmmLog.WithError(waitErr).Error("firecracker exited with an unexpected error")
exitCode = 1
}
}
os.Exit(exitCode)
return nil // unreachable
}
Loading