IPv6 UEFI PXE - duplicitní odpovědi

IPv6 UEFI PXE boot

V naší infrastruktuře se snažíme ulehčit si práci jak jen to jde. Jedna z technologií, která nám v tom pomáhá je bootování ze sítě.

Aktuálně nám bootování ze sítě zajišťuje základní služby, které nám nejenom šetří čas, ale také pomáhají v případě incidentů. Mezi ně patří instalace serverů, live boot, zátěžové testy, atd.

Dnes se ale podíváme na jeden velmi specifický případ, kdy nám bootování ze sítě nefungovalo.

Hardware

V první řadě je potřeba zmínit, že kompletní PXE boot děláme po IPv6. U starších serverů používáme PCI-E síťové karty se speciálně sestaveným a flashnutým firmwarem. U novějších serverů používáme UEFI, kde byl IPv6 PXE boot uveden ve verzi 2.3 (Errata D)

Na problém jsme narazili při instalaci Supermicro twin serverů. Konkrétně se jedná o základní desku X10DRT-P. Aby byly informace kompletní, tak BIOS byl flashnut na poslední dostupnou verzi - 3.2, která byla sestavena dne 11/19/2019.

Popis zapojení serverů

Využíváme schéma zapojení serverů, kdy jednotlivé servery jsou zapojené buď do jednoho nebo dvou L2 access switchů. Využíváme Cisco Nexus 3048TP pro 1Gbps nebo různé druhy Arista switchů pro 1Gbps, 10Gbps a 40Gbps síť. L2 switche jsou dále připojeny do L3 switchů, které se starají o routing a VLAN segmentaci v rámci naší sítě. L3 agregační switche jsou vždy v párech a využívají MLAG.

L3 switche využíváme jako DHCPv6 relay pro přeposílání DHCPv6 požadavků na naše DHCPv6 servery. Díky tomuto nemusíme držet DHCPv6 servery ve stejné L2 doméně jako jednotlivé servery. Konfigurace na obou switchích vypadá následovně:

interface Vlan16
    ...
    ipv6 dhcp relay destination 2001:db8::b007
    ipv6 nd other-config-flag
    ...

Pokud tedy jakýkoliv switch dostane multicast DHCPv6 požadavek, přepošle jej na adresu, kde naslouchají DHCPv6 servery.

Popis problému

Problém, na který jsme narazili spočívá v tom, že DHCPv6 požadavek se dostane na oba L3 switche. Oba switche pak jeden požadavek přepošlou na DHCPv6 servery. Dojde tak k duplikaci požadavků. Malý úryvek tcpdumpu z DHCPv6 serveru:

15:32:01.937496 IP6 (hlim 64, next-header UDP (17) payload length: 157) 2001:db8:1::fffe.547 > 2001:db8::b007.547: [udp sum ok] dhcp6 relay-fwd (linkaddr=2001:db8:1::fffe peeraddr=fe80::225:90ff:fefc:d38c (relay-message (dhcp6 solicit (xid=1bf7f8 (client-ID type 4) (elapsed-time 0) (IA_NA IAID:4239699955 T1:4294967295 T2:4294967295) (option-request opt_59 opt_60) (opt_62) (opt_61) (vendor-class))))
15:32:01.937535 IP6 (hlim 64, next-header UDP (17) payload length: 157) 2001:db8:1::ffff.547 > 2001:db8::b007.547: [udp sum ok] dhcp6 relay-fwd (linkaddr=2001:db8:1::ffff peeraddr=fe80::225:90ff:fefc:d38c (relay-message (dhcp6 solicit (xid=1bf7f8 (client-ID type 4) (elapsed-time 0) (IA_NA IAID:4239699955 T1:4294967295 T2:4294967295) (option-request opt_59 opt_60) (opt_62) (opt_61) (vendor-class))))

DHCPv6 využívá UDP a aplikace, které jej využívají by měly počítat s různými scénáři jako je duplikace datagramů, ztráta nebo různé poškození.

Podle testů BIOS v této základní desce s takovou situací počítá. Požadavek na poskytnutí všech potřebných informací odešle znovu a zkouší to dokud nedojde k timeoutu nebo neodstane správnou odpověď. V tomto případě však vždy dostane dvě odpovědi, což je pro BIOS chybový stav a proces se opakuje.

Celý bootovací proces po uplynutí celkem dlouhého timeoutu skončí chybou: PXE-E16: No offer received.

U novějších základních desek (konkrétně například X11SCH-LN4F a X11SPL-F) jsme ale na toto chování nenarazili a tam duplikace odpovědí vůbec nevadí.

Řešení

Jako DHCPv6 server využíváme upravenou implementaci coredhcp, která je postavená na velmi dobře napsané knihovně dhcp.

Zpracování požadavku pomocí systému pluginů nám dovoluje napsat si vlastní logiku, která ovlivní odpověď od DHCPv6 serveru.

Využili jsme toho a pro vyřešení našeho problému jsme si napsali jednoduchý plugin, který se stará o deduplikaci zpráv. Zpráva je buď předaná k dalšímu zpracování nebo duplicitní je zahozena. Jako deduplikační klíč používáme číslo transakce, která je pro každý DHCPv6 požadavek unikátní. Kód pluginu je k nalezení na konci tohoto blog postu.

V rámci otestování prototypu jsme si všechen DHCPv6 provoz přesměrovali na jeden server, kde jsme si problém nasimulovali a to jak bez pluginu, tak i s ním. Potvrdili jsme si, že toto je řešení, které funguje.

Teď náš čeká už jen úprava deduplikace na distribuovanou verzi pro situaci, kdy je v síti DHCPv6 serverů víc a provoz je na jednotlivé servery rozdělován pomocí ECMP.

Celý kód pluginu

plugins/deduplication/plugin.go

package deduplication

import (
    "time"

    "github.com/coredhcp/coredhcp/handler"
    "github.com/coredhcp/coredhcp/logger"
    "github.com/coredhcp/coredhcp/plugins"
    "github.com/insomniacslk/dhcp/dhcpv6"
    "github.com/patrickmn/go-cache"
)

var log = logger.GetLogger("plugins/deduplication")

// Configuration:
//
// server6:
//   - plugins:
//       - deduplication:

var Plugin = plugins.Plugin{
    Name:   "deduplication",
    Setup6: setup6,
    Setup4: nil,
}

// Initialize cache
var transactionCache = cache.New(15 * time.Second, 3600 * time.Second)

func setup6(args ...string) (handler.Handler6, error) {
    log.Printf("loaded plugin for DHCPv6.")
    return deduplicationHandler6, nil
}

func deduplicationHandler6(req, resp dhcpv6.DHCPv6) (dhcpv6.DHCPv6, bool) {
    log.Debugf("received DHCPv6 packet: %s", req.Summary())

    // Decapsulate the message
    decap, err := req.GetInnerMessage()
    if err != nil {
        log.Errorf("Could not decapsulate relayed message, aborting: %v", err)
        return nil, true
    }

    // Get transaction ID
    transactionId := decap.TransactionID

    // Check the presence in the cache
    cacheKey := transactionId.String()

    _, found := transactionCache.Get(cacheKey)
    if found {
        log.Debugf("Dropping the request as transaction '%s' is duplicate.", cacheKey)
        return nil, true
    }

    // Insert record to the cache and continue
    log.Debugf("Logging transaction '%s' to the database and continuing.", cacheKey)
    transactionCache.Set(cacheKey, "1", cache.DefaultExpiration)

    return resp, false
}