Blog

Running Thousands of KVM Guests on Amazon's new i3.metal Instances

A digital chip

The Amazon i3 Family

Amazon has recently released to general availability the i3.metal instance, which allows us to do some things which we could not do before in the Amazon cloud, such as running an unmodified hypervisor. We were able to run more than six thousand KVM virtual machines on one of these instances, far beyond our pessimistic guess of around two thousand. In the remainder of this post we will discuss what makes these platforms important and unique, how we ran KVM virtual machines on the platform using Amazon’s own Linux distribution, and how we measured its performance and capacity using kprobes and the extended Berkeley Packetcpu Filter eBPF .

Read on for details!

i3.metal and the Nitro System

The i3 family platforms include two improvements from what Amazon has historically offered to AWS customers. The first is the combination of the Annapurna ASIC and the Nitro PCI card, which together integrate security, storage, and network I/O within custom silicon. The second improvement is the Nitro hypervisor, which replaces Xen for all new EC2 instance types. Together, we refer to the Nitro card, Annapurna ASIC, and Nitro hypervisor as the Nitro System. (See the EC2 FAQs entry for the Nitro Hypervisor for some additional details.)

Although Amazon has not released much information about the Nitro system there are important technical insights in Brendan Gregg’s blog and in two videos ( here and here ) from the November 2017 AWS re:Invent conference. From these presentations, it is clear that the Nitro firmware includes a stripped-down version of the KVM hypervisor that forgoes the QEMU emulator and passes hardware directly to the running instance. In this sense, Nitro is more properly viewed as partitioning firmware that uses hardware self-virtualization features, including support for nested virtualization on the i3.metal instances.

Nitro protects the Annapurna ASIC and the multi-root PCI hardware from being reprogrammed for the i3.metal systems, but nothing else (this invisible presence is to protect against the use of unauthorized elastic block stores or network access.) For example, while Nitro has no hardware emulation (which is the role of QEMU in a conventional KVM hypervisor), Nitro does enable self-virtualizing hardware (pdf). Importantly, Nitro on the i3.metal system exposes hardware virtualization features to the running kernel, which can be a hypervisor. Thus, a hypervisor such as KVM, Xen, or VMWare can be run directly in an i3.metal instance partitioned by the Nitro firmware.

Image above: Amazon’s i3 platform includes the Annapurna ASIC, the Nitro PCI Card, and the Nitro Firmware. See https://youtu.be/LabltEXk0VQ

Key Virtualization Features Exploited by the Nitro Firmware

Below is a brief, incomplete summary of virtualization features exploited by the Nitro system—particularly in the bare metal instances.

VMCS Shadowing

Virtual Machine Control Structure (VMCS) Shadowing provides hardware-nested virtualization on Intel Processors. The VMCS is a set of registers that controls access to hardware features by a virtual machine (pdf). The first-level hypervisor—in this case the Nitro system—keeps a copy of the second to nth level VMCS and only investigates registers that are different from the cached version. Not every register in the VMCS requires the first level hypervisor to monitor. The Nitro firmware thus provides nested virtualization with no material effect on performance (consuming only a small amount of additional processor resources). If the instance hypervisor does not violate the boundaries established by Nitro, there is no intervention and no effect upon performance.

Most significantly, VMCS shadowing registers are freely available to the kernel running on the bare-metal instance, which is unique for EC2  instances.

Extended Page Tables

Once the hypervisor has established memory boundaries for the virtual machine, Extended Page Tables (EPT) are a hardware feature that allows a virtual machine to manage its own page tables. Enabling this hardware feature produced a two order magnitude of improvement in virtual machine performance on x86 hardware.

Like VMCS shadowing, EPT works especially well with nested hypervisors. The Nitro firmware establishes a page table for the bare-metal workload (Linux, KVM, or another hypervisor.) The bare-metal workload manages its own page tables.

As long as it does not violate the boundaries established by the Nitro firmware, Nitro does not effect the performance or functionality of the bare-metal workload. Nitro’s role on i3.metal workloads prevents the workload from gaining the ability to re-configure the Annapurna ASIC or the Nitro card and violating the limits set for the instance.

Posted Interrupts

The multi-root virtualization capability (pptx) in the i3 instances virtualizes the Amazon Enhanced Networking and Elastic Block Storage (EBS) using PCI hardware devices (Annapurna ASIC and the Nitro card) assigned by the Nitro firmware to specific bare-metal workloads.

Posted interrupts (pdf) allow system firmware to deliver hardware interrupts directly to a virtual machine, when that virtual machine is assigned a PCI function. The Nitro system uses posted interrupts to allow the bare-metal workload to process hardware interrupts generated by the Nitro hardware without any intervention from the Nitro System.

That is, the Annapurna ASIC and Nitro PCI card can interrupt the bare-metal workload directly, while remaining protected from re-configuration by the bare metal workload. There are no detrimental effects on performance as long as the Nitro System does not over-provision CPUs, which it does not do. (The bare-metal workload may, even if it is a hypervisor, as we will see below in the limited testing we did)

Loading KVM on a Bare Metal Instance

On an EC2 Bare Metal system (i3.metal in the screen grab above), Nitro is hardware partitioning firmware. The Nitro firmware is based on KVM and does not use hardware emulation software (such as QEMU). It does initialize the custom Amazon hardware and pass-through hardware to the running instance: networking, storage, processors, PCI trees, and memory. It then jumps into the bare-metal instance kernel, which in our testing was Amazon Linux. (Amazon also supports the VMware Hypervisor as a bare-metal instance)

The Nitro firmware only activates if the bare-metal kernel violates established partitioning. The fact that the Nitro firmware is actually Linux and KVM is not new: Linux has been used as BIOS for many years for complex systems that consolidate networked or shared resources for hardware platforms.

Passing-through the VMX flag and Running Nested Virtualization

The Bare Metal kernel sees the vmx flag when it inspects /proc/cpuinfo:

grep -E "(vmx|svm)" /proc/cpuinfo | wc -l
72

This flag is necessary in order to load KVM. It indicates that the Virtual Machine Control Structure (VMCS) is programmable by the Linux-KVM kernel. VMCS Shadowing makes this possible; it uses copy-on-write methods and register caching in the processor itself to run each layer in the stack (Nitro, KVM, and the Virtual Machine) directly on the processor hardware. Each layer is controlled by the layer beneath it.

The i3.metal systems use register caching and snooping to provide hardware-virtualized processors to each layer in the system, beginning with the Nitro System, up to virtual machines being run by the bare-metal instance (KVM in this case).

The Nitro firmware does not use QEMU because it does not emulate any hardware. In our testing, we did use QEMU hardware emulation in the upper layer virtual machines. This resulted in the picture below, where the Nitro firmware is running beneath the i3 instance kernel. We then loaded KVM, and used QEMU to provide hardware emulation to the virtual machines:

When running a hypervisor such as KVM on the i3.metal systems, each layer has direct access to the processor through VMCS Shadowing, which provides each layer with the Virtual Machine Control registers.

Installing KVM on an Amazon Linux Image

The Amazon Linux distribution is derived from Fedora Linux with KVM available as two loadable modules. (KVM is maintained and supported by Amazon as a standard feature of the bare metal instance.)

Some components need to be installed, for example QEMU:

[ec2-user@ip-10-0-6-93 ~]$ yum list | grep qemu-kvm
qemu-kvm.x86_64                       10:1.5.3-141.6.amzn1          @amzn-updates
qemu-kvm-common.x86_64                10:1.5.3-141.6.amzn1          @amzn-updates
qemu-kvm-tools.x86_64                 10:1.5.3-141.6.amzn1          @amzn-updates

Libvirt is not part of the Amazon Linux distribution, which saves cost . We do not need Libvirt, and it would get in the way of later testing.

[ec2-user@ip-10-0-6-93 ~]$ yum list | grep -i Libvirt | wc -l
0

Libvirt is an adequate collection of software, but qemu-kvm is not aware of it, meaning the virtual machine state information stored by Libvirt may be out of sync with qemu-kvm . Libvirt also provides an additional attack vector to KVM while providing little additional functionality over what is provided by standard Linux utilities and kernel features, with qemu-kvm.

Built-in Processor Support for KVM

The i3.metal instance has 72 threads running on 36 physical cores that support KVM and posted interrupts. This information may be read in /proc/cpuinfo: 

grep -E "(vmx|svm)" /proc/cpuinfo | wc -l 
72

Loading KVM on the Nitro system is most easily done by  modprobe‘ing the KVM modules:

[ec2-user@ip-10-0-6-93 ~]$ sudo modprobe kvm-intel

[ec2-user@ip-10-0-6-93 ~]$ lsmod | grep kvm
kvm_intel             183379  0
kvm                   562462  1 kvm_intel
irqbypass               3903  1 kvm

The irqbypass  module provides posted interrupts to KVM virtual machines, reminding us again that we may pass PCI devices present on the bare-metal host through to KVM virtual machines.

Built-in virtio virtual I/O at the Linux Kernel Level

virtio  is a Linux kernel i/o virtualization feature: it is maintained and supported by Amazon and that it works with qemu-kvm  to provide isolated (not shared as in Xen’s dom0  netback and blockback) virtual i/o devices for virtual machines that do not need direct access to a hardware PCI device. Each virtio  device is a unique and private virtual PCI device with separation provided by the Linux kernel.

The Amazon Linux kernel supports virtio devices, as shown by this excerpt of the Amazon Linux configuration file:

# CONFIG_VIRTIO_VSOCKETS is not set
CONFIG_VIRTIO_BLK=m
CONFIG_SCSI_VIRTIO=m
CONFIG_VIRTIO_NET=m
CONFIG_VIRTIO_CONSOLE=m
CONFIG_HW_RANDOM_VIRTIO=m
# CONFIG_DRM_VIRTIO_GPU is not set
CONFIG_VIRTIO=m
# Virtio drivers
CONFIG_VIRTIO_PCI=m
CONFIG_VIRTIO_PCI_LEGACY=y
# CONFIG_VIRTIO_BALLOON is not set
# CONFIG_VIRTIO_INPUT is not set
CONFIG_VIRTIO_MMIO=m
# CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES is not set

Kernel Shared Memory (KSM)

KSM is a Linux kernel feature that scans memory pages, merges duplicates, marks those pages as read-only, and copies the pages when they are written (COW).  KSM provides a kernel-level mechanism for over-provisioning memory. KSM is automatic, built in, and does not require an external module as Xen does, for example, with its Dom0 balloon driver.

KSM is documented in the Linux kernel documentation directory.

The Amazon Linux kernel is configured with KSM:

[ec2-user@ip-10-0-6-93 ~]$ cat /boot/config-4.9.77-31.58.amzn1.x86_64 | grep -i ksm
CONFIG_KSM=y

Running a KVM virtual machine with copy-on-write memory is straightforward, by starting the virtual machine with the mem-merge feature turned on: 

$ sudo /x86_64-softmmu/qemu-system-x86_64 
-enable-kvm -m 1G 
...
-chardev stdio,id=mon0 
-mon chardev=mon0,mode=readline 
-machine mem-merge=on

QEMU 1.7.50 monitor - type ’help’ for more information

Using the -machine mem-merge=on  command upon virtual machine startup causes QEMU to execute an madvise system call with the MADV_MERGEABLE parameter for the virtual machine memory, marking the VM memory as merge-able.

To disable merging for a virtual machine upon startup, use the same command but substitute mem-merge=off . 

Running the KVM Virtual Machine

We created a virtual machine using a minimal Linux distribution: TTY Linux. It has an image built specifically to run with KVM using virtio  network and block devices.

We ran KVM Linux virtual machines using this command line:

sudo /usr/libexec/qemu-kvm --enable-kvm -name ttylinux -m 1G -hda ttylinux.qcow2 --cdrom ttylinux-virtio_x86_64-16.1.iso -chardev stdio,id=mon0 -mon chardev=mon0,mode=readline
QEMU 1.5.3 monitor - type 'help' for more information
(qemu) VNC server running on `127.0.0.1:5900'

Only three steps are required to create the virtual machine:

  1. Download the TTY Linux distribution and unzip to an iso image:
[ec2-user@ip-10-0-6-93 ~]$ wget https://www.dropbox.com/s/dumum2xsajzvjvw/ttylinux-virtio_x86_64-16.1.iso.gz
https://www.dropbox.com/s/dumum2xsajzvjvw/ttylinux-virtio_x86_64-16.1.iso.gz
Resolving www.dropbox.com (www.dropbox.com)... 162.125.6.1, 2620:100:601c:1::a27d:601
Connecting to www.dropbox.com (www.dropbox.com)|162.125.6.1|:443... connected.
...
ttylinux-virtio_x86_64-16.1.is 100%[====================================================>]  41.62M  9.21MB/s   in 4.5s   
...
[ec2-user@ip-10-0-6-93 ~]$ gunzip ttylinux virtio_x86_64-16.1.iso.gz
  1. Create the qcow disk image for the virtual machine:
qemu-img create -f qcow2 ttylinux.qcow2 1G
Formatting 'ttylinux.qcow2', fmt=qcow2 size=1073741824 encryption=off cluster_size=65536 lazy_refcounts=off 
  1. Run the virtual machine:
sudo /usr/libexec/qemu-kvm --enable-kvm -name ttylinux -m 1G -hda ttylinux.qcow2 --cdrom ttylinux-virtio_x86_64-16.1.iso -chardev stdio,id=mon0 -mon chardev=mon0,mode=readline
QEMU 1.5.3 monitor - type 'help' for more information
(qemu) VNC server running on `127.0.0.1:5900'

We were struck by how easy it was to run KVM virtual machines on these Nitro systems, configured as they are with Amazon Linux. Each virtual machine in our testing had 1G of memory and 1G of writeable storage.

numactl and other Linux Process Control

A benefit of  KVM on i3.metal is the ability to use standard Linux system calls to control virtual machine resources. A good example is using the Linux numactl  command to allocate CPU cores for a kvm virtual machine: 

#!/usr/bin/bash
numactl --physcpubind=1 /usr/bin/qemu-system-x86_64 
 -enable-kvm -name ttylinux -m 1G 
 -hda /var/lib/libvirt/filesystems/ttylinux.qcow 
 --cdrom /var/lib/libvirt/filesystems/ttylinux-virtio_x86_64-16.1.iso 
 -vnc 10.0.1.5:1 -chardev stdio,id=mon0 
 -monitor stdio

The above command uses numactl utility to bind the KVM virtual machine to Core #1.  It demonstrates how integrated KVM is with the Linux kernel and how simple it is to allocate memory and cores to specific virtual machines.

Integration with the Linux Kernel: cgroups, nice, numactl, taskset

We can turn the Linux kernel into a hypervisor by loading the KVM modules and starting a virtual machine, but the Linux personality is still there. We can control the virtual machine using standard Linux resource and process control tools such as cgroups, nice, numactl, and taskset :

[ec2-user@ip-10-0-6-93 ~]$ numactl -s
policy: default
preferred node: current
physcpubind: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 
cpubind: 0 1 
nodebind: 0 1 
membind: 0 1 

All cgroup  commands work naturally with KVM virtual machines. As far as cgroups is concerned, each KVM virtual machine is a normal Linux process (although KVM runs that process at the highest privilege level in VMX guest mode (pptx), which provides hardware virtualization support directly to the virtual machine). There are two utilities to bind a KVM virtual machine to a specific processor, NUMA node, or memory zone: taskset  and numactl .

In summary, the Linux command set along with qemu-kvm  allows us native control over processors, memory zones, and other platform properties for to running KVM virtual machines. Libvirt, on the other hand, is a layer over these native control interfaces that tends to obscure what is really going on at the hardware level.

Testing the Limits of  Bare-Metal AWS Hypervisor Performance

To more securely run virtual-machine workloads on cloud services, we accessed a bare-metal instance for project research during the preview period. We wanted to first verify that KVM can be used as a hypervisor on EC2 bare-metal instances, and second, get a read on stability and performance. We had limited time for this portion of the research.

To measure system response, we decided to use the BPF Compiler Collection (BCC) (building and using this toolset may be the subject of another blog post).

BCC uses the extended Berkeley Packet Filter, an amazing piece of technology in recent Linux Kernels that runs user-space byte code within kernel space. BCC compiles byte code that uses dynamic kernel probes to instrument kernel behavior.

To test CPU load, we added a simple shell script to each VM’s init process:

while [[ 1 ]]; do :; done

This ensured that each virtual machine would be consuming all the CPU cycles allowed to it by KVM.

Next, we used a simple shell script to start KVM virtual machines into oblivion:

while [[ 1 ]]; do 
sudo /usr/libexec/qemu-kvm --enable-kvm -name ttylinux -m 1G -hda ttylinux.qcow2;
done

Then we ran the BCC program runqlat.py, which measures how much time processes are spending on the scheduler’s run queue – a measure of system load and stability. The histogram below shows the system when running 6417 virtual machines.

[ec2-user@ip-10-0-6-93 tools]$ sudo ./runqlat.py      
Tracing run queue latency... Hit Ctrl-C to end.
^C
usecs               : count     distribution
         0 -> 1          : 897      |*                                       |
         2 -> 3          : 4700     |*****                                   |
         4 -> 7          : 34035    |****************************************|
         8 -> 15         : 25067    |*****************************           |
        16 -> 31         : 6939     |********                                |
        32 -> 63         : 9622     |***********                             |
        64 -> 127        : 8046     |*********                               |
       128 -> 255        : 4801     |*****                                   |
       256 -> 511        : 1736     |**                                      |
       512 -> 1023       : 635      |                                        |
      1024 -> 2047       : 913      |*                                       |
      2048 -> 4095       : 1767     |**                                      |
      4096 -> 8191       : 2031     |**                                      |
      8192 -> 16383      : 1841     |**                                      |
     16384 -> 32767      : 900      |*                                       |
     32768 -> 65535      : 249      |                                        |
     65536 -> 131071     : 201      |                                        |
    131072 -> 262143     : 109      |                                        |
    262144 -> 524287     : 51       |                                        |

The histogram above demonstrates how long, within a range, each sampled process waited on the KVM scheduler’s run queue before it was actually placed on the processor and run. The wait time in usecs shows how long a process that is runnable (not sleeping or waiting for any resources or events to occur) waited in order to run. There are three things to look for in this histogram:

  1. How closely grouped are the sampled wait times? Most processes should be waiting approximately the same time. This histogram shows this is the case, with close to half samples waiting between 4 and 15 microseconds.
  2. How low are the wait times? On a system that is under-utilized, the wait times should be mostly immaterial (just a few microseconds or less on this hardware). This system is over-utilized, and yet the wait times for most of the samples are fewer than 15 microseconds.
  3. How scattered are the samples in terms of wait times? In this histogram there are two groups: the larger group with wait times less than 511 microseconds, and the smaller group with wait times between 1024 and 32767 microseconds. The second group consists of only roughly 7% of samples. We would expect a distressed system to show several different groups clustered around longer wait times, with outliers comprising more than 7% of all samples.

Upon reaching 6417 virtual machines, the system was unable to start any new VMs, due to memory exhaustion. However we were able to ssh to running VMs; when we stopped a VM, KVM started a new one. This system appeared to be capable of running indefinitely with this extreme load placed upon the CPU resources.

CPU and Memory Over-provisioning

When fully loaded with virtual machines, CPUs were overloaded 10:1 virtual cycles to physical cycles. There were more than thirty thousand processes running on the system, and it was actively reclaiming memory using KSM (discussed above). Before running the tests, the consensus among our team was that perhaps we could run 2K virtual machines before the system fell apart. This guess (that’s all it was) proved to be overly pessimistic. (However, we did not test I/O capacity in any significant way.)

Beyond proving that we could run a hell of a lot of virtual machines on the i3.metal platform, and that CPU over provisioning was wickedly efficient, we didn’t accomplish much else; for example, we can conclude nothing about the I/O performance of the system. But these are rich grounds for further performance and limit testing using the BCC toolkit, which we hope to discuss in a later blog post.