Von Heuhaufen und Nadeln – JSON Transformator jq bringt Licht ins Dunkel der AWS EC2 Preise
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
- Reserved Instances
- Dedicated Hosts
- Spot Instances
- Saving Plans
“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:
- Linux
- RHEL
- SLES
- Windows
- Windows with SQL-Standard
- Windows with SQL-Web
- Windows with SQL-Enterprise
- Linux with SQL-Standard
- Linux with SQL-Web
- Linux with SQL-Enterprise
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:
- General Purpose – Current Generation
- Compute Optimized – Current Generation
- GPU Instances – Current Generation
- Memory Optimized – Current Generation
- Storage Optimized – Current Generation
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:
-
operatingSystem
gibt das Betriebssystem an ("Linux"
,"RHEL"
,"SUSE"
,"Windows"
) -
preInstalledSw
enthält die Angaben zum optionalen SQL-Server ("NA"
,"SQL Ent"
,"SQL Std"
,"SQL Web"
) -
licenseModel
enthält die Werte"NA"
,"Bring your own license"
und"No License required"
Diese drei Attribute entsprechen der eingangs erwähnten zweiten Filterebene – den verschiedenen Reitern der Tabelle.
-
location
gibt die AWS-Region anDieses Attribut bildet die dritte Filterebene ab – die Dropdown-Box. Streng genommen gehört auch das Attribut
locationType
dazu, das aber aktuell immer den Wert"AWS-Region"
hat. -
instanceFamily
(z.B."General purpose"
)Dieses Attribut entspricht der Unterteilung der großen Tabelle – die vierte Ebene.
-
currentGeneration
("Yes"
oder"No"
)Die Preise für die älteren Instanz-Typen werden auf anderen Webseiten aufgeführt – dies entspricht der fünften Filter-Ebene.
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:
-
capacitystatus
:"Used"
Dieser Filter schließt Kapazitätsreservierungen u.Ä aus.
-
tenancy
:"Shared"
Filter für Dedicated Hosts bzw -Instances
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:
-
Laufzeit:
1yr
oder3yr
-
Fester Instance-Type oder nicht:
standard
oderconvertible
-
Vorauszahlung:
No Upfront
,Partial Upfront
,All Upfront
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
-
EC2 Pricing, Amazon ↩︎
-
Using the AWS Price List API, Amazon ↩︎
-
Guide to Linux jq Command for JSON processing, Jonathan Cook, 2019-11-07 ↩︎