Von Heuhaufen und Nadeln – JSON Transformator jq bringt Licht ins Dunkel der AWS EC2 Preise


de KaffeeKlatsch jq aws aws-pricing

Erschienen im KaffeeKlatsch, Januar/Februar 2020


Die Preise für EC2-Instanzen sind sowohl über die Webseite1 als auch über die Pricing-API2 abrufbar. In beiden Varianten ist es schwierig, die Daten nach eigenen Kriterien zu vergleichen oder für weitere Berechnungen zu verwenden. jq3 ist ein JSON-Transformator, der die originalen, sehr verquasteten JSON-Daten der Pricing-API in eine Struktur bringen kann, mit der die eigentlichen Analysen deutlich einfacher durchgeführt werden können.

Der Artikel behandelt die Datenstrukturen der AWS EC2 Preise einerseits und JSON-Transformationen mit jq andererseits in etwa zu gleichen Teilen. Reine jq-Tutorials wie 4 zeigen zwar die Möglichkeiten von jq anhand von sehr einfachen Strukturen, sie zeigen aber nicht, wie man ein großes, reales Problem schrittweise lösen kann. Andererseits ist auch die Pricing-API durchaus interessant – Querlesen der jeweils interessanteren Teile ist also diesmal erlaubt.

AWS EC2 Preise

Zuerst ein Überblick, wie die AWS-Website die Preise ihrer virtuellen Maschinen organisiert: Auf der Einstiegsseite der AWS EC2-Preisliste1 findet die erste Vorauswahl mittels folgender Optionen statt:

“On-Demand” leitet auf eine Seite um, auf der zwar schon Preise zu sehen sind, diese werden aber dynamisch anhand weiterer Filterkriterien nachgeladen.

Die zweite Filterebene findet sich in verschiedenen Tab-Reitern mit diesen Bezeichnungen:

Innerhalb des Reiters befindet sich als dritte Filter-Ebene die Drop-Down Box “Region”, welche die Preisliste auf die angegebene Region einschränkt. Die Tabelle selbst ist nochmal in Abschnitte unterteilt, mit denen einzelnen Instance-Types zu Instance-Families zusammengefasst werden:

Dabei deutet “Current Generation” auch an, dass noch eine weitere Filterebene wirksam ist – die fünfte Ebene.

Erst dann werden die einzelnen Preise für die Instance-Types aufgelistet – je nach Region sind dies bis zu rund 220 Einträge.

Wenn man also zum Beispiel wissen möchte, wie groß der Aufpreis zwischen den Regionen “US East (Ohio)” und “EU (Frankfurt)” ist und ob die Lizenzen für RHEL oder SLES überproportional ansteigen und ab wann sich statt “On-Demand” vielleicht eine der “Reserved Instance” Varianten rentiert, dann ist einige Arbeit nötig, um die Daten aus den verschiedenen Seiten herauszuziehen.

Als Alternative bietet sich die Pricing-API2 an, mit der einzelne oder auch alle Preise eines Service ermittelt werden können. Da diese API für alle AWS-Services gilt, ist sie leider entsprechend “generisch”.

Der Aufruf der Pricing-API erfolgt am einfachsten über das aws Command Line Interface (CLI):

aws pricing get-products \
    --region us-east-1 \
    --service-code AmazonEC2 \
    > ec2-products-raw.json

Diese API ist nur in den Regionen us-east-1 und ap-south-1 aufrufbar, daher muss die Region explizit angegeben werden. Der angegebene Befehl lädt ALLE Preise des EC2-Services herunter – nicht nur die Preise der Instances, sondern zum Beispiel auch die Gebühren für Netzwerktransfers zwischen den Instances. Der Befehl dauert sehr lange, daher schreibt der Befehl die Ausgaben (~850 MB) zur späteren mehrfachen Verwendung in eine Datei. Der Befehl versteht prinzipiell auch Filter-Angaben, mit denen die zusätzlichen Posten nicht übertragen werden – die Laufzeit verringert sich aber nicht wesentlich, da ein Großteil tatsächlich EC2 Instance-Preise sind: In der Datei vom 18.11.2019 befanden sich 212.220 Einträge, davon bezogen sich nur 720 nicht auf EC2-Instanzen.

Die Struktur der Datei sieht verkürzt so aus:

{
    "PriceList": [
        "{\"product\":{\"productFamily\":\"…",
        "{\"product\":{\"productFamily\":\"…",
        …
        "{\"product\":{\"productFamily\":\"…",
        "{\"product\":{\"productFamily\":\"…",
    ],
    "FormatVersion": "aws_v1"
}

Das heißt, dass hier JSON in JSON verpackt und daher escaped wurde.

jq zum Ersten…

Dies kann jq rückgängig machen:

jq -c '.PriceList[] | fromjson' \
    < ec2-products-raw.json \
    > ec2-products.json

Die Ausgabe hat dann folgende Struktur:

{"product":{"productFamily":"Compute Instance", …}
…
{"product":{"productFamily":"Compute Instance", …}

Die Ausgabe ist streng genommen keine JSON-Datei mehr, sie enthält vielmehr eine Sequenz von einzelnen JSON-Objekten. Es fehlt also ein umschließendes Array und die einzelnen Werte sind nicht durch Kommata getrennt.

jq liest eine Sequenz von JSON-Werten (Scalare, Arrays oder Objekten) ein und wendet auf jeden Wert den angegebenen Filter an. Das Ergebnis eines Filters und einer Eingabe können null, ein oder mehrere unabhängige Ausgabe-Werte sein. Ist ein Filter – wie im Beispiel – aus mehreren Teilen aufgebaut, so leitet jq in Unix-Manier die einzelnen Ausgaben des ersten Filters als Eingabe an den zweiten Filter weiter. Da der Filter-Ausdruck .PriceList[] | fromjson auch als .PriceList | .[] | fromjson geschrieben werden kann, liest jq ein Objekt ein, pickt daraus das Attribut PriceList heraus und leitet dessen Wert an den nächsten Filter weiter. Der Filter .[] spaltet ein Objekt oder ein Array in einzelne Werte auf. Da in der Regel mehrere unabhängige Ausgaben entstehen, werden die folgenden Filter entsprechend mehrmals ausgeführt. Der Filter fromjson erwartet einen String, parst ihn als JSON und gibt ihn wieder aus. Da jq den Filter fromjson für jede Eingabe einzeln aufruft, ist das Ergebnis kein zusammenhängendes JSON- Array, sondern die schon beschriebene Sequenz von unabhängigen JSON-Objekten.

Dieses Aufdröseln einer Eingabe in unabhängige Ausgaben und nicht etwa in ein Array von Werten ist die entscheidende Spielregel von jq – sie liest sich einfach, aber die Konsequenzen sind oft … überraschend.

jq formatiert normalerweise die Ausgabe-Objekte mit Einrückungen, Zeilenumbrüchen und – bei Ausgabe auf die Konsole – Syntax-Highlighting. Durch die Option -c oder --compact-output schreibt jq ein Objekt pro Zeile, was bei der rund 850MB großen Eingabedatei viel Platz und Zeit bei den nachfolgenden Verarbeitungsschritten spart.

Zurück zur Filter-Syntax: . ist der einfachste Filter: Er gibt die Eingabe unverändert aus. Dies ist auch nützlich, um mit jq als JSON-Formatter zu verwenden:

jq . < input.json

.name selektiert aus einem JSON-Objekt den Wert eines Attributes. Mehrere solcher Pfad-Filter können entweder über die allgemeine Verkettungs-Syntax mit | verbunden werden oder durch direktes Hintereinanderschreiben. Die beiden folgenden Befehle erzeugen dieselbe Ausgabe:

jq '.product | .productFamily' < ec2-products.json |head
jq '.product.productFamily'    < ec2-products.json |head

.[] nimmt aus einem Array oder einem Objekt die Werte und gibt jeden Wert als unabhängiges Ergebnis aus. Dieser Filter kann ebenfalls mit vorangehenden “Pfad”-Filtern ohne | verkettet werden. Dabei entfällt aber der Punkt. .foo[] und .foo | .[] liefert also dasselbe Ergebnis.

Preisliste – Attribute

Zurück zur Preisliste: Eine Zeile bzw. ein JSON-Objekt hat aktuell folgende Struktur:

{
  "product": {
    "productFamily": "Compute Instance",
    "attributes": {
      "memory": "1 GiB",
      "vcpu": "1",
      "capacitystatus": "Used",
      "instanceType": "t2.micro",
      "tenancy": "Shared",
      "usagetype": "USE2-BoxUsage:t2.micro",
      "locationType": "AWS Region",
      "storage": "EBS only",
      "normalizationSizeFactor": "0.5",
      "instanceFamily": "General purpose",
      "operatingSystem": "Linux",
      "processorFeatures": "Intel AVX; Intel Turbo",
      "servicecode": "AmazonEC2",
      "physicalProcessor": "Intel Xeon Family",
      "clockSpeed": "Up to 3.3 GHz",
      "licenseModel": "No License required",
      "ecu": "Variable",
      "currentGeneration": "Yes",
      "preInstalledSw": "NA",
      "networkPerformance": "Low to Moderate",
      "location": "US East (Ohio)",
      "servicename": "Amazon Elastic Compute Cloud",
      "processorArchitecture": "32-bit or 64-bit",
      "operation": "RunInstances"
    },
    "sku": "F9GPUA3E29X6GJVE"
  },
  "serviceCode": "AmazonEC2",
  "terms": {
    "OnDemand": {
        // …
    },
    "Reserved": {
        // …
    }
  },
  "version": "20191116004755",
  "publicationDate": "2019-11-16T00:47:55Z"
}

Die mit // … markierten Stellen enthalten weitere Strukturen, deren Erklärung aber erst später folgt.

Im Wesentlichen besteht also ein Eintrag aus einer product- und einer term-Struktur. In product befinden sich als eindeutiger Schlüssel die sku-Nummer (Stock Keeping Unit) sowie eine Reihe von Attributen, die das Produkt beschreiben. Diese Attribute enthalten einige der eingangs erwähnten Filterebenen, aber auch weitere Informationen.

Leider sind die Werte aller Attribute Strings. Bei einigen Attributen wie vcpu ist das nicht weiter schlimm (auch wenn es “natürlich” Einträge mit "0.5" CPUs gibt). Bei der Speichergröße memory (im Beispiel mit "1 GiB" angegeben) gibt es durchaus Angaben wie "1,952 GiB". Spätestens bei der CPU-Taktfrequenz clockSpeed (im Beispiel "Up to 3.3 GHz") wird klar, dass die Attribute ursprünglich nicht zur maschinellen Auswertung, sondern nur zur Anzeige gedacht sind.

Bei allen Attributen kann sich übrigens der Wertebereich jederzeit ändern. Die aktuell gültigen Werte können über die API abgefragt werden.

aws pricing get-attribute-values \
    --region us-east-1 \
    --service-code AmazonEC2 \
    --attribute-name clockSpeed

Folgende Attribute sind für die Auswahl der EC2-Preise relevant:

Die erste Filter-Ebene (“On-Demand”, “Reserved Instances”, “Dedicated Hosts”, …) ergibt sich nur teilweise aus Attributen. Die Preise für Standard-OnDemand und “Reserved Instances” benötigen folgende Filter:

Diese Filter reduzieren die Preisliste auf die Einträge, die auch auf der Webseite zu sehen sind. Allerdings enthält jeder Eintrag der Pricelist-API nicht einen Preis, sondern sowohl On-Demand-Preise als auch die Preise für die verschiedenen Reserved-Instances Varianten. D.h. die erste Filterebene der Webseite bildet sich nicht vollständig auf die Produkt-Attribute ab.

jq zum Zweiten…

Zuerst soll jq die Filterkriterien der Webseite auf die gesamte Preisliste anwenden. Die Filter-Funktion select(boolean_expression) lässt nur JSON-Werte passieren, die der angegebenen Bedingung entsprechen. Ein erster Filter könnte also so aussehen:

jq 'select(
        .product.attributes.capacitystatus == "Used"
        and
        .product.attributes.tenancy == "Shared"
    )' \
    < ec2-products.json

Eine deutlich übersichtlichere und einfacher erweiterbare Variante sieht so aus und liefert als einzigen Treffer das Beispiel von oben:

jq 'select(
        .product.attributes
        | contains({
            capacitystatus: "Used",
            tenancy: "Shared",
            currentGeneration: "Yes",
            operatingSystem: "Linux",
            preInstalledSw: "NA",
            location: "US East (Ohio)",
            instanceType: "t2.micro"
        })
    )' \
    < ec2-products.json

Der Pfad-Filter .product.attributes extrahiert den angegebenen Teil aus der gesamten Struktur und übergibt ihn dem contains(element)-Filter. contains prüft in diesem Fall, ob alle Felder des Parameter-Objektes im aktuellen gefilterten Objekt enthalten sind. Das Vergleichsobjekt wird mit der Syntax { key: value, …} aufgebaut.

Preise und Konditionen

Zurück zur Pricing-API: Die product-Struktur definiert nur, “was” verkauft wird – die SKU (Stock Keeping Unit). Dasselbe Produkt kann aber zu unterschiedlichen Konditionen verkauft werden. Dieser Teil wird in der terms Struktur definiert. AWS bietet im Moment für jedes Produkt nur die Konditionen für On-Demand Instanzen sowie für Reserved Instances an. Die Dedicated-Instances und -Hosts, Kapazitätsreservierungen und noch einige Varianten sind separate SKUs. Spot Instances finden sich übrigens gar nicht in dieser Pricing-API.

Die terms Struktur enthält die beiden Unterstrukturen OnDemand und Reserved:

  "terms": {
    "OnDemand": {
        // …
    },
    "Reserved": {
        // …
    }
  },

An der Stelle der Platzhalter // … stehen eine oder mehrere der folgenden Strukturen. Sie haben in JSON zwar keinen Namen, da sie aber ein Feld offerCode enthalten, bietet sich offer an.

  "F9GPUA3E29X6GJVE.VJWZNREJX2": {
    "priceDimensions": {
      "F9GPUA3E29X6GJVE.VJWZNREJX2.6YS6EN2CT7": {
        "unit": "Hrs",
        "endRange": "Inf",
        "description": "Linux/UNIX (Amazon VPC), t2.micro reserved instance applied",
        "appliesTo": [],
        "rateCode": "F9GPUA3E29X6GJVE.VJWZNREJX2.6YS6EN2CT7",
        "beginRange": "0",
        "pricePerUnit": {
          "USD": "0.0000000000"
        }
      },
      "F9GPUA3E29X6GJVE.VJWZNREJX2.2TG2D8R56U": {
        "unit": "Quantity",
        "description": "Upfront Fee",
        "appliesTo": [],
        "rateCode": "F9GPUA3E29X6GJVE.VJWZNREJX2.2TG2D8R56U",
        "pricePerUnit": {
          "USD": "68"
        }
      }
    },
    "sku": "F9GPUA3E29X6GJVE",
    "effectiveDate": "2017-10-31T23:59:59Z",
    "offerTermCode": "VJWZNREJX2",
    "termAttributes": {
      "LeaseContractLength": "1yr",
      "OfferingClass": "convertible",
      "PurchaseOption": "All Upfront"
    }
  },

Bei OnDemand gibt es nur eine dieser Strukturen, bei der termAttributes ein leeres Objekt {} und priceDimensions nur einen Preis enthält.

Bei Reserved gibt es 12 solcher Strukturen, da Reserved Instances folgende Kombinationsmöglichkeiten haben:

Je nach gewählter Vorauszahlung enthält priceDimensions einen oder zwei Einträge.

Die Verarbeitung dieser Strukturen ist schwierig, da sie nicht mit festen Schlüsseln adressierbar sind. Die Schlüssel setzen sich aus Teilen zusammen, von denen nur der erste Teil, die SKU, außerhalb der Struktur definiert ist. Der zweite Teil, der offerTermCode ist schon innerhalb der Struktur definiert. Die Schlüssel der priceDimensions sind mit dem rateCode identisch, der ebenfalls in der Struktur selbst enthalten ist. Das heißt um die Struktur zu adressieren, muss man sie schon adressiert haben, um die darin enthaltenen Informationen zur Adressierung verwenden zu können. Münchhausen konnte sich am eigenen Schopf aus dem Sumpf ziehen – jq kann das nicht.

Die IDs für offerTermCode (VJWZNREJX2) und rateCode (z.B. 6YS6EN2CT7) sind leider nicht öffentlich dokumentiert. Das impliziert, dass sie sich jederzeit ändern können und daher zur Auswertung ungeeignet sind.

jq zum Dritten…

Für eine einfachere Verarbeitung soll die Offer-Unterstruktur von terms aus dem oberen Beispiel in folgende Struktur umgewandelt werden:

"CONV-AU-1yr" : {
    "hourRate": "0.0000000000",
    "upfrontFee": "68"
},

Als erster Schritt soll der Name CONV-AU-1yr aus der Struktur termAttributes berechnet werden. Da das Skript nach und nach länger wird, kann man auf der Kommandozeile nicht mehr bequem arbeiten. Daher schreibt man das Skript in eine Datei und führt es mit jq -f script.jq input.json aus. input.json enthält zuerst nur den Wert der termAttributes-Struktur:

{
  "LeaseContractLength": "1yr",
  "OfferingClass": "convertible",
  "PurchaseOption": "All Upfront"
}

Folgendes Filter-Skript besteht aus 4 Filtern, von denen die ersten drei aus der jeweiligen Eingabe einen Wert herausziehen, in einer Variable speichern und die Eingabe unverändert weitergeben. Im letzten Schritt wird der Name mittels String-Interpolation zusammengebaut:

.OfferingClass as $class
| .PurchaseOption as $upfront
| .LeaseContractLength as $length
| "\($class)-\($upfront)-\($length)"

Das Ergebnis des Skriptes ist

"convertible-All Upfront-1yr"

Das entspricht noch nicht dem Ziel, verdeutlicht aber das Prinzip der Umwandlung.

Sowohl beim ersten Teil einer Variablendefinition (zum Beispiel .OfferingClass) als auch in den Klammern der String-Interpolation können beliebige Filter-Ausdrücke verwendet werden. Die nächste Version des Skriptes führt daher die Transformation der Werte bei der Zuweisung durch:

{
    "standard": "STD",
    "convertible": "CONV"
}[.OfferingClass] as $class
| {
    "No Upfront": "NU",
    "Partial Upfront": "PU",
    "All Upfront": "AU"
}[.PurchaseOption] as $upfront
| .LeaseContractLength as $length
| "\($class)-\($upfront)-\($length)"

Dazu werden die einzelnen Werte mittels Hash-Tabellen umgesetzt: Die Tabelle wird mit der schon bekannten Syntax {key: value} erzeugt und unmittelbar danach wird mit {…}[value] der passende Wert ausgelesen.

Das Skript erfüllt zwar seinen Zweck, soll aber später in einem größeren Kontext verwendet werden. Daher bietet jq die Möglichkeit an, eigene Funktionen zu definieren:

def name: definition ;
def name(filter1; filter2; …) definition ;
def name($parameter1; $parameter2; …): definition ;

Funktionen sind – wie alles andere auch in jq – Filter. Die Funktion arbeitet also in erster Linie auf der aktuellen Eingabe . statt auf den Parametern und erzeugt ein oder mehrere Ausgaben. Die Übergabe von Parametern ist daher in den meisten Fällen nicht nötig, aber man kann sowohl Variablen (mit $ Präfix) als auch Filter übergeben.

def reserved_term_name:
    {
        "standard": "STD",
        "convertible": "CONV"
    }[.OfferingClass] as $class
    | {
        "No Upfront": "NU",
        "Partial Upfront": "PU",
        "All Upfront": "AU"
    }[.PurchaseOption] as $upfront
    | .LeaseContractLength as $length
    | "\($class)-\($upfront)-\($length)"
;

Der nächste Schritt ist die Transformation der Offer-Unterstrukturen von OnDemand bzw. Reserved – hier nochmal gekürzt dargestellt:

  "F9GPUA3E29X6GJVE.VJWZNREJX2": {
    "priceDimensions": {
      "F9GPUA3E29X6GJVE.VJWZNREJX2.6YS6EN2CT7": { … },
      "F9GPUA3E29X6GJVE.VJWZNREJX2.2TG2D8R56U": { … }
    }
  },

Die Schlüssel für die Offer-Strukturen sowie die darin enthaltenen priceDimensions sind nicht sprechend, sondern nur zusammengesetzte technische Codes, die nicht zur Adressierung taugen. Außerdem sind auf beiden Ebenen einzelne Strukturen optional: In priceDimensions kann die Struktur für die Vorauszahlung oder auch für die stündliche Rate fehlen. Ebenso könnten einige oder auch alle Offer-Strukturen fehlen.

Im Ziel-Format soll der Schlüssel aber aus dem Inhalt der Struktur berechnet werden. Die Lösung: jq hat die Funktion from_entries, die aus einem Array von Objekten mit den Schlüsseln key und value ein neues Objekt erzeugt:

jq -n '[ {key: "foo", value: "bar"} ] | from_entries'

{
  "foo": "bar"
}

Der Plan ist daher, aus den Werten von OnDemand, Reserved und priceDimensions jedes Attribut (eine Offer bzw. eine priceDimension) herauszuziehen, jeden Wert in einen key/value Eintrag umzuwandeln und daraus ein neues Objekt zu bauen. Das Ergebnis soll den alten Wert von OnDemand, Reserved oder priceDimension ersetzen. Diesen Plan setzt die Hilfsfunktion map_helper um:

def map_helper(f):
    . // {}
    | map(f)
    | from_entries
;

map(f) extrahiert, wie der schon bekannte .[]-Filter, Werte aus Arrays und Objekten und wendet den Filter f an. Neu ist nur, dass der Parameter von map_helper ein Filter und keine Variable ist. Das erste Filterglied der Kette . // {} liefert die Eingabe . zurück, solange sie nicht null oder false ist, ansonsten das leere Objekt {}. Das ist nötig, weil z.B. Reserved manchmal fehlt.

Der Filter für die Ebene priceDimensions lautet:

def map_prices:
    map_helper(
        if .unit == "Hrs" or .unit == "Hours" then
            { key: "hourRate",   value: .pricePerUnit.USD }
        elif .unit == "Quantity" then
            { key: "upfrontFee", value: .pricePerUnit.USD }
        else
            error("Unknown priceDimension '\(.unit)'")
        end
    )
;

map_prices erzeugt aus priceDimensions der exemplarischen Offer-Struktur diese Ausgabe:

{
  "hourRate": "0.0000000000",
  "upfrontFee": "68"
}

Der Filter für die Offer-Ebene ist im Prinzip deutlich kürzer, da zum vorherigen Filter eigentlich nur noch ein Name wie CONV-AU-1yr für die Offer-Struktur nötig ist und dieses Problem ja schon reserved_term_name löst. Allerdings eben nur für den Reserved-Anteil – im OnDemand-Anteil fehlt die termAttributes-Struktur bzw. ist leer. term_name überbrückt diese Lücke.

def map_offers:
    map_helper({
        key: .termAttributes | term_name,
        value: .priceDimensions | map_prices
    })
;

def term_name:
    if . == {} then
        "OnDemand"
    else
        reserved_term_name
    end
;

Der Filter map_offers reduziert die 32 Zeilen der exemplarischen Offer-Struktur (plus zwei weitere syntaktische Zeilen) auf folgende 6 Zeilen:

{
  "CONV-AU-1yr": {
    "hourRate": "0.0000000000",
    "upfrontFee": "68"
  }
}

Damit sind die beiden großen Teile der Preislisten-API behandelt und müssen nur noch zu einem hübschen Paket verschnürt werden. Die einfachste Möglichkeit ist, jeden Preislisten-Eintrag komplett neu aufzubauen:

{
    product: .product | {
        sku,
        keys: .attributes | {
            capacitystatus,
            tenancy,
            currentGeneration,
            location,
            instanceType,
            operatingSystem,
            preInstalledSw,
            licenseModel
        },
        data: .attributes | {
            vcpu: .vcpu,
            memory: .memory
        }
    },
    pricing: .terms | {
        OnDemand: .OnDemand | map_offers | .OnDemand,
        Reserved: .Reserved | map_offers
    }
}

Hier wird also keine Hilfs-Funktion mehr definiert, sondern der “Haupt”-Filter. Mit der schon bekannten {}-Syntax zur Objekt-Erzeugung wird ein Eingabe-Objekt in die neue Struktur überführt.

Eine Kleinigkeit fällt aber auf: Statt der Syntax { key: value,…} wird an einigen Stellen die Syntax { key, … } als Abkürzung von { key: .key } verwendet.

Die Struktur ist schon gut im Filter ablesbar aber die Ausgabe darf natürlich nicht fehlen:

{
  "product": {
    "sku": "F9GPUA3E29X6GJVE",
    "keys": {
      "capacitystatus": "Used",
      "tenancy": "Shared",
      "currentGeneration": "Yes",
      "location": "US East (Ohio)",
      "instanceType": "t2.micro",
      "operatingSystem": "Linux",
      "preInstalledSw": "NA",
      "licenseModel": "No License required"
    },
    "data": {
      "vcpu": "1",
      "memory": "1 GiB"
    }
  },
  "pricing": {
    "OnDemand": {
      "hourRate": "0.0116000000"
    },
    "Reserved": {
      "STD-NU-1yr": {
        "hourRate": "0.0072000000"
      },
      "CONV-PU-1yr": {
        "upfrontFee": "34",
        "hourRate": "0.0039000000"
      },
      // 10 weitere Abschnitte
    }
  }
}

Das neue product enthält Informationen aus dem alten product-Teil – aber nur die SKU sowie die Attribute für die verschiedenen Filterebenen sowie einige wenige zusätzliche Attribute wie vcpu und memory. Dabei sind die Filter-Attribute in keys gebündelt, die zusätzlichen Attribute in data.

Neben product liegt pricing, das die Informationen von terms enthält – allerdings dank map_offers stark vereinfacht. Die technischen IDs sind verschwunden – alle Informationen lassen sich direkt adressieren.

Die Reduktion sowohl an Platz, vor allem aber an Komplexität ist erstaunlich. Die Daten können dadurch deutlich einfacher in Analyse-Tools, Jupyter-Notebooks oder Excel geladen werden.

Apropos Platz: In den Daten vom 18.11.2019 befinden sich 211500 Einträge für EC2 Instanzen (oder deren Reservierungen, …), die nach der fromjson Umwandlung 758 MiB benötigen. Die Vereinfachung reduziert das auf 117MiB – also auf 15% der ursprünglichen Größe – in beiden Fällen ohne Pretty-Printing und einem Eintrag pro Zeile.

Fazit

Es ist in der Regel einfach, einige wenige Daten mit jq zu extrahieren. Wenn aber die Eingangsdaten, wie im Fall der AWS Pricing-API, regelrecht verkorkst sind, wird die Lernkurve mit jq relativ steil. Das liegt zum einen an der ungewohnten Art, wie Filter verbunden werden können. Zum anderen sind anfangs gerade die Möglichkeiten, schrittweise und strukturiert vorzugehen, nicht bekannt oder fallen in der Dokumentation nicht ins Auge. Der Artikel geht deutlich über das “Hello World”-Niveau hinaus, zeigt aber auch wie mit eigenen Funktionen schrittweise komplexe Transformationen dennoch übersichtlich gelingen können. Als noch weitergehende Möglichkeiten kann ein jq-Skript übrigens auch weitere Module oder JSON-Referenzdaten importieren.

vollständiges Script

#!/usr/bin/jq -f

###########################################

def reserved_term_name:
    {
        "standard": "STD",
        "convertible": "CONV"
    }[.OfferingClass] as $class
    | {
        "No Upfront": "NU",
        "Partial Upfront": "PU",
        "All Upfront": "AU"
    }[.PurchaseOption] as $upfront
    | .LeaseContractLength as $length
    | "\($class)-\($upfront)-\($length)"
;

def term_name:
    if . == {} then
        "OnDemand"
    else
        reserved_term_name
    end
;

###########################################

def map_helper(f):
    . // {}
    | map(f)
    | from_entries
;

def map_prices:
    map_helper(
        if .unit == "Hrs" or .unit == "Hours" then
            { key: "hourRate",   value: .pricePerUnit.USD }
        elif .unit == "Quantity" then
            { key: "upfrontFee", value: .pricePerUnit.USD }
        else
            error("Unknown priceDimension '\(.unit)'")
        end
    )
;

def map_offers:
    map_helper({
        key: .termAttributes | term_name,
        value: .priceDimensions | map_prices
    })
;

###########################################

{
    product: .product | {
        sku,
        keys: .attributes | {
            capacitystatus,
            tenancy,
            currentGeneration,
            location,
            instanceType,
            operatingSystem,
            preInstalledSw,
            licenseModel
        },
        data: .attributes | {
            vcpu: .vcpu,
            memory: .memory
        }
    },
    pricing: .terms | {
        OnDemand: .OnDemand | map_offers | .OnDemand,
        Reserved: .Reserved | map_offers
    }
}

Kurzbiografie

Andreas Heiduk ist als Senior Consultant für MATHEMA Software GmbH tätig. Seine Themenschwerpunkte umfassen Domänenspezifische Sprachen, die Java Standard Edition (JSE) und die Java Enterprise Edition (JEE). Daneben findet er die unterschiedlichsten Themen von hardwarenaher Programmierung bis hin zu verteilten Anwendungen interessant.

  1. EC2 Pricing, Amazon ↩︎

  2. Using the AWS Price List API, Amazon ↩︎

  3. jq Homepage ↩︎

  4. Guide to Linux jq Command for JSON processing, Jonathan Cook, 2019-11-07 ↩︎