W połowie sierpnia stawiałem produkcyjny klaster i przenosiłem na niego własny serwer Gitea. To był dobry moment, żeby uporządkować drogę, którą kod przechodzi od commita do działającego poda. Pętlę miałem już sprawdzoną na domowym klastrze od 1 sierpnia, najpierw na aplikacji testowej, potem na pierwszej prawdziwej stronie. Teraz miała obsłużyć wszystko.
Pętla w czterech krokach
- Push do repozytorium aplikacji uruchamia workflow w Gitea Actions.
- CI buduje obraz i wysyła go do rejestru OCI wbudowanego w Gitea, z tagiem równym SHA commita.
- Ten sam workflow klonuje repozytorium GitOps, podmienia tag obrazu w manifeście i robi commit „Deploy aplikacja SHA”.
- Argo CD widzi różnicę i oznacza aplikację jako „OutOfSync”. Synchronizację uruchamiam ja.
Najważniejszy jest krok trzeci. Klaster nigdy nie pyta rejestru „co jest najnowsze”. Wersja, która działa, jest zapisana w gicie, więc git log repozytorium GitOps jest jednocześnie historią wdrożeń, a cofnięcie wdrożenia to git revert jednego commita.
# .gitea/workflows/build.yml (uogólnione)
name: Build and deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: homelab
steps:
- uses: actions/checkout@v4
- name: Build and push
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.example.com -u ci --password-stdin
docker build -t registry.example.com/me/app:${{ github.sha }} .
docker push registry.example.com/me/app:${{ github.sha }}
- name: Bump the image tag in the GitOps repo
run: |
git clone --depth=1 "https://ci:${{ secrets.GITOPS_TOKEN }}@git.example.com/me/gitops.git" gitops
cd gitops
sed -i "s|image: registry.example.com/me/app:.*|image: registry.example.com/me/app:${{ github.sha }}|" app/deployment.yaml
git diff --quiet && exit 0
git -c user.name="app CI" -c user.email="$CI_EMAIL" commit -am "Deploy app ${{ github.sha }}"
git push
Token do rejestru ma tylko uprawnienie do zapisu paczek, a klaster pobiera obrazy własnym tokenem tylko do odczytu, zapieczętowanym jako SealedSecret osobno w każdym namespace.
Dlaczego nic nie wdraża się samo
Argo CD potrafi synchronizować automatycznie i naprawiać każde odchylenie od gita. U mnie ta opcja jest wyłączona na obu klastrach i to jest decyzja, a nie zaległość. Commit w gicie to propozycja. Wdrożenie to osobna, świadoma czynność, przed którą widzę dokładny diff. Przy jednej osobie utrzymującej całość to najtańsza forma przeglądu zmian.
Ręczna synchronizacja ma swoje pułapki i jedną z nich opłaciłem błędem na produkcji. W Argo CD synchronizacja wybranych zasobów, a także strategia apply zamiast domyślnej hook, pomija hooki. Moja aplikacja miała Job z migracją bazy jako hook przed wdrożeniem. Synchronizacja odziedziczyła listę zasobów z poprzedniej operacji, migracja się nie uruchomiła, a nowa wersja przez chwilę pytała bazę o kolumnę, której jeszcze nie było. Od tamtej pory synchronizuję takie aplikacje w całości, z domyślną strategią, i sprawdzam, czy Job migracji się wykonał.
Renovate: aktualizacje jako pull requesty, nie niespodzianki
Tydzień później doszedł Renovate. Raz w tygodniu przegląda repozytoria GitOps i otwiera pull requesty z nowymi wersjami chartów Helm i obrazów. Zasady są surowe:
{
"extends": ["config:recommended"],
"schedule": ["* * * * 1"],
"minimumReleaseAge": "3 days",
"packageRules": [
{
"description": "Wydania, które zmieniają zachowanie, odczekują tydzień",
"matchUpdateTypes": ["minor", "major"],
"minimumReleaseAge": "7 days"
},
{
"description": "Sieć klastra: wersja widoczna na liście, PR dopiero po świadomym zaznaczeniu",
"matchPackageNames": ["cilium", "quay.io/cilium/**"],
"dependencyDashboardApproval": true
},
{
"description": "Własne obrazy podbija CI; PR od Renovate walczyłby z nim",
"matchPackageNames": ["registry.example.com/**"],
"enabled": false
}
]
}
Nic nie scala się automatycznie. Odczekanie po wydaniu (trzy dni dla poprawek, tydzień dla reszty) to tani filtr na wydania, które szybko dostają poprawkę. Do tego scalenie PR zmienia tylko gita, a nie klaster, bo synchronizacja dalej jest ręczna. Aktualizacja przechodzi więc przez dwie bramki: czytam changelog przy scalaniu i diff przy synchronizacji.
Jeszcze jedno ustawienie, którego nie ma w powyższym fragmencie: Renovate domyślnie czeka w nieskończoność na wydania bez daty publikacji, a niektóre rejestry jej nie podają. Ustawienie minimumReleaseAgeBehaviour: "timestamp-optional" sprawia, że okres odczekania działa tam, gdzie data istnieje, a reszta przestaje wisieć.
Czego się nauczyłem
- Tag obrazu = SHA commita, nigdy
latest. Wtedy z gita da się odczytać, co dokładnie działa, a cofnięcie wdrożenia jest zwykłym revertem. - Własne obrazy wyłącz z Renovate. Jeśli CI zapisuje tag w gicie, to drugi automat, który robi to samo, tylko generuje konflikty.
- Przy ręcznej synchronizacji pilnuj hooków. Synchronizacja części zasobów albo strategia
applypo cichu pomija migracje. Najbezpieczniej synchronizować całą aplikację z domyślną strategią i sprawdzić Job, zanim uznasz wdrożenie za skończone.