Mumbling about computers

Creating a golden CentOS image

2019-12-24 [ systems-deployment ]

What is involved in creating a disk image that can be dd'd to a disk and boot into a working system? Is it too much? Why is everyone still using kickstart/preseed for images?

From a high-level overview, there are three things that need to be solved:

  • Creating a useful filesystem, containing all the needed software
  • Converting this filesystem to a disk image
  • Making the disk image bootable

Creating a filesystem

This post focuses on CentOS but it should be quite similar for debian-based distros.

What's needed to bootstrap a system? Only what's needed to bootstrap the package manager! We can work our way up from there.

System packages

CentOS provides a centos-release package, which is (conveniently) enough to bootstrap yum:

$ rpm -qlp centos-release-7-7.1908.0.el7.centos.x86_64.rpm

We don't want to install this in the main system though, so we'll create a directory to use as a chroot.

mkdir -p $CHROOT
yum --installroot=$CHROOT install centos-release-7-7

From now on, we can install all system packages that we want with yum, the minimal amount that we need for a reasonable system is: yum --installroot=$CHROOT install yum openssh-server systemd


Where does linux store users and password? /etc/passwd and /etc/shadow, but we don't have those yet so we can't log in.

For a basic demonstration, you could do this:

echo 'root:x:0:0:root:/root:/bin/bash' > $CHROOT/etc/passwd
echo 'root:$6$L08Ghg3slCgBXyYP$jsyibvI2vKpBgm8ikZGRMeqknY6VqNuiy1xXk3uupc8KNwVOcx7yXscmTSVVfnnaB5sFFsKub1SiEyo7ITs3M0:17834:0:99999:7:::' > $CHROOT/etc/shadow

That root password is 'password'. Pay a lot of attention at how I'm using single quotes for /etc/shadow, the type of encryption is determined by $6$ and the character $ is valid for the password hash, so if you use double-quotes, those will be expanded and you'll spend a lot of time trying to figure out what's going on.

Kernel and initramfs

The early boot stage is set-up by the initramfs, which is not currently available in our chroot, and we are also missing a kernel.

Luckily we can just copy these from the bootstrapping machine:

cp /boot/initramfs-$(uname -r).img $CHROOT/boot/initramfs.igz
cp /boot/vmlinuz-$(uname -r)       $CHROOT/boot/vmlinuz

Note how we are leaving the versions out -- these machines will never see an upgrade. We'll just create new images and re-flash them.

Converting the filesystem to an image

Ok, we have files on disk but we need to have a "disk". For a "disk" we need both a partition table and at least a single partition.

How big should our disk be? Let's check how big our chroot is:

$ du -sh $CHROOT
582M /tmp/chroot

Let's create an 800MB disk (could be smaller, but because we'll be flashing this as-is I don't want the inodes to be squashed too close together. I don't know how much this will affect the system after the fs is expanded): dd if=/dev/zero of=disk.img bs=1m count=800

On this disk, we want to create both the partition table and the only partition we'll have.

$ fdisk disk.img

Command (m for help): o
Created a new DOS disklabel with disk identifier 0x42d8352b.

Command (m for help): n
Partition type
   p   primary (0 primary, 0 extended, 4 free)
   e   extended (container for logical partitions)
Select (default p): p
Partition number (1-4, default 1): 1
First sector (2048-1638399, default 2048):
Last sector, +/-sectors or +/-size{K,M,G,T,P} (2048-1638399, default 1638399):

Created a new partition 1 of type 'Linux' and of size 799 MiB.

Command (m for help): a
Selected partition 1
The bootable flag on partition 1 is enabled now.

Command (m for help): w
The partition table has been altered.
Syncing disks.

We have a disk now, but as it's not a real disk, it will not be populated under /dev/sdx (and /dev/sdx1 for the partition), so how do we mount it? We can create a loopback device with the losetup tool:

losetup /dev/loop0 disk.img --offset $((2048*512)), 2048 is the number of the first sector of the partition, and 512 comes from the disk sector size.

The partition now exist, but it has no filesystem on it, that's an easy thing to solve: mkfs.xfs /dev/loop0

We can now mount /dev/loop0 disk_image/ and then rsync -ar $CHROOT/* disk_image/.

Let's unmount the disk and try to boot it with kvm -hda disk.img:

Booting from Hard Disk...

Making the disk image bootable

Why does the image hang? Well, the boot process from BIOS involves looking up what to boot by checking the MBR, which lives in the first 446 bytes of the disk (the first sector), and we have not written anything there yet. See this.

Conveniently, we can get this MBR from GRUB: dd if=/usr/lib/grub/i386-pc/boot.img of=disk.img bs=446 count=1 conv=notrunc

Now we have some code in the MBR specifying that the next code to be execute is present at the address 512 in the disk.

What can we put there? GRUB.

For that, we have to generate a GRUB image with the modules that we want to have available (and we really want to be able to boot xfs), and then flash that starting on the byte number 512 on the disk:

grub2-mkimage -O i386-pc -o ./core.img -p '(hd0,msdos1)/boot/grub' biosdisk part_msdos xfs
dd if=core.img of=disk.img bs=512 seek=1 conv=notrunc

We only build this 3 modules as the space for core.img is reasonably limited (1023.5KB, which is 1024*512-512 bytes for the MBR), but grub still needs to load a few more modules, like the ones used for text rendering and keyboard input, so we have to put those in the filesystem.

Without adding the modules we just get this:

Booting from Hard Disk...
error: file `/boot/grub/i386-pc/normal.mod' not found.
Entering rescue mode...
grub rescue>

Re-mount the disk.img and add the modules to it:

mount /dev/loop0 $TARGET
mkdir -p $TARGET/boot/grub/i386-pc/
cp /usr/lib/grub/i386-pc/* $TARGET/boot/grub/i386-pc/

It boots to grub!

                             GNU GRUB  version 2.02

   Minimal BASH-like line editing is supported. For the first word, TAB
   lists possible command completions. Anywhere else TAB lists possible
   device or file completions.


Ah, yes. We didn't tell grub what to do.. let's add

set timeout=5
menuentry "regular_startup" {
    linux   /boot/vmlinuz quiet root=/dev/sda1
    initrd  /boot/initramfs.igz

to /boot/grub/grub.cfg (grub.cfg is the default name that grub looks for, but /boot/grub is what we passed to grub2-mkimage before)

The grub entry is alive! But..

Finishing touches

When booting, I saw a few units pass by in red before the login prompt popped up, by doing some quick investigation

I can see that /dev/sda1 is mounted readonly. We can remount it as rw so it's not some missing driver, we are just missing /etc/fstab.