Linux distributions usually ship with an installer. This installer is then flashed onto a pendrive / optical medium, and the system boots from this device to install the operating system. A linux installer will generally perform the following tasks:
- Partition your disk
- Copy root filesystem contents
chroot
into the system, and run scripts to install packages, and setup the system- Prepare kernel, initrd/initramfs, and install the bootloader
Depending on the requirements, a system may support multiple types of booting, such as booting from the master boot record (aka legacy boot), or booting via the Unified Extensible Firmware Interface (aka UEFI). EFI is a special beast on its own, so I won't go into details about how to prepare a system for EFI installation.
What I will show in this article is how to perform the tasks an operating system installer does, without an installer. This allows you to create bootable virtual machine disks (that can be flashed for physical servers as well). I will also show how to create a container image from a root filesystem archive.
The Ubuntu Base archive
The Ubuntu Base archive contains a root filesystem for a complete functional Ubuntu system. It is meant to be a base for building container images, or cloud virtual machines. If you have a Linux OS running, you can download the base archive, extract it, and chroot
into it (after a bit of a setup) to start playing around with Ubuntu:
#!/bin/bash
wget https://cdimage.ubuntu.com/ubuntu-base/releases/22.04/release/ubuntu-base-22.04-base-amd64.tar.gz
sudo mkdir -p /mnt/ubuntu.22.04
sudo tar -xvf ubuntu-base-22.04-base-amd64.tar.gz -C /mnt/ubuntu.22.04
sudo mkdir -p /mnt/ubuntu.22.04/etc
sudo cp /etc/resolv.conf /mnt/ubuntu.22.04/etc/resolv.conf
sudo mkdir -p /mnt/ubuntu.22.04/proc
sudo mkdir -p /mnt/ubuntu.22.04/sys
sudo mkdir -p /mnt/ubuntu.22.04/dev/pts
sudo mkdir -p /mnt/ubuntu.22.04/run
sudo mount -t proc none /mnt/ubuntu.22.04/proc
sudo mount -t sysfs none /mnt/ubuntu.22.04/sys
sudo mount --bind /dev /mnt/ubuntu.22.04/dev
sudo mount --bind /dev/pts /mnt/ubuntu.22.04/dev/pts
sudo mount --bind /run /mnt/ubuntu.22.04/run
sudo chroot /mnt/ubuntu.22.04
You can exit the chroot
by pressing Ctrl+C
, or running the exit
command inside the chroot
. To cleanup the chroot, use the following command:
#!/bin/bash
sudo umount -R /mnt/ubuntu.22.04/*
sudo rm -rf /mnt/ubuntu.22.04/
Creating a virtual machine disk
The rootfs in itself is not very useful, as in order to be able to boot into the operating system, we need a few other pieces as well:
- We need the Linux kernel,
- We need the initramfs,
- And we need a bootloader
A physical disk also needs to be partitioned, before it can be consumed by a BIOS. I will show you how to create a bootable image, which uses the legacy boot procedure.
- First, we will create an empty disk file
- We will then create a loop device for the disk file
- We will create a partition table in the drive image
- We will then create loop devices for each partition
- Then we will create the filesystems in each partition
- Then we will mount the filesystems on the partitions to our host Linux
- We will then copy files to the filesystems
- Finally, we will install the MBR of our bootloader onto the disk image
Creating an empty disk image
You can use the dd
command to create an image. I chose to create a 4GiB (4 * 1024 * 1024 * 1024 bytes) image with the name ubuntu.img
in the current working directory.
#!/bin/bash
dd if=/dev/zero of=ubuntu.img bs=1M count=4096
Using loop devices
Loop devices allow us to access files as if they were block devices. This gives a transparent layer for partitioning, and filesystem management tools to access not only physically connected storage devices, but also allow virtual disks to be manipulated. The heart and soul of loop devices is the losetup
command, which can be ran to create a loop device for a file.
#!/bin/bash
sudo losetup --find --show ubuntu.img
This command will output the name of your loop device, such as /dev/loop0
. Now, instead of doing /dev/sda
or /dev/sdaX
for commands such as parted
, we will instead use /dev/loop0
and /dev/loop0pX
.
Creating a partition table
I have used this process in the context of embedded devices, where resources are constrained. A partition table for a server in a data center, or a personal computer will differ from what I will show you, and I also have to point out, that this method does not apply any encryption either, meaning it is not the most secure approach possible. Nevertheless, I will create the following partition table:
- 16 MiB of empty space at the beginning of the disk
- 512 MiB FAT32 partition for the bootloader, and kernel, and the initramfs
- Rest is partitioned (~3.5GiB) for the root filesystem, as an
ext4
filesystem
You can use the parted
tool to create the partition table. The -s
flag is used to run parted
in script mode, allowing me to specify the partition layout in a single command.
#!/bin/bash
sudo parted -s /dev/loop0 mklabel gpt \
mkpart primary fat32 16MiB 512MiB \
mkpart primary ext4 512MiB 100% \
set 1 boot on \
set 1 legacy_boot on
Creating the filesystems
We will now create the filesystems in our partitions. Since we are using a GPT partition table, Linux will pick up the partitions, and create matching loop devices. For this, we have to first detach the current loop device:
#!/bin/bash
sudo losetup -d /dev/loop0
Now, we can use the -P
flag on losetup
to execute the partition scan when setting up the loop device:
#!/bin/bash
sudo losetup --find --show -P ubuntu.img
This command should have created the /dev/loopXp1
and /dev/loopXp2
devices. You can check this by running ls -la /dev | grep loop
, and inspecting the output. In my example, the /dev/loop0p1
is my FAT32 partition, and /dev/loop0p2
is my ext4 partition.
$ ls -la /dev | grep loop
crw------- 1 root root 10, 237 Aug 19 07:29 loop-control
brw------- 1 root root 7, 0 Aug 19 08:27 loop0
brw------- 1 root root 259, 2 Aug 19 08:29 loop0p1
brw------- 1 root root 259, 3 Aug 19 08:29 loop0p2
brw------- 1 root root 7, 1 Aug 19 07:29 loop1
brw------- 1 root root 7, 2 Aug 19 07:29 loop2
brw------- 1 root root 7, 3 Aug 19 07:29 loop3
brw------- 1 root root 7, 4 Aug 19 07:29 loop4
brw------- 1 root root 7, 5 Aug 19 07:29 loop5
brw------- 1 root root 7, 6 Aug 19 07:29 loop6
brw------- 1 root root 7, 7 Aug 19 07:29 loop7
You can run the mkfs
command to create the filesystems in these loop devices:
#!/bin/bash
sudo mkfs -t vfat /dev/loop0p1
sudo mkfs -t ext4 /dev/loop0p2
Mounting the filesystems
After the filesystems have been created, we can mount them into our running Linux host. I will create a single directory for mounting the root filesystem, and I will mount the boot partition inside this directory. This is required so that when installing a kernel in chroot, it is placed on the correct partition.
After the directories are created, it is the matter of a simple mount
command to mount these filesystems.
#!/bin/bash
sudo mkdir -p /mnt/ubuntu.22.04.root
sudo mkdir -p /mnt/ubuntu.22.04.root/boot
sudo mount -t ext4 /dev/loop0p2 /mnt/ubuntu.22.04.root
sudo mount -t vfat /dev/loop0p1 /mnt/ubuntu.22.04.root/boot
You are now ready to copy files into your drive image!
Copying files
Using a simple tar
command, we can extract the contents of the root filesystem into /mnt/ubuntu.22.04.root
.
#!/bin/bash
sudo tar -xvf rootfs.tar -C /mnt/ubuntu.22.04.root
A proper operating system installer would at this point chroot
into the root filesystem, and use scripts, and the package manager to setup the system. We will also do the same, in order to acquire a kernel for our image. This chroot
ing will also need a few preparation commands as well.
#!/bin/bash
sudo mkdir -p /mnt/ubuntu.22.04.root/etc
sudo cp /etc/resolv.conf /mnt/ubuntu.22.04.root/etc/resolv.conf
sudo mkdir -p /mnt/ubuntu.22.04.root/proc
sudo mkdir -p /mnt/ubuntu.22.04.root/sys
sudo mkdir -p /mnt/ubuntu.22.04.root/dev/pts
sudo mkdir -p /mnt/ubuntu.22.04.root/run
sudo mount -t proc none /mnt/ubuntu.22.04.root/proc
sudo mount -t sysfs none /mnt/ubuntu.22.04.root/sys
sudo mount --bind /dev /mnt/ubuntu.22.04.root/dev
sudo mount --bind /dev/pts /mnt/ubuntu.22.04.root/dev/pts
sudo mount --bind /run /mnt/ubuntu.22.04.root/run
sudo chroot /mnt/ubuntu.22.04.root
Now, inside the chroot, use apt
to update the package catalog, and install a linux kernel. I will also install a few other packages, that will make life easier, and will be required for bootloader installation as well.
#!/bin/bash
apt -y update
apt -y install linux-headers-generic linux-image-generic nano whois extlinux syslinux-common
Installing the bootloader
There are many bootloaders you could use to boot Linux, for simple use-cases I prefer to use SysLinux. Specifically, I will use extlinux
, which is a boot loader, that can load the configuration, and the kernel from a FAT32/ext2/ext3/ext4 filesystem. Since we are using the GPT partitioning scheme, SysLinux will automatically find the partition marked with the legacy_boot
flag, and assume that our config resides on this partition. I will perform the bootloader installation inside a chroot on the drive image itself.
If you have not yet cleaned up the previous chroot, you can use it again. The following commands will create a bootloader config, install the bootloader, and install the MBR of the bootloader, all inside a chroot.
#!/bin/bash
# Write a setup script for running inside the chroot
cat > /mnt/ubuntu.22.04.root/setup.sh <<EOF
mkdir -p /boot/extlinux
cat > /boot/extlinux/extlinux.conf <<EOF2
DEFAULT mylinux
LABEL mylinux
KERNEL /boot/vmlinuz
INITRD /boot/initrd.img
APPEND root=/dev/sda2 console=ttyS0,9600 console=tty0 ro splash
EOF2
# Install extlinux
extlinux --install /boot/extlinux
# Install extlinux MBR
dd bs=440 count=1 conv=notrunc /usr/lib/syslinux/mbr/gptmbr.bin of=/dev/loop0
EOF
# Make setup script runnable
chmod 777 /mnt/ubuntu.22.04.root/setup.sh
# Run setup script in chroot
chroot /mnt/ubuntu.22.04.root /bin/bash /setup.sh
# Delete setup script
rm /setup.sh
At this point, your drive image should be ready. You should now cleanup your environment, and detach all loop devices. I like to do the following:
#!/bin/bash
sync
umount -R /mnt/ubuntu.22.04.root
losetup -D
Trying out the image
You can use qemu
to boot up your new operating system image. For example, you can use the following command to boot up a virtual machine:
qemu-system-x86_64 -device virtio-scsi-pci,id=scsi \
-drive file=ubuntu.img,id=drive,if=none,format=raw \
-device scsi-hd,drive=drive \
-m 1G \
-display curses