6 min czytania

Tag obrazu to wersja: dlaczego nigdy :latest i jak wycofać wdrożenie jednym commitem

Deployment wskazujący app:latest działa do pierwszego restartu poda na innym węźle. Jak tagować obrazy wersją i skrótem commita, przypinać je po digest, chronić tagi w rejestrze i sprzątać rejestr bez ryzyka.

Rys. 1. Każdy build dostaje niezmienny tag z wersją i skrótem commita, a klaster uruchamia dokładnie to, co wskazuje manifest; ruchomy tag latest nie mówi, co działa, więc nie ma do czego wrócić.

Aplikacja działała tydzień bez zmian. W nocy węzeł został zrestartowany po aktualizacji systemu, pod uruchomił się na innym węźle i pobrał obraz app:latest, który od wczoraj wskazywał na niewydaną wersję z gałęzi głównej. Rano aplikacja zachowuje się inaczej, w repozytorium wdrożeń nie ma żadnego nowego commita, a na pytanie „do czego wracamy” nie ma odpowiedzi, bo poprzedni latest już nie istnieje.

Tag obrazu powinien być wersją: niezmiennym identyfikatorem jednego builda. Wtedy manifest w Git mówi dokładnie, co działa, różne węzły uruchamiają ten sam kod, a wycofanie jest zmianą jednej linii.

Dlaczego nigdy :latest

latest nie znaczy „najnowsza stabilna wersja”. To zwykły tag, który narzędzia nadają domyślnie, gdy nie podano innego, i który można w każdej chwili przestawić na dowolny obraz. Z tego wynikają trzy problemy:

  • Brak powtarzalności. Ten sam manifest na dwóch węzłach może uruchomić dwa różne obrazy, zależnie od tego, kiedy który węzeł go pobrał.
  • Brak wycofania. Nie ma zapisu, na jaki obraz wskazywał latest tydzień temu, więc nie ma do czego wrócić.
  • Zależność od rejestru przy każdym starcie poda. Kubernetes ustawia domyślną politykę pobierania na podstawie tagu.
Odwołanie w manifeścieDomyślne imagePullPolicySkutek
app:latestAlwayskażdy start kontenera odpytuje rejestr; gdy rejestr nie odpowiada, pod nie wystartuje
app (bez tagu)Alwaysjak wyżej, bo brak tagu oznacza latest
app:1.4.2-3f9c2a1bIfNotPresentobraz z węzła, jeśli już tam jest
app@sha256:…IfNotPresentobraz identyfikowany treścią

Przy Always kubelet przy każdym uruchomieniu kontenera sprawdza w rejestrze, na jaki digest wskazuje tag. Awaria rejestru albo wyczerpany limit zapytań oznacza wtedy, że restart poda kończy się błędem ErrImagePull, nawet jeśli obraz leży na węźle.

Schemat tagów: wersja i skrót commita

Dobry tag odpowiada na dwa pytania: która to wersja i z jakiego kodu powstała. Format 1.4.2-3f9c2a1b łączy wersję semantyczną (kolejność i zgodność czytelne dla ludzi) ze skrótem commita (jednoznaczne powiązanie z kodem). W GitLab CI wersja pochodzi z tagu Git v1.4.2, a skrót ze zmiennej CI_COMMIT_SHORT_SHA, która ma osiem znaków.

build-image:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:v1.24.0-debug
    entrypoint: [""]
  rules:
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
  script:
    # logowanie do rejestru jak w poprzedniej części serii
    - VERSION="${CI_COMMIT_TAG#v}"
    - /kaniko/executor
      --context "${CI_PROJECT_DIR}"
      --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
      --destination "${CI_REGISTRY_IMAGE}:${VERSION}-${CI_COMMIT_SHORT_SHA}"
      --destination "${CI_REGISTRY_IMAGE}:${VERSION}"
      --digest-file "${CI_PROJECT_DIR}/image-digest.txt"
  artifacts:
    paths: [image-digest.txt]

Buildy z gałęzi roboczych, których nikt nie wdraża na produkcję, dostają tylko tag sha-3f9c2a1b. Dzięki temu po samym tagu widać, czy obraz jest wydaniem, a polityka sprzątania może je łatwo rozróżnić. Plik z --digest-file zawiera digest wypchniętego obrazu i przydaje się w kolejnym zadaniu, które zmienia manifest.

Niezmienność tagów w rejestrze

Schemat tagów nic nie daje, jeśli ktoś może wypchnąć inny obraz pod istniejący tag. Rejestry mają do tego mechanizmy:

  • GitLab: reguły ochrony tagów kontenerów (dostępne także w wersji bezpłatnej) ograniczają wypychanie i usuwanie tagów pasujących do wzorca do wybranych ról. Pełna niezmienność, niezależna od roli, jest w planie Ultimate.
  • Harbor: reguły niezmienności tagów w ustawieniach projektu.
  • Amazon ECR: ustawienie repozytorium imageTagMutability na IMMUTABLE.

Niezależnie od rejestru pipeline może sam odmówić nadpisania. Fragment zadania uruchamianego przed budowaniem, w obrazie zawierającym skopeo:

TAG="${CI_COMMIT_TAG#v}-${CI_COMMIT_SHORT_SHA}"
if skopeo inspect --creds "${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD}" \
     "docker://${CI_REGISTRY_IMAGE}:${TAG}" > /dev/null 2>&1; then
  echo "Tag ${TAG} już istnieje w rejestrze, przerywam."
  exit 1
fi

Przypinanie po digest

Digest (sha256:…) to skrót treści manifestu obrazu. Tag można przestawić, digestu nie: inny obraz ma inny digest. Kubernetes akceptuje zapis z tagiem i digestem jednocześnie; obraz jest wtedy identyfikowany po digeście, a tag zostaje czytelną informacją dla ludzi.

spec:
  template:
    spec:
      containers:
        - name: app
          image: registry.example.com/shop/app:1.4.2-3f9c2a1b@sha256:6f1c9e…

Digest odczytuje się bez pobierania obrazu:

skopeo inspect --format '{{.Digest}}' docker://registry.example.com/shop/app:1.4.2-3f9c2a1b

Przypinanie po digest jest obowiązkowe tam, gdzie nie kontrolujesz niezmienności tagów: obrazy bazowe, obrazy narzędzi w CI, obrazy z publicznych chartów Helm. Dla własnych obrazów w rejestrze z ochroną tagów wystarczy tag z wersją i commitem; digest dodaje się dla usług najważniejszych.

Wycofanie: revert manifestu

Gdy wdrożenie odbywa się przez GitOps, każda zmiana wersji jest commitem w repozytorium manifestów. Wycofanie to cofnięcie tego commita:

git log --oneline -3 -- apps/shop/overlays/prod/
# a91c0de shop: 1.4.3-7d2e91f0
# 5be2f13 shop: 1.4.2-3f9c2a1b
git revert --no-edit a91c0de
git push origin main

Kontroler GitOps zobaczy zmianę i przywróci poprzedni obraz. Ponieważ tag 1.4.2-3f9c2a1b nadal istnieje i wskazuje na ten sam obraz, wycofanie przywraca dokładnie to, co działało wcześniej.

Polityka sprzątania rejestru

Niezmienne tagi oznaczają, że rejestr rośnie. Sprzątanie musi rozróżniać wydania od buildów roboczych. W GitLabie służy do tego polityka czyszczenia tagów w ustawieniach rejestru kontenerów projektu (Settings → Packages and registries):

Grupa tagówWzorzecZasada
Wydania\d+\.\d+\.\d+(-[0-9a-f]{8})?zachowywane zawsze
Buildy roboczesha-[0-9a-f]{8}usuwane po 14 dniach, 10 ostatnich zostaje
Pamięć podręczna warstwosobne repozytorium cacheodtwarzalna, czas życia jak w narzędziu budującym

GitLab sam otacza wzorce kotwicami początku i końca, więc w polu nie wpisuje się ^ ani $. Usunięcie tagów nie zwalnia od razu miejsca na dysku: w instancji samodzielnie utrzymywanej robi to dopiero odśmiecanie rejestru, uruchamiane w oknie serwisowym. Zanim reguła zacznie działać, trzeba porównać ją z listą obrazów uruchomionych w klastrach: obraz, który działa, nie może zniknąć z rejestru, bo następny restart poda na innym węźle skończy się błędem pobierania.

Sprawdź u siebie

Obrazy z tagiem latest albo bez tagu, uruchomione w klastrze:

kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.spec.containers[*].image}{"\n"}{end}' \
  | awk '{for (i = 2; i <= NF; i++) { n = split($i, p, "/"); if (p[n] !~ /[:@]/ || p[n] ~ /:latest$/) print $1, $i }}' \
  | sort -u
# Dobrze: pusty wynik. Każda linia to przestrzeń nazw i obraz do poprawy.

Obraz z manifestu i digest, który faktycznie działa:

kubectl get pods -n shop -o custom-columns='POD:.metadata.name,IMAGE:.spec.containers[*].image,IMAGEID:.status.containerStatuses[*].imageID'
# Dobrze: wszystkie repliki mają ten sam imageID.
# Źle: różne digesty dla tego samego tagu, czyli tag był nadpisany między pobraniami.

Digest i data utworzenia obrazu w rejestrze:

skopeo inspect --format '{{.Digest}} {{.Created}}' docker://registry.example.com/shop/app:1.4.2-3f9c2a1b
# Dla obrazu jednoarchitekturowego digest powinien zgadzać się z końcówką imageID z poprzedniego polecenia.

Ustawienia polityki sprzątania projektu w GitLabie:

glab api "projects/shop%2Fapp" | jq '.container_expiration_policy'
# Dobrze: enabled true i name_regex_keep obejmujący tagi wydań.
# Źle: enabled false przy rosnącym rejestrze albo brak wzorca zachowania wydań.