{"id":493218,"date":"2026-09-02T10:49:17","date_gmt":"2026-09-02T10:49:17","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=493218"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=493218","title":{"rendered":"I Cloned the Same VM 100\u00a0Times and Found Five Identities That Were No Longer Unique"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>The original plan was simple. I prepared one Ubuntu Server VM under KVM, installed the packages needed for the lab, enabled SSH, configured cloud\u2011init, created a WireGuard interface for the private test network and installed a small monitoring agent. Once the system looked clean, I shut it down and used it as a template for 100\u00a0full clones.<\/p>\n<p>The hypervisor did its part perfectly. Every clone received its own virtual NIC and MAC address. DHCP assigned different addresses, and provisioning changed the hostname according to the VM number. The resulting environment looked exactly as expected.<\/p>\n<pre><code>vm-001    10.20.0.101vm-002    10.20.0.102vm-003    10.20.0.103...vm-100    10.20.0.200<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Ping worked, SSH worked, the monitoring dashboard showed 100\u00a0hosts, and every VM produced its own CPU and memory graphs. For a few minutes I considered the job finished.<\/p>\n<p>The problem appeared when I started writing a small inventory script for an unrelated test. I wanted every result to contain something more stable than an IP address, so I added <code>\/etc\/machine-id<\/code> to the collected data. The first three rows were identical.<\/p>\n<p>At first I thought the script was reading the local file instead of the remote one. That would have been an embarrassingly simple bug. I logged into several VMs manually and ran the same command.<\/p>\n<pre><code>cat \/etc\/machine-id<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>vm-001\u00a0returned:<\/p>\n<pre><code>8f75c476869c4b1cbcc8f03a714e1285<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>vm-018\u00a0returned exactly the same value. So did vm-064\u00a0and vm-100.<\/p>\n<p>That was the point where the model in my head started to change. The network had created 100\u00a0distinct endpoints, but that did not mean the operating systems inside those endpoints had become 100\u00a0distinct installations.<\/p>\n<p>The template had already been booted before cloning. systemd had generated an identity, SSH had created host keys, cloud\u2011init had finished initialization, the filesystem had been created, and WireGuard already had its private key. Shutting down the source VM did not return it to some neutral pre\u2011identity state. It simply froze all of that persistent state inside the disk image.<\/p>\n<p>The clones were not being born from a blank template. They were waking up from copies of a machine that had already decided who it was.<\/p>\n<h3>The first collision was machine\u2011id, but SSH was much worse<\/h3>\n<p>The duplicated <code>\/etc\/machine-id<\/code> was the easiest problem to understand. On a systemd\u2011based Linux installation, this file contains a persistent identifier for the local system. It is small, boring and easy to forget, which is probably why it is such a good example of this entire problem.<\/p>\n<p>Nothing dramatic happens when two machines share it. The kernel still boots, applications still start and the server remains reachable. That makes the collision easy to miss. At the same time, software can use machine\u2011id directly or derive stable identifiers from it, so duplicated values can confuse inventory tools, agents and services that assume the value distinguishes installations.<\/p>\n<p>The SSH check was less harmless.<\/p>\n<p>Each clone contained the same files under <code>\/etc\/ssh<\/code> that had been generated on the original VM:<\/p>\n<pre><code>ls -l \/etc\/ssh\/ssh_host_*<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The relevant files included the RSA, ECDSA and Ed25519\u00a0host keys. I compared the Ed25519\u00a0fingerprints on several machines.<\/p>\n<pre><code>ssh-keygen -lf \/etc\/ssh\/ssh_host_ed25519_key.pub<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Every host returned the same fingerprint.<\/p>\n<p>To make sure this was not just some formatting oddity, I hashed the private key itself.<\/p>\n<pre><code>sudo sha256sum \/etc\/ssh\/ssh_host_ed25519_key<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Again, identical.<\/p>\n<p>This changed the seriousness of the experiment. A duplicated machine\u2011id is mainly an identity\u2011management problem. A duplicated SSH host private key is a duplicated secret.<\/p>\n<p>Suppose vm-023\u00a0is destroyed and another clone later receives the same network address. With unique SSH host keys, a client that previously connected to that address can notice that the server identity has changed. With a shared host key, the replacement presents exactly the key the client already trusts.<\/p>\n<p>There is a second consequence that I initially overlooked. If one VM is compromised and its SSH host private key is stolen, the attacker does not obtain the identity of one server. They obtain the identity currently used by the entire cloned fleet.<\/p>\n<p>That was enough to stop treating the problem as a funny lab artifact. The template had silently turned per\u2011host credentials into shared credentials.<\/p>\n<h3>The virtual disk was unique, but the filesystem inside it was not<\/h3>\n<p>The third collision was the root filesystem UUID. Once I thought about it properly, this one was almost embarrassingly predictable.<\/p>\n<p>Each clone had its own virtual disk object. From the hypervisor point of view those disks were different. But a virtual disk is only a container for bytes, and the ext4\u00a0filesystem inside it had been copied byte for byte from the source machine.<\/p>\n<p>I checked the root device first:<\/p>\n<pre><code>findmnt -n -o SOURCE \/<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Then the filesystem metadata:<\/p>\n<pre><code>sudo blkid<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Every clone reported the same UUID for the root ext4\u00a0filesystem.<\/p>\n<pre><code>\/dev\/vda2: UUID=8e157db1-d42a-4d2c-a85d-d21db08353ad TYPE=ext4<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>While every disk remained isolated inside its own VM, nothing obviously failed. That made it tempting to ignore the duplication. The problem became much easier to see when I attached two cloned system disks to the same recovery machine.<\/p>\n<p>Now one Linux system could see two filesystems claiming exactly the same UUID.<\/p>\n<p>That is where the difference between disk identity and filesystem identity stops being theoretical. Recovery commands, mount configuration and tooling that expects a UUID to identify one filesystem can become ambiguous. The hypervisor had created another virtual disk, but it had not created another ext4\u00a0filesystem. It had copied an existing one.<\/p>\n<p>I briefly considered adding a command to regenerate the UUID during first boot, but that felt too casual for the root filesystem. Boot configuration may refer to the UUID, <code>\/etc\/fstab<\/code> may refer to it, and changing it after the system is already assembled can create a much more annoying failure than the one being fixed.<\/p>\n<p>So filesystem identity became a provisioning concern rather than a generic first\u2011boot cleanup step. In my setup, the disk image is now prepared so the filesystem identity is handled deliberately during image creation or disk provisioning.<\/p>\n<p>That was the first useful design lesson from the experiment: not every identity should be reset in the same place. Different components own different identities, and they should ideally be allowed to create those identities at the correct stage of the machine lifecycle.<\/p>\n<h3>cloud\u2011init was not broken; my image lifecycle was<\/h3>\n<p>cloud\u2011init produced the strangest symptoms because initially it looked like the component that should have prevented exactly this problem.<\/p>\n<p>Some per\u2011instance initialization did not behave consistently after cloning, so I started with the obvious checks.<\/p>\n<pre><code>cloud-init status --longcloud-init query instance_idls -la \/var\/lib\/cloud\/<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The answer was sitting in <code>\/var\/lib\/cloud<\/code>.<\/p>\n<p>The source VM had already completed a normal boot before it became a template. That meant cloud\u2011init had already stored local state describing the instance it had initialized. In the first version of the lab I had also preserved instance\u2011specific NoCloud seed data longer than I should have.<\/p>\n<p>The clones therefore inherited not only software configuration but evidence that first\u2011boot initialization had already happened.<\/p>\n<p>For a while I was mentally blaming cloud\u2011init because I expected it to make the cloned systems unique. That expectation was backwards. cloud\u2011init can initialize a new instance, but it cannot magically know that a filesystem containing completed initialization state is supposed to represent a completely different machine unless the image is prepared for that transition.<\/p>\n<p>The source VM had already crossed the boundary between image and instance.<\/p>\n<p>That distinction became the most useful way to think about the whole setup. Packages belong to the image. Base operating system configuration can belong to the image. A host identity belongs to the instance. Cloud metadata belongs to the instance. A secret generated to identify one particular server belongs to the instance.<\/p>\n<p>Once that boundary is clear, a lot of cleanup decisions stop looking arbitrary.<\/p>\n<h3>The fifth duplicate was a WireGuard private identity<\/h3>\n<p>WireGuard was the most obvious mistake in retrospect.<\/p>\n<p>The source VM already had a private key under its WireGuard configuration when it was converted into a template. Naturally, the private key was copied to every clone.<\/p>\n<p>Checking the public identity was enough to prove it:<\/p>\n<pre><code>sudo wg show wg0 public-key<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The result was identical on every VM.<\/p>\n<p>At the network layer, the environment looked like 100\u00a0hosts. At the WireGuard layer, it looked like one cryptographic peer copied 100\u00a0times.<\/p>\n<p>This one was entirely self\u2011inflicted. WireGuard had no way to know that its private key was sitting inside a reusable image, and the hypervisor had no way to know that one particular sequence of bytes inside the virtual disk represented a credential that should become unique.<\/p>\n<p>That realization made me widen the search beyond the original five fields. What about a monitoring agent that generates an installation UUID on its first start? What about a backup client that enrolls once and stores a local identity? What about a service discovery agent that writes its node ID under <code>\/var\/lib<\/code>? What about locally generated TLS keys?<\/p>\n<p>A VM image has no concept of identity semantics. It simply contains files and blocks. If some software generates a persistent identifier before the image is sealed, that identifier becomes part of the image unless something explicitly removes it.<\/p>\n<p>The practical question I now ask for every service is simple: if I copy this state to another machine, can that machine appear to be the original one?<\/p>\n<p>If the answer is yes, that state deserves attention before the image is reused.<\/p>\n<h3>Checking 100\u00a0machines manually was not going to happen<\/h3>\n<p>After finding the first few collisions manually, I wanted to know whether the problem existed on a handful of VMs or across the whole fleet. Logging into 100\u00a0systems and running five commands on each was not an attractive plan, so I wrote a collector.<\/p>\n<p>The script connects to every VM, extracts the identities I cared about and stores them in a CSV file. It is intentionally simple because the interesting part here is not SSH automation. The interesting part is having a repeatable way to ask whether the supposedly independent machines are actually unique at the layers being tested.<\/p>\n<pre><code class=\"bash\">#!\/usr\/bin\/env bashset -uOUTPUT=\"vm-identities.csv\"printf '%s\\n' \\  \"host,machine_id,ssh_ed25519,root_uuid,cloud_instance_id,wireguard_public_key\" \\  &gt; \"$OUTPUT\"for i in $(seq -w 1 100); do    HOST=\"vm-$i\"    echo \"Scanning $HOST\" &gt;&amp;2    SSH_OPTIONS=(        -o BatchMode=yes        -o ConnectTimeout=3        -o StrictHostKeyChecking=accept-new    )    MACHINE_ID=$(        ssh \"${SSH_OPTIONS[@]}\" \"$HOST\" \\            \"cat \/etc\/machine-id 2&gt;\/dev\/null\" \\            2&gt;\/dev\/null    )    SSH_ED25519=$(        ssh \"${SSH_OPTIONS[@]}\" \"$HOST\" \\            \"ssh-keygen -lf \/etc\/ssh\/ssh_host_ed25519_key.pub 2&gt;\/dev\/null |             awk '{print \\$2}'\" \\            2&gt;\/dev\/null    )    ROOT_UUID=$(        ssh \"${SSH_OPTIONS[@]}\" \"$HOST\" '            ROOT=$(findmnt -n -o SOURCE \/)            blkid -s UUID -o value \"$ROOT\" 2&gt;\/dev\/null        ' 2&gt;\/dev\/null    )    CLOUD_INSTANCE_ID=$(        ssh \"${SSH_OPTIONS[@]}\" \"$HOST\" '            cloud-init query instance_id 2&gt;\/dev\/null ||            cat \/var\/lib\/cloud\/data\/instance-id 2&gt;\/dev\/null        ' 2&gt;\/dev\/null    )    WG_PUBLIC_KEY=$(        ssh \"${SSH_OPTIONS[@]}\" \"$HOST\" \\            \"sudo wg show wg0 public-key 2&gt;\/dev\/null\" \\            2&gt;\/dev\/null    )    MACHINE_ID=${MACHINE_ID\/\/$'\\n'\/}    SSH_ED25519=${SSH_ED25519\/\/$'\\n'\/}    ROOT_UUID=${ROOT_UUID\/\/$'\\n'\/}    CLOUD_INSTANCE_ID=${CLOUD_INSTANCE_ID\/\/$'\\n'\/}    WG_PUBLIC_KEY=${WG_PUBLIC_KEY\/\/$'\\n'\/}    printf '%s,%s,%s,%s,%s,%s\\n' \\        \"$HOST\" \\        \"$MACHINE_ID\" \\        \"$SSH_ED25519\" \\        \"$ROOT_UUID\" \\        \"$CLOUD_INSTANCE_ID\" \\        \"$WG_PUBLIC_KEY\" \\        &gt;&gt; \"$OUTPUT\"doneechoecho \"Inventory written to $OUTPUT\"for column in 2 3 4 5 6; do    count=$(        cut -d, -f\"$column\" \"$OUTPUT\" |        tail -n +2 |        grep -v '^$' |        sort -u |        wc -l    )    echo \"column $column: $count\"done<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The first complete run produced a wonderfully bad result.<\/p>\n<pre><code>Hosts scanned:                    100Unique machine IDs:                 1Unique SSH Ed25519 host keys:        1Unique root filesystem UUIDs:        1Unique cloud-init instance IDs:      1Unique WireGuard public keys:        1<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>That output changed the problem from a collection of suspicious files into something measurable. The environment contained 100\u00a0running operating systems, but five independent mechanisms still saw essentially one identity.<\/p>\n<h3>I turned the collisions into a test that could fail the image build<\/h3>\n<p>A CSV file was useful while investigating the problem, but I wanted the check to survive after the experiment. If the template was rebuilt six months later, I did not want uniqueness to depend on someone remembering to inspect several files manually.<\/p>\n<p>The next step was a small Python validator. It groups hosts by every collected identity, prints collisions and returns a non\u2011zero exit code when duplicated values are found. That makes the check useful not only for a lab but also as a gate in a template validation pipeline.<\/p>\n<pre><code class=\"python\">#!\/usr\/bin\/env python3from __future__ import annotationsimport csvimport sysfrom collections import defaultdictfrom pathlib import PathINPUT_FILE = Path(\"vm-identities.csv\")FIELDS = {    \"machine_id\": \"Linux machine ID\",    \"ssh_ed25519\": \"SSH Ed25519 host key\",    \"root_uuid\": \"Root filesystem UUID\",    \"cloud_instance_id\": \"cloud-init instance ID\",    \"wireguard_public_key\": \"WireGuard public key\",}def load_inventory(path: Path) -&gt; list[dict[str, str]]:    if not path.exists():        print(f\"Inventory file not found: {path}\", file=sys.stderr)        raise SystemExit(2)    with path.open(\"r\", encoding=\"utf-8\", newline=\"\") as file:        rows = list(csv.DictReader(file))    if not rows:        print(\"Inventory is empty\", file=sys.stderr)        raise SystemExit(2)    return rowsdef group_hosts(    rows: list[dict[str, str]],    field: str,) -&gt; dict[str, list[str]]:    groups: dict[str, list[str]] = defaultdict(list)    for row in rows:        host = row.get(\"host\", \"\").strip()        value = row.get(field, \"\").strip()        if not host:            continue        groups[value or \"&lt;missing&gt;\"].append(host)    return groupsdef check_field(    rows: list[dict[str, str]],    field: str,    label: str,) -&gt; bool:    groups = group_hosts(rows, field)    missing = groups.pop(\"&lt;missing&gt;\", [])    collisions = {        value: hosts        for value, hosts in groups.items()        if len(hosts) &gt; 1    }    print(\"=\" * 72)    print(label)    print(\"=\" * 72)    print(f\"Unique values: {len(groups)}\")    print(f\"Missing values: {len(missing)}\")    if not collisions:        print(\"Status: OK\")        print()        return False    print(f\"Collisions: {len(collisions)}\")    print()    for value, hosts in sorted(        collisions.items(),        key=lambda item: len(item[1]),        reverse=True,    ):        print(f\"Value: {value}\")        print(f\"Used by: {len(hosts)} hosts\")        for host in hosts[:8]:            print(f\"  {host}\")        if len(hosts) &gt; 8:            print(f\"  ... {len(hosts) - 8} more\")        print()    return Truedef main() -&gt; int:    rows = load_inventory(INPUT_FILE)    print(f\"Hosts scanned: {len(rows)}\")    print()    failed = False    for field, label in FIELDS.items():        if check_field(rows, field, label):            failed = True    if failed:        print(\"RESULT: identity collisions detected\")        return 1    print(\"RESULT: no identity collisions detected\")    return 0if __name__ == \"__main__\":    raise SystemExit(main())<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>After rebuilding the template, I ran the same validation again. This time the important host\u2011level identities were unique.<\/p>\n<pre><code>Hosts scanned:                    100Unique machine IDs:               100Unique SSH Ed25519 host keys:      100Unique cloud-init instance IDs:    100Unique WireGuard public keys:      100<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Filesystem identity remained a separate validation because I had moved that part into disk provisioning. That separation was intentional rather than an exception hidden in the script.<\/p>\n<p>The rule that came out of this was useful far beyond this particular lab: duplicated values are not automatically bugs, but duplicated identities should always be deliberate.<\/p>\n<h3>Fixing the image was cleaner than repairing every clone<\/h3>\n<p>My first instinct was to create one large post\u2011clone script that removed IDs, deleted keys, reset cloud\u2011init and restarted services. It would probably have worked for this exact image.<\/p>\n<p>It would also have become a trap.<\/p>\n<p>A better solution was to change the template lifecycle so that the image was sealed before instance\u2011specific identity became permanent.<\/p>\n<p>For systemd, the reusable image should not retain the finished machine ID of the source VM. Before sealing, the machine ID can be cleared so the new instance creates its own value during initialization.<\/p>\n<pre><code>sudo truncate -s 0 \/etc\/machine-id<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>I also check <code>\/var\/lib\/dbus\/machine-id<\/code> rather than assuming its layout, because depending on the system it may be linked or managed differently.<\/p>\n<pre><code>ls -l \/etc\/machine-id \/var\/lib\/dbus\/machine-id<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>For OpenSSH, the host keys are removed before sealing.<\/p>\n<pre><code>sudo rm -f \/etc\/ssh\/ssh_host_*<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The important part is not the deletion command itself. The important part is making sure the boot process generates new host keys before SSH is used.<\/p>\n<p>cloud\u2011init is reset as part of the image sealing process rather than treated as something that will somehow repair an already initialized clone.<\/p>\n<pre><code>sudo cloud-init clean --logs --machine-id<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Instance metadata is then supplied separately to each new VM.<\/p>\n<p>WireGuard follows the same principle. Its private key does not belong in a generic template if the key is intended to identify one host. The key is generated during per\u2011instance provisioning.<\/p>\n<pre><code>sudo install -d -m 700 \/etc\/wireguardsudo sh -c '    umask 077    wg genkey &gt; \/etc\/wireguard\/private.key    wg pubkey &lt; \/etc\/wireguard\/private.key \\        &gt; \/etc\/wireguard\/public.key'<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The resulting lifecycle is now much easier to understand.<\/p>\n<pre><code>build base image        \u2193install software        \u2193remove instance-specific identity        \u2193seal image        \u2193clone        \u2193supply instance metadata        \u2193first boot        \u2193generate machine identity and credentials<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Previously I had accidentally built this instead:<\/p>\n<pre><code>build machine        \u2193boot it        \u2193generate identity and credentials        \u2193configure everything        \u2193seal finished machine        \u2193copy its identity 100 times<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Those two pipelines look similar until something actually depends on the copied identity.<\/p>\n<h3>There is no single identity of a Linux VM<\/h3>\n<p>The experiment became more interesting once I stopped asking whether a clone was unique and started asking who considered it unique.<\/p>\n<p>The hypervisor saw 100\u00a0VM objects. The virtual switch saw 100\u00a0MAC addresses. DHCP saw 100\u00a0clients. DNS saw 100\u00a0hostnames. Monitoring saw 100\u00a0metric sources.<\/p>\n<p>systemd originally saw one machine identity copied 100\u00a0times. SSH saw one host key copied 100\u00a0times. ext4\u00a0saw 100\u00a0copies of one filesystem. WireGuard saw one cryptographic peer. cloud\u2011init inherited state from one initialized source.<\/p>\n<p>None of those layers were behaving randomly. Each one was internally consistent. They simply used different definitions of machine identity.<\/p>\n<p>That explains why this class of problem can stay invisible for a surprisingly long time. Normal health checks tend to focus on reachability, process state, CPU, memory, HTTP responses and network addresses. A VM can pass all of those checks while still carrying an identity copied from another machine.<\/p>\n<p>The failure is not necessarily a crash. Sometimes it is two monitoring agents overwriting each other. Sometimes it is a strange SSH trust relationship. Sometimes it appears only during recovery, when two disks with the same filesystem UUID are attached to one host. Sometimes it is a security issue caused by a private key that was never supposed to leave the original machine.<\/p>\n<p>The most dangerous cloned identity is often the one nobody remembered existed.<\/p>\n<h3>What I check before sealing a VM image now<\/h3>\n<p>The template process now has a small identity review before an image is considered reusable:<\/p>\n<ul>\n<li>\n<p>system identity, including machine\u2011id and instance\u2011specific hostnames<\/p>\n<\/li>\n<li>\n<p>SSH, VPN and locally generated TLS private keys<\/p>\n<\/li>\n<li>\n<p>cloud\u2011init state and first\u2011boot markers<\/p>\n<\/li>\n<li>\n<p>filesystem and storage identifiers when the provisioning model requires them to be unique<\/p>\n<\/li>\n<li>\n<p>monitoring, backup, service discovery and security\u2011agent identities<\/p>\n<\/li>\n<li>\n<p>application UUIDs and enrollment state stored under persistent directories such as <code>\/var\/lib<\/code><\/p>\n<\/li>\n<\/ul>\n<p>The last category requires the most manual work because Linux has no universal command that can tell whether an arbitrary file represents application identity.<\/p>\n<p>One technique that has worked well is to compare the filesystem before and after services are started for the first time. A file that appears once, survives reboots and is later used to identify the host deserves inspection.<\/p>\n<p>I also keep coming back to one simple test. If copying a particular file to another machine allows that machine to impersonate the original one from the point of view of some service, that file probably should not be baked into a generic image unless the sharing is intentional.<\/p>\n<h3>Conclusion<\/h3>\n<p>The original job was supposed to be boring infrastructure work. Build one VM, clone it 100\u00a0times, run a test and delete everything afterwards.<\/p>\n<p>Instead, it exposed a weakness in the way I thought about VM templates.<\/p>\n<p>A powered\u2011off server is not automatically a golden image. If the machine has already completed first boot and generated host\u2011specific state, shutting it down does not make that state generic. It only makes the machine easier to copy.<\/p>\n<p>The hypervisor will happily duplicate a filesystem containing machine IDs, SSH private keys, cloud\u2011init state, application enrollment IDs and VPN credentials because, at that layer, they are just bytes.<\/p>\n<p>A reusable image therefore needs to be incomplete in a very specific way. It should contain the operating system, packages and common configuration, but it should still be waiting to become a particular machine.<\/p>\n<p>After rebuilding the image around that idea, the 100\u00a0clones finally became independent not only from the point of view of KVM, DHCP and DNS, but also from the point of view of the services running inside them.<\/p>\n<p>That was the part I had missed at the beginning. The hypervisor had created 100\u00a0virtual machines, while several layers inside the first version of those machines still believed they were copies of the same one.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1077654\/\">https:\/\/habr.com\/ru\/articles\/1077654\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>The original plan was simple. I prepared one Ubuntu Server VM under KVM, installed the packages needed for the lab, enabled SSH, configured cloud\u2011init, created a WireGuard interface for the private test network and installed a small monitoring agent. Once the system looked clean, I shut it down and used it as a template for 100\u00a0full clones.The hypervisor did its part perfectly. Every clone received its own virtual NIC and MAC address. DHCP assigned different addresses, and provisioning changed the hostname according to the VM number. The resulting environment looked exactly as expected.vm-001    10.20.0.101vm-002    10.20.0.102vm-003    10.20.0.103&#8230;vm-100    10.20.0.200Ping worked, SSH worked, the monitoring dashboard showed 100\u00a0hosts, and every VM produced its own CPU and memory graphs. For a few minutes I considered the job finished.The problem appeared when I started writing a small inventory script for an unrelated test. I wanted every result to contain something more stable than an IP address, so I added \/etc\/machine-id to the collected data. The first three rows were identical.At first I thought the script was reading the local file instead of the remote one. That would have been an embarrassingly simple bug. I logged into several VMs manually and ran the same command.cat \/etc\/machine-idvm-001\u00a0returned:8f75c476869c4b1cbcc8f03a714e1285vm-018\u00a0returned exactly the same value. So did vm-064\u00a0and vm-100.That was the point where the model in my head started to change. The network had created 100\u00a0distinct endpoints, but that did not mean the operating systems inside those endpoints had become 100\u00a0distinct installations.The template had already been booted before cloning. systemd had generated an identity, SSH had created host keys, cloud\u2011init had finished initialization, the filesystem had been created, and WireGuard already had its private key. Shutting down the source VM did not return it to some neutral pre\u2011identity state. It simply froze all of that persistent state inside the disk image.The clones were not being born from a blank template. They were waking up from copies of a machine that had already decided who it was.The first collision was machine\u2011id, but SSH was much worseThe duplicated \/etc\/machine-id was the easiest problem to understand. On a systemd\u2011based Linux installation, this file contains a persistent identifier for the local system. It is small, boring and easy to forget, which is probably why it is such a good example of this entire problem.Nothing dramatic happens when two machines share it. The kernel still boots, applications still start and the server remains reachable. That makes the collision easy to miss. At the same time, software can use machine\u2011id directly or derive stable identifiers from it, so duplicated values can confuse inventory tools, agents and services that assume the value distinguishes installations.The SSH check was less harmless.Each clone contained the same files under \/etc\/ssh that had been generated on the original VM:ls -l \/etc\/ssh\/ssh_host_*The relevant files included the RSA, ECDSA and Ed25519\u00a0host keys. I compared the Ed25519\u00a0fingerprints on several machines.ssh-keygen -lf \/etc\/ssh\/ssh_host_ed25519_key.pubEvery host returned the same fingerprint.To make sure this was not just some formatting oddity, I hashed the private key itself.sudo sha256sum \/etc\/ssh\/ssh_host_ed25519_keyAgain, identical.This changed the seriousness of the experiment. A duplicated machine\u2011id is mainly an identity\u2011management problem. A duplicated SSH host private key is a duplicated secret.Suppose vm-023\u00a0is destroyed and another clone later receives the same network address. With unique SSH host keys, a client that previously connected to that address can notice that the server identity has changed. With a shared host key, the replacement presents exactly the key the client already trusts.There is a second consequence that I initially overlooked. If one VM is compromised and its SSH host private key is stolen, the attacker does not obtain the identity of one server. They obtain the identity currently used by the entire cloned fleet.That was enough to stop treating the problem as a funny lab artifact. The template had silently turned per\u2011host credentials into shared credentials.The virtual disk was unique, but the filesystem inside it was notThe third collision was the root filesystem UUID. Once I thought about it properly, this one was almost embarrassingly predictable.Each clone had its own virtual disk object. From the hypervisor point of view those disks were different. But a virtual disk is only a container for bytes, and the ext4\u00a0filesystem inside it had been copied byte for byte from the source machine.I checked the root device first:findmnt -n -o SOURCE \/Then the filesystem metadata:sudo blkidEvery clone reported the same UUID for the root ext4\u00a0filesystem.\/dev\/vda2: UUID=8e157db1-d42a-4d2c-a85d-d21db08353ad TYPE=ext4While every disk remained isolated inside its own VM, nothing obviously failed. That made it tempting to ignore the duplication. The problem became much easier to see when I attached two cloned system disks to the same recovery machine.Now one Linux system could see two filesystems claiming exactly the same UUID.That is where the difference between disk identity and filesystem identity stops being theoretical. Recovery commands, mount configuration and tooling that expects a UUID to identify one filesystem can become ambiguous. The hypervisor had created another virtual disk, but it had not created another ext4\u00a0filesystem. It had copied an existing one.I briefly considered adding a command to regenerate the UUID during first boot, but that felt too casual for the root filesystem. Boot configuration may refer to the UUID, \/etc\/fstab may refer to it, and changing it after the system is already assembled can create a much more annoying failure than the one being fixed.So filesystem identity became a provisioning concern rather than a generic first\u2011boot cleanup step. In my setup, the disk image is now prepared so the filesystem identity is handled deliberately during image creation or disk provisioning.That was the first useful design lesson from the experiment: not every identity should be reset in the same place. Different components own different identities, and they should ideally be allowed to create those identities at the correct stage of the machine lifecycle.cloud\u2011init was not broken; my image lifecycle wascloud\u2011init produced the strangest symptoms because initially it looked like the component that should have prevented exactly this problem.Some per\u2011instance initialization did not behave consistently after cloning, so I started with the obvious checks.cloud-init status &#8212;longcloud-init query instance_idls -la \/var\/lib\/cloud\/The answer was sitting in \/var\/lib\/cloud.The source VM had already completed a normal boot before it became a template. That meant cloud\u2011init had already stored local state describing the instance it had initialized. In the first version of the lab I had also preserved instance\u2011specific NoCloud seed data longer than I should have.The clones therefore inherited not only software configuration but evidence that first\u2011boot initialization had already happened.For a while I was mentally blaming cloud\u2011init because I expected it to make the cloned systems unique. That expectation was backwards. cloud\u2011init can initialize a new instance, but it cannot magically know that a filesystem containing completed initialization state is supposed to represent a completely different machine unless the image is prepared for that transition.The source VM had already crossed the boundary between image and instance.That distinction became the most useful way to think about the whole setup. Packages belong to the image. Base operating system configuration can belong to the image. A host identity belongs to the instance. Cloud metadata belongs to the instance. A secret generated to identify one particular server belongs to the instance.Once that boundary is clear, a lot of cleanup decisions stop looking arbitrary.The fifth duplicate was a WireGuard private identityWireGuard was the most obvious mistake in retrospect.The source VM already had a private key under its WireGuard configuration when it was converted into a template. Naturally, the private key was copied to every clone.Checking the public identity was enough to prove it:sudo wg show wg0 public-keyThe result was identical on every VM.At the network layer, the environment looked like 100\u00a0hosts. At the WireGuard layer, it looked like one cryptographic peer copied 100\u00a0times.This one was entirely self\u2011inflicted. WireGuard had no way to know that its private key was sitting inside a reusable image, and the hypervisor had no way to know that one particular sequence of bytes inside the virtual disk represented a credential that should become unique.That realization made me widen the search beyond the original five fields. What about a monitoring agent that generates an installation UUID on its first start? What about a backup client that enrolls once and stores a local identity? What about a service discovery agent that writes its node ID under \/var\/lib? What about locally generated TLS keys?A VM image has no concept of identity semantics. It simply contains files and blocks. If some software generates a persistent identifier before the image is sealed, that identifier becomes part of the image unless something explicitly removes it.The practical question I now ask for every service is simple: if I copy this state to another machine, can that machine appear to be the original one?If the answer is yes, that state deserves attention before the image is reused.Checking 100\u00a0machines manually was not going to happenAfter finding the first few collisions manually, I wanted to know whether the problem existed on a handful of VMs or across the whole fleet. Logging into 100\u00a0systems and running five commands on each was not an&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-493218","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/493218","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=493218"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/493218\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=493218"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=493218"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=493218"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}