2018-10-20

The Quest for a Small Windows 10 Installation

There are a number of tutorials online for installing "minimal" versions of Windows XP and Windows 7. These guides aim to create a reduced set of binaries and features for Windows that will fit in typically a few hundred megabytes of storage. There are not many tutorials for "how to install a minimal version of Windows 10" and what I did find were mostly a hodge-podge of one-liners on Reddit. We can do better.

From time to time I want to set up a Windows VM. In particular, I want to set up a short-lived Windows VM that will run some software or do some legacy task, and then I tear it down and go on my merry way. I used to use the free Windows VMs that Microsoft provides for browser testing and they're decent VMs, but they are huge. I have tried, with limited success, to shrink this .VHDX file so I can run more than two or three of them at once. Even if I only use one Edge testing VM, I have this big old 18+ GiB .VHDX file laying around that I have to keep because it has my settings on it and is already configured the way I want it. But it's huge!

I uninstalled programs. I reordered the files to the front of the virtual disk with a defragmenting application. I zeroed out the freespace with an old version of SDelete (SDelete v2.0 has substantially worse performance than v1.61). I ran the Optimize-VHD PowerShell cmdlet. Some of this made reductions here and there, but these gains were relatively minor. There's just too much "stuff" in the free VM that Microsoft offers.

The best thing for a tiny Windows install is Windows Server Nano, which is a even-more-stripped-down version of Server Core that has all the UI and graphics goo removed. Server Nano is just a kernel, a shell, and a remote management endpoint so you can assign it work. It's great! It's tiny! It's exactly what I want! But, it sucks for gaming and it's basically discontinued as of 2017! Technically, you could say that Nano Server is having its "scope limited" to just container applications, but that's the same thing in my book. Sigh. OK. I couldn't handle the price tag of buying a Windows Server licence anyway.

So I kept on using the free VMs, updating them periodically as new versions of Windows 10 were released, and wondering if there was a better way, a way to run Windows 10, for free, in a VM that doesn't eat half the space of an SSD drive that I could instead be using to store cat videos and episodes of Pushing Daisies.

And there is a better way. In fact, Microsoft has a better way and gives it away for free, kinda. They took Windows 10, an OK operating system, and pulled all the garbage out of it that people don't want: it has no Cortana integration, no Store integration, no Edge integration. It doesn't automatically try to upgrade itself to The Next Thing every six months. It's called "Windows 10 Enterprise LTSB", or "Enterprise S", or now "Enterprise LTSC", and it's Windows the way Windows was meant to be. I just wish they'd promoted this version of Windows more than "not at all whatsoever" before I chose to leave Windows World and find something better.

The following process is how I set up a Windows 10 VM from scratch. Your actual mileage may vary. My goal in performing these steps is to produce the smallest Windows 10 VM I can. There is a trade-off between small size and other system metrics, like performance and security. In this case, I don't mind an unoptimized VM if it is tiny. If I really wanted performance from my Windows 10 install, I wouldn't virtualize it in the first place.

  1. Fetch the latest Windows 10 Enterprise LTSB install ISO. This can be found on the Microsoft Evalution Center. You want the "ISO - LTSB" version; if you pick "ISO - Enterprise" it will prompt you for registration information and not give you what you want.

  2. When you have the ISO downloaded, create your VM, attach the ISO as a virtual CD/DVD, and boot. A quick way to do this in an elevated PowerShell window is like so:

    $ErrorActionPreference = 'Stop'
    Set-StrictMode -Version 2
    
    $vm_name          = 'win10-ltsb'
    $iso_path         = 'C:\Path\to\14393.0.160715-1616.RS1_RELEASE_CLIENTENTERPRISE_S_EVAL_X64FRE_EN-US.ISO'
    $proc_count       = 2
    $vhd_length       = 32   * [Math]::Pow(2,30) # GiB
    $memory_min_bytes = 4096 * [Math]::Pow(2,20) # MiB
    $memory_max_bytes = 4096 * [Math]::Pow(2,20) # MiB
    
    $vhd_file_name = "{0}-osDisk.vhd" -f ($vm_name)
    $vm_vhd_path   = [System.IO.Path]::Combine(${Env:PUBLIC}, 'Documents', 'Hyper-V', 'Virtual hard disks', $vhd_file_name)
    
    # Create a new VHD and VM:
    $vhd_properties = @{
      'Path'      = $vm_vhd_path;
      'SizeBytes' = $vhd_length;
      'Dynamic'   = $True;
    }
    New-VHD @vhd_properties | Out-Null
    
    $vm_create_properties = @{
      'Generation'         = 1;
      'Name'               = $vm_name;
      'MemoryStartupBytes' = $memory_min_bytes;
      'VHDPath'            = $vm_vhd_path;
    }
    New-VM @vm_create_properties

  3. Remove the default unconfigured virtual network adapter and virtual DVD drive and add your own. Then, start the VM:

    $vm_net_adapter = @{
      'VMName'     = $vm_name;
      'SwitchName' = (Get-VMSwitch | Select-Object -First 1 -ExpandProperty Name);
    }
    Remove-VMNetworkAdapter -VMName $vm_name
    Add-VMNetworkAdapter @vm_net_adapter
    
    $vm_dvd_properties = @{
      'VMName'             = $vm_name;
      'ControllerNumber'   = 1;
      'ControllerLocation' = 0;
      'Confirm'            = $False;
    }
    Remove-VMDvdDrive @vm_dvd_properties
    
    $vm_dvd_properties['Path'] = $iso_path
    
    Add-VMDvdDrive @vm_dvd_properties
    $vm_set_properties = @{
      'Name'               = $vm_name;
      'DynamicMemory'      = $True;
      'MemoryMinimumBytes' = $memory_min_bytes;
      'MemoryMaximumBytes' = $memory_max_bytes;
      'ProcessorCount'     = $proc_count;
      'CheckpointType'     = 'Disabled';
    }
    Set-VM @vm_set_properties
    
    Start-VM -VMName $vm_name

  4. Connect to the VM and let it boot to the installer GUI. When you get to the "Install Now" window, press Shift+F10. This will open a command prompt window that we'll use to install Windows instead of using the GUI.

  5. Prepare your virtual disk with "diskpart". This will completely destroy any data on your virtual disk, so make sure you really want to format your VHD before you continue. Don't do this on a real system that has important data on it. The commands to run are:

    X:\sources> diskpart
      DISKPART> select disk 0
      DISKPART> clean
      DISKPART> create part pri
      DISKPART> select part 1
      DISKPART> active
      DISKPART> format quick
      DISKPART> assign letter=c
      DISKPART> exit

    CAVEAT: This partition scheme is incompatible with BitLocker disk encryption. Encryption of a VM is beyond the scope of this tutorial. For this VM, speed and at-rest security are secondary to a small VM footprint. If you wish, you can typically enable BitLocker encryption on your VMs after you have completed installation, and the manage-bde.exe utility will repartition your disk for you, if it can, as needed.

    CAVEAT: This creates an MBR partition on your VHD, which restricts it to running in Hyper-V as a Gen 1 VM. If you want your VM to be Gen 2 or to support UEFI, you'd need to adjust your diskpart commands to create an EFI boot partition. Since this might impact VHD size, I am avoiding such a configuration. For the curious, try something like this:

    REM For people who want a UEFI-friendly Gen 2 VHD layout:
    X:\sources> diskpart
      DISKPART> select disk 0
      DISKPART> clean
      DISKPART> convert gpt
      DISKPART> create part efi size=128
      DISKPART> format fs=fat32 quick
      DISKPART> create part pri
      DISKPART> format fs=ntfs quick
      DISKPART> assign letter=c
      DISKPART> list vol
      DISKPART> exit

    Remember to create the VM as a Gen 2 VM in the first place.

  6. There's a trick to getting a minimal install that allows us to install the Windows OS in a transparently-compressed format. Install Windows with DISM, using the /Compact argument:

    dism.exe /Apply-Image /ImageFile:D:\sources\install.wim /Index:1 /Compact /ApplyDir:C:\

    NOTE: You can compact the Windows base OS binaries after you've installed with compact.exe. Run the first command to check if your binaries have already been compressed, and if not, run the second command:

    C:\Windows\System32\compact.exe /CompactOS:query
    C:\Windows\System32\compact.exe /CompactOS:always

    In lieu of installing to the VHD and then compressing the OS, we compress and install at the same time.

    NOTE: If we were using a Windows install ISO that contained multiple editions on it (Pro, Home, Education, et cetera) on it, we'd need to adjust the /Index:N value to match the order of the edition we wanted. The LTSB install ISO does not include multiple editions, so "/Index:1" works. If you want to inspect your install media and see which editions/indexes are available to you, run:

    dism.exe /Get-ImageInfo /ImageFile:D:\sources\install.wim

    NOTE: The name of the .WIM file may also vary from ISO to ISO depending on which version of Windows you're installing, so you can look for the biggest file in the D:\sources directory by running dir /s D:\sources | find "install.". The file name may not even end in .WIM, but it's going to be the only file that's 3+ gigs in size.

  7. Configure the bootloader:

    bcdboot C:\Windows

  8. Exit the command prompt, stop the VM, and eject the install media. I like to stop the VM after I close the command prompt by clicking "Repair your computer" and then "Turn off your PC". Most other options in the GUI just restart your machine, but this method actually halts it for real.

    If you want to make a backup of the .VHD file now, you can. This VHD is smaller than a normal Windows install and usable as-is, but it can stand to get smaller.

  9. We can open up the VHD before before we start it for the first time and make some configuration changes:

    $vhd_obj           = Mount-VHD -Path $vm_vhd_path -PassThru
    $part_obj          = Get-Partition -DiskNumber $vhd_obj.DiskNumber
    $drive_letter      = $part_obj.DriveLetter + ':'
    $vhd_system32_path = "{0}:\Windows\System32" -f ($part_obj.DriveLetter)
    $vhd_sysprep_path  = [System.IO.Path]::Combine($vhd_system32_path, 'Sysprep')
    $vhd_system_hive   = [System.IO.Path]::Combine($vhd_system32_path, 'config', 'SYSTEM')

  10. Mount the registry of the fledgling new VM and hardcode its pagefile and swapfile maximum sizes to 256 MiB:

    reg.exe load HKLM\VHDSYSTEM $vhd_system_hive
    
    reg.exe add "HKLM\VHDSYSTEM\ControlSet001\Control\Session Manager\Memory Management" /f /v PagingFiles /t REG_MULTI_SZ /d "?:\pagefile.sys 256 256"

    256 MiB is a comfortable compromise to me between making the OS image slim and making the OS unusable. Windows will set the swapfile to be at least 256MiB anyways if you try to make it smaller than that.

  11. Implement the Spectre registry key fix:

    reg.exe add "HKLM\VHDSYSTEM\ControlSet001\Control\Session Manager\Memory Management" /v FeatureSettingsOverride /t REG_DWORD /d 8 /f
    
    reg.exe add "HKLM\VHDSYSTEM\ControlSet001\Control\Session Manager\Memory Management" /v FeatureSettingsOverrideMask /t REG_DWORD /d 3 /f
    
    reg.exe unload HKLM\VHDSYSTEM

  12. Write an unattend file. This is non-trivial and could make for a series of blog posts in its own right.

    Back in September of 2001 I began a project of writing documentation for building a comprehensive unattended network installation process for Windows NT 4.0 Workstation. I took this idea on as a personal challenge: how does one take 12 IBM computers with network access and consistently format them and re-install them using only a network share, some DOS Ethernet drivers, and a floppy disk? It can be done, even with buggy closed-source tools with names like "sysdiff". It was nerdily glorious and took me all summer to get working. The bulk of the time I spent building the automated OS network deployment system was fighting with drivers and NT bugs, but a close second was drafting a thorough unattended "answer file". Back then it was an .INI file, nowadays Microsoft likes XML. It's going to turn into JSON eventually.

    A well-crafted unattend file is a really amazing read. If you want to write your own, start with a basic template and then read through the Components documentation online. For the purposes of this document, you can start with this one. It's important to note that the following file:

    (a) Uses the amd64 architecture and will not work on an x86 install. If you'd rather install an x86 VM, WHAT IS WRONG WITH YOU?

    (b) defines an Administrators-level user account and password you should change before you use it on your own VMs

    (c) disables the System Restore feature to save space

    (d) disables system hibernation

    (e) disables the Windows Update service

    (f) sets the locale and time zone to en-US and Pacific Standard Time

    (Carefully) make any modifications to this file you need to make, and then save this file as "unattend.xml":

    <?xml version="1.0" encoding="utf-8"?>
    <unattend xmlns="urn:schemas-microsoft-com:unattend">
      <settings pass="oobeSystem">
    
        <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
          <InputLocale>0409:00000409</InputLocale>
          <SystemLocale>en-US</SystemLocale>
          <UILanguage>en-US</UILanguage>
          <UserLocale>en-US</UserLocale>
        </component>
    
        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Display>
            <ColorDepth>32</ColorDepth>
            <HorizontalResolution>1024</HorizontalResolution>
            <RefreshRate>60</RefreshRate>
            <VerticalResolution>768</VerticalResolution>
          </Display>
          <FirstLogonCommands>
            <SynchronousCommand wcm:action="add">
              <CommandLine>powercfg.exe hibernate off</CommandLine>
              <Order>1</Order>
            </SynchronousCommand>
            <SynchronousCommand wcm:action="add">
              <CommandLine>sc.exe config wuauserv start= disabled</CommandLine>
              <Order>2</Order>
            </SynchronousCommand>
            </FirstLogonCommands>
          <OOBE>
            <HideEULAPage>true</HideEULAPage>
            <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
            <NetworkLocation>Work</NetworkLocation>
            <ProtectYourPC>1</ProtectYourPC>
          </OOBE>
          <RegisteredOrganization>MYORG</RegisteredOrganization>
          <RegisteredOwner>OWNER</RegisteredOwner>
          <UserAccounts>
            <LocalAccounts>
              <LocalAccount wcm:action="add">
                <Password>
                  <Value>MY_PASSWORD_HERE</Value>
                </Password>
                <Description>Administrator Account</Description>
                <DisplayName>My Account</DisplayName>
                <Group>Administrators</Group>
                <Name>MY_USERNAME_HERE</Name>
              </LocalAccount>
            </LocalAccounts>
          </UserAccounts>
          <TimeZone>Pacific Standard Time</TimeZone>
        </component>
    
        <component name="Microsoft-Windows-SystemRestore-Main" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
          <DisableSR>1</DisableSR>
        </component>
    
      </settings>
    </unattend>

  13. Copy your unattend.xml to the \Windows\System32\Sysprep directory in the .VHD file:

    Copy-Item -Verbose -Path 'C:\Path\to\unattend.xml' -Destination $vhd_sysprep_path

  14. There shouldn't be a pagefile or swapfile if you haven't booted the VM yet, but accidents happen. Delete them if they exist:

    $vhd_pagefiles = @('page', 'swap') | % {"{0}:\{1}file.sys" -f ($part_obj.DriveLetter, $_)}
    Remove-Item -Verbose -Path $vhd_pagefiles -Force -Confirm:$False -ErrorAction SilentlyContinue

  15. Defrag. This isn't to actually defragment your VHD, it's using the "/X" option: "Perform free space consolidation on the specified volumes."

    Start-Process -NoNewWindow -Wait -FilePath 'defrag.exe' -ArgumentList @($drive_letter, '/V', '/H', '/X')

  16. Zero out the free space of the VHD. This requires SDelete v1.61 as mentioned previously, since SDelete v2 is broken. You can find SDelete v1.61 online pretty easily. It's also available as a Chocolatey package though expectedly you'll need to specify the exact version because it's not the latest. Verify the checksum(s) of the binary before you run it:

    MD5 = e189b5ce11618bb7880e9b09d53a588f

    SHA1 = 964f7144780aff59d48da184daa56b1704a86968

    SHA256 = 97d27e1225b472a63c88ac9cfb813019b72598b9dd2d70fe93f324f7d034fb95

    SHA512 = 292c3ea75fb957fa9dd04554c4d58b668a09c11655a88e7bc993306bf9feece8fbfefdd2934ce4e2df91947d2caff337bfab8dc990425e54bcbfe239a4d073e2

    Start-Process -NoNewWindow -Wait -FilePath 'C:\Path\to\sdelete161.exe' -ArgumentList @('-accepteula', '-z', $drive_letter)

  17. Dismount the .VHD file, then remount it with the -ReadOnly option. This allow Hyper-V to shrink its size with Optimize-VHD and all our efforts really pay off:

    $1MiB      = [Math]::Pow(2,20)
    $size_orig = (Get-Item -Path $vm_vhd_path).Length
    Dismount-VHD -Path $vm_vhd_path
    
    $vhd_obj  = Mount-VHD -Path $vm_vhd_path -PassThru -ReadOnly
    $part_obj = Get-Partition -DiskNumber $vhd_obj.DiskNumber
    
    Optimize-VHD -Path $vm_vhd_path
    Dismount-VHD -Path $vm_vhd_path
    $size_new = (Get-Item -Path $vm_vhd_path).Length
    
    $result = @()
    $result += "orig size: {0:0.0} MiB" -f ($size_orig / $1MiB)
    $result += "new  size: {0:0.0} MiB" -f ($size_new / $1MiB)
    $result += "shrinkage: {0:0.00}%" -f (100 * (1.0 - ($size_new / $size_orig)))
    Write-Host ([String]::Join("`n", $result))

Make a backup of your VHD at this point, not just for reverting back to it in case you make a mistake or hit an error in building your new VM, but because whenever you want a VM you can grab this backup VHD, attach it to your VM, and you're off to the races.

Now when you start your VM you'll have a fairly tiny Windows 10 setup. "Tiny" in this regard is comparative, as the slimmest I've been able to get this .VHD is about 3.99 GiB before I hit "Start" for the first time. That's much smaller than the free VMs you get with which to test the Edge browser, but it's enormous compared to some of the skinny Win7 images that you can create by following some of those other online tutorials.

This VM also isn't patched, so you may want to take care of that. I'd recommend it, except for circumstances where you won't ever connect this VM to the Internet. Be aware, though, that the evaluation ISO you downloaded needs to phone home in order to start your time-based 90-day trial. Otherwise, you'll find that your VM keeps shutting down every 60 minutes just to annoy you.

You can get around this by running "slmgr.vbs /rearm" from an elevated prompt and NOT rebooting when prompted to do so, but this only works one time and one time only. If you stop or restart the VM, you're back to square one.

Extra credit: Once your VM is set up, run one of the fixer scripts on Github on it. This is advanced Windows Fu, but I like to review all the settings that these scripts toggle and select the subset that's right for me. They try to minimize the telemetry stuff, but they have the side benefit of enabling a bunch of nice little customizations that I would normally end up doing by hand anyway.

I'm in the process of converging on this skinny Windows 10 LTSB image for my go-to Windows VM. I think it has potential for when I need to run a Windows-only workload (I'm looking at you, OS-dependent backup paths in SpiderOAK). I think a better way to do this, though it will yield a larger file size than what you'll get following the above method, would be to take the install ISO and slipstream a handful of current hotfixes into it with NTLite. This way I don't have to re-download and install gigs of patches every time I want to run a VM, at the cost of maintaining a custom ISO that needs to be cultivated or remade every few months.

Just adding the May 2018 servicing stack update, the October 2018 cumulative update, and the September 2018 Adobe Flash player update to the VM resulted in a final .VHD file that was 5.99 GiB. That's huge, but for a Windows VM I can't find a way to make it smaller without compromising security.

No comments: