Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Wir hatten 2 SĂ€cke Gras, 75 Tabletten Mescalin, eine Unix-Umgebung, ein Docker-Repository und die Aufgabe, die Befehle docker pull und docker push ohne den Docker-Client zu implementieren.

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

UPD:
Frage: Wozu das alles?
Antwort: Lasttests des Produkts (NICHT mit Bash-Methoden, die Skripte sind zu Bildungszwecken beigefĂŒgt). Es wurde entschieden, den Docker-Client nicht zu verwenden, um zusĂ€tzliche Schichten (in einem angemessenen Rahmen) zu reduzieren und entsprechend eine höhere Last zu emulieren. Dadurch wurden alle systembedingten Verzögerungen des Docker-Clients entfernt. Wir erhielten eine relativ reine Last direkt auf das Produkt.
In dem Artikel wurden Werkzeuge der GNU-Versionen verwendet.

Lassen Sie uns zunÀchst klÀren, was diese Befehle tun.

Also, wozu wird docker pull verwendet? Laut Dokumentation:

"Ziehe ein Bild oder ein Repository aus einem Registry."

Dort finden wir auch den Link zu verstehen Sie Bilder, Container und Speicher-Treiber..

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Von hier aus können wir verstehen, dass ein Docker-Image eine Sammlung von Schichten ist, die Informationen ĂŒber die letzten Änderungen im Image enthalten, die offensichtlich fĂŒr uns von Bedeutung sind. Danach schauen wir in die Registry-API..

Hier steht Folgendes:

"Ein 'Image' ist eine Kombination aus einem JSON-Manifest und individuellen Schichtdateien. Der Vorgang des Ziehens eines Images konzentriert sich auf das Abrufen dieser beiden Komponenten."

Der erste Schritt laut Dokumentation ist “Ziehen eines Image-Manifests.”.

Wir werden es natĂŒrlich nicht ziehen, aber die Daten daraus benötigen wir. Weiterhin folgt ein Beispiel fĂŒr eine Anfrage: GET /v2/{name}/manifests/{reference}

"Der Name und der Referenzparameter identifizieren das Bild und sind erforderlich. Die Referenz kann ein Tag oder Digest enthalten."

Unser Docker-Repository ist lokal bereitgestellt, versuchen wir, die Anfrage auszufĂŒhren:

curl -s -X GET "http://localhost:8081/link/to/docker/registry/v2/centos-11-10/manifests/1.1.1" -H "header_if_needed"

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Im Antwort erhalten wir JSON, aus dem uns derzeit nur die Schichten, genauer gesagt ihre Hashes, interessieren. Nachdem wir sie erhalten haben, können wir jeden durchgehen und folgende Anfrage ausfĂŒhren: "GET /v2/{name}/blobs/{digest}"

„Der Zugang zu einer Schicht wird durch den Namen des Repositories geregelt, ist jedoch im Registry eindeutig durch Digest identifiziert.“

Digest ist in diesem Fall der Hash, den wir erhalten haben.

Wir probieren es aus

curl -s -X GET "http://localhost:8081/link/to/docker/registry/v2/centos-11-10/blobs/sha256:f972d139738dfcd1519fd2461815651336ee25a8b54c358834c50af094bb262f" -H "header_if_needed" --output firstLayer

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Sehen wir uns an, welche Datei wir letztendlich als erste Schicht erhalten haben.

file firstLayer

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

d.h. die Schichten sind tar-Archive, deren Entpackung in entsprechender Reihenfolge den Inhalt des Images ergibt.

Lassen Sie uns ein kleines Bash-Skript schreiben, um all dies zu automatisieren.

#!/bin/bash -eu

downloadDir=$1
# url as http://localhost:8081/link/to/docker/registry
url=$2
imageName=$3
tag=$4

# array of layers
layers=($(curl -s -X GET "$url/v2/$imageName/manifests/$tag" | grep -oP '(?<=blobSum" : ").+(?=")'))

# download each layer from array
for layer in "${layers[@]}"; do
    echo "Downloading ${layer}"
    curl -v -X GET "$url/v2/$imageName/blobs/$layer" --output "$downloadDir/$layer.tar"
done

# find all layers, untar them and remove source .tar files
cd "$downloadDir" && find . -name "sha256:*" -exec tar xvf {} ;
rm sha256:*.tar
exit 0

Jetzt können wir es mit den gewĂŒnschten Parametern starten und den Inhalt des benötigten Images erhalten.

.\/script.sh dirName "http:\/\/localhost:8081\/link\/to\/docker\/registry" myAwesomeImage 1.0

Teil 2 — docker push

Hier wird es etwas komplizierter.

Lass uns wieder mit Dokumentationbeginnen. Also, wir mĂŒssen jede Schicht hochladen, das entsprechende Manifest erstellen und es auch hochladen. Klingt einfach.

Nachdem wir die Dokumentation studiert haben, können wir den Upload-Prozess in mehrere Schritte unterteilen:

  • Initialisierung des Prozesses — "POST \/v2\/{repoName}\/blobs\/uploads\/"
  • Hochladen der Schicht (wir werden die monolithische Übertragung verwenden, d.h. jede Schicht wird ganz gesendet) — "PUT \/v2\/{repoName}\/blobs\/uploads\/{uuid}?digest={digest}
    Content-Length: {size of layer}
    Content-Type: application\/octet-stream
    Layer Binary Data".
  • Hochladen des Manifests — "PUT \/v2\/{repoName}\/manifests\/{reference}".

Aber in der Dokumentation wurde ein Schritt ausgelassen, ohne den nichts funktionieren wird. FĂŒr den monolithischen Upload, ebenso wie fĂŒr den partiellen (chunked), muss vor dem Hochladen einer Schicht ein PATCH-Antrag ausgefĂŒhrt werden:

"PATCH \/v2\/{repoName}\/blobs\/uploads\/{uuid}
Content-Length: {size of chunk}
Content-Type: application\/octet-stream
{Layer Chunk Binary Data}".

Andernfalls können Sie nicht ĂŒber den ersten Punkt hinauskommen, da Sie anstelle des erwarteten Antwortcodes 202 einen 4xx-Code erhalten werden.

Jetzt sieht der Algorithmus so aus:

  • Initialisierung
  • Patch der Schicht
  • Hochladen der Schicht
  • Hochladen des Manifests
    Die Punkte 2 und 3 werden entsprechend so oft wiederholt, wie Schichten hochgeladen werden mĂŒssen.

ZunÀchst benötigen wir ein beliebiges Image. Ich werde archlinux:latest verwenden.

docker pull archlinux

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Jetzt speichern wir es lokal fĂŒr weitere Analysen.

docker save c24fe13d37b9 -o savedArch

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Entpacken Sie das erhaltene Archiv im aktuellen Verzeichnis.

tar xvf savedArch

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Wie wir sehen, liegt jede Schicht in einem eigenen Ordner. Lassen Sie uns nun die Struktur des Manifests betrachten, das wir erhalten haben.

cat manifest.json | json_pp

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Nicht viel. Lassen Sie uns sehen, welches Manifest fĂŒr den Upload erforderlich ist, gemĂ€ĂŸ Dokumentation.

Implementierung der Befehle docker pull und docker push ohne Docker-Client durch HTTP-Anfragen.

Offensichtlich ist das vorhandene Manifest nicht geeignet, also machen wir unser eigenes mit Blackjack und Layern und Konfigurationen.

Wir werden immer mindestens eine config-Datei und ein Array von Schichten haben. Versionsschema 2 (gĂŒltig zum Zeitpunkt des Schreibens des Artikels), mediaType bleibt unverĂ€ndert:

echo ‘{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": config_size,
      "digest": "config_hash"
   },
   "layers": [
      ’ > manifest.json

Nach der Erstellung des Basis-Manifests muss es mit gĂŒltigen Daten gefĂŒllt werden. Dazu verwenden wir das Template des JSON-Objekts fĂŒr die Schicht:

{
         "mediaType": "application\/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": ${layersSizes[$i]},
         "digest": "sha256:${layersNames[$i]}"
      },

dies werden wir fĂŒr jedes Layer im Manifest hinzufĂŒgen.

Als NĂ€chstes mĂŒssen wir die GrĂ¶ĂŸe der Konfigurationsdatei herausfinden und die Platzhalter im Manifest durch die tatsĂ€chlichen Daten ersetzen.

sed -i "s/config_size/[33m$configSize[0m/g; s/config_hash/[33m$configName[0m/g" $manifestFile

Nun können wir den Upload-Prozess initiieren und uns die UUID speichern, die alle nachfolgenden Anfragen begleiten sollte.

Das vollstÀndige Skript sieht ungefÀhr so aus:

#!/bin/bash -eux

imageDir=$1
# url as http://localhost:8081/link/to/docker/registry
url=$2
repoName=$3
tag=$4
manifestFile=$(readlink -f ${imageDir}/manifestCopy)
configFile=$(readlink -f $(find $imageDir -name "*.json" ! -name "manifest.json"))

# calc layers sha 256 sum, rename them accordingly, and add info about each to manifest file
function prepareLayersForUpload() {
  info_file=$imageDir/info
  # lets calculate layers sha256 and use it as layers names further
  layersNames=($(find $imageDir -name "layer.tar" -exec shasum -a 256 {} ; | cut -d" " -f1))

  # rename layers according to shasums. !!!Set required amount of fields for cut command!!!
  # this part definitely can be done easier but i didn't found another way, sry
  find $imageDir -name "layer.tar" -exec bash -c 'mv {} "$(echo {} | cut -d"/" -f1,2)/$(shasum -a 256 {} | cut -d" " -f1)"' ;

  layersSizes=($(find $imageDir -name "*.tar" -exec ls -l {} ; | awk '{print $5}'))

  for i in "${!layersNames[@]}"; do
    echo "{
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": ${layersSizes[$i]},
         "digest": "sha256:${layersNames[$i]}"
      }," >> $manifestFile
  done
  # remove last ','
  truncate -s-2 $manifestFile
  # add closing brakets to keep json consistent
  printf "nt]n}" >> $manifestFile
}

# calc config sha 256 sum and add info about it to manifest
function setConfigProps() {
  configSize=$(ls -l $configFile | awk '{print $5}')
  configName=$(basename $configFile | cut -d"." -f1)

  sed -i "s/config_size/$configSize/g; s/config_hash/$configName/g" $manifestFile
}

#prepare manifest file
prepareLayersForUpload
setConfigProps
cat $manifestFile

# initiate upload and get uuid
uuid=$(curl -s -X POST -I "$url/v2/$repoName/blobs/uploads/" | grep -oP "(?<=Docker-Upload-Uuid: ).+")

# patch layers
# in data-binary we're getting absolute path to layer file
for l in "${!layersNames[@]}"; do
  pathToLayer=$(find $imageDir -name ${layersNames[$l]} -exec readlink -f {} ;)
    curl -v -X PATCH "$url/v2/$repoName/blobs/uploads/$uuid" 
  -H "Content-Length: ${layersSizes[$i]}" 
  -H "Content-Type: application/octet-stream" 
  --data-binary "@$pathToLayer"

# put layer
  curl -v -X PUT "$url/v2/$repoName/blobs/uploads/$uuid?digest=sha256:${layersNames[$i]}" 
  -H 'Content-Type: application/octet-stream' 
  -H "Content-Length: ${layersSizes[$i]}" 
  --data-binary "@$pathToLayer"
done

# patch and put config after all layers
curl -v -X PATCH "$url/v2/$repoName/blobs/uploads/$uuid" 
  -H "Content-Length: $configSize" 
  -H "Content-Type: application/octet-stream" 
  --data-binary "@$configFile"

  curl -v -X PUT "$url/v2/$repoName/blobs/uploads/$uuid?digest=sha256:$configName" 
  -H 'Content-Type: application/octet-stream' 
  -H "Content-Length: $configSize" 
  --data-binary "@$configFile"

# put manifest
curl -v -X PUT "$url/v2/$repoName/manifests/$tag" 
  -H 'Content-Type: application/vnd.docker.distribution.manifest.v2+json' 
  --data-binary "@$manifestFile"

exit 0

Wir können ein fertiges Skript verwenden:

./uploadImage.sh "~\/path\/to\/saved\/image" "http:\/\/localhost:8081\/link\/to\/docker\/registry" myRepoName 1.0

UPD:
Was haben wir als Ergebnis erhalten?
Erstens, echte Daten zur Analyse, da die Tests in BlazeMeter ausgefĂŒhrt werden und die Daten zu den Docker-Client-Anfragen im Vergleich zu reinen HTTP-Anfragen wenig informativ sind.

Zweitens haben wir durch den Wechsel die Anzahl der virtuellen Benutzer fĂŒr den Docker-Upload um etwa 150 % erhöhen und gleichzeitig die durchschnittliche Antwortzeit um 20-25 % verkĂŒrzen können. Beim Docker-Download konnten wir die Anzahl der Benutzer um 500 % erhöhen, wĂ€hrend die durchschnittliche Antwortzeit um etwa 60 % gesenkt wurde.

Danke fĂŒr Ihre Aufmerksamkeit.

Quelle: habr.com

60GB SSD 8Gb DDR4