Od 60 aplikacji na Apache do Kubernetes - migracja infrastruktury we Wrocławiu
Jak przenieśliśmy 60 aplikacji z Apache2 i Dockera na Kubernetes RKE2 z GitOps (ArgoCD), Helm Chartem i Sealed Secrets. Case study wdrożenia.
Sześćdziesiąt aplikacji. Połowa na Dockerze, połowa na Apache2. Trzy środowiska. Zero powtarzalności wdrożeń. Tak wyglądał punkt startu jednego z naszych projektów we Wrocławiu — i dokładnie dlatego klient do nas trafił.
📋 TL;DR
- Stan zastany: ~60 aplikacji (osobne kontenery frontend + backend), PHP + Node.js, mix Apache2 i Docker, bazy na współdzielonych VM
- Rozwiązanie: RKE2 (klaster per środowisko) + MetalLB + NGINX Ingress Controller + ArgoCD (GitOps) + uniwersalny Helm Chart + Sealed Secrets
- CI/CD: GitLab pipeline → build → test → push → deploy (branch mapping: dev→DEV, master→RC, tag→PROD)
- Efekt: powtarzalny proces wdrożeń, rollback jednym commitem, pełna audytowalność
⚠️ Problem: infrastruktura, która “jakoś działa”
Klient prowadził własne centrum danych z wirtualizacją. Infrastruktura rosła organicznie — nowa aplikacja, nowy vhost na Apache, kopia konfiguracji z poprzedniego projektu. Efekt?
| Element | Stan zastany |
|---|---|
| Aplikacje | ~60 (osobne kontenery frontend + backend) |
| Technologie | PHP (backend), Node.js (frontend) |
| Runtime | ~50% Docker, ~50% Apache2 |
| Bazy danych | Na współdzielonych VM |
| Wdrożenia | Mix ręcznych i półautomatycznych |
| Rollback | SSH + przywracanie plików z backupu |
Problem nie leżał w tym, że aplikacje nie działały. Działały. Ale każde wdrożenie wyglądało inaczej. Rollback był operacją chirurgiczną. Onboarding nowej osoby w zespole oznaczał tygodnie wchodzenia w kontekst. A jedno źle wrzucone wdrożenie na produkcję potrafiło zepsuć piątkowe popołudnie.
Co bolało najbardziej?
- Brak powtarzalności — deploy na DEV wyglądał inaczej niż na PROD
- Brak historii zmian — “kto to wrzucił?” było pytaniem bez odpowiedzi
- Ręczne rollbacki — cofanie zmian wymagało dostępu SSH i nerwów ze stali
- Konfiguracja rozproszona — vhosty Apache, pliki
.env, zmienne w różnych miejscach - Brak separacji środowisk — DEV potrafił wpływać na RC
🎯 Oczekiwania klienta
Wymagania były konkretne i realistyczne:
- Klaster Kubernetes — stabilna, skalowalna platforma
- Wsparcie w konteneryzacji — przeniesienie aplikacji z Apache2 do kontenerów
- Optymalizacja Dockerfile — mniejsze obrazy, szybsze buildy, mniej warstw
- CI/CD w GitLab — automatyczny pipeline od kodu do produkcji
- Łatwe wdrażanie i wycofywanie zmian — deploy jednym kliknięciem, rollback bez stresu
Brzmi jak lista życzeń? Może. Ale każde z tych życzeń da się spełnić, jeśli architektura jest przemyślana od fundamentów.
🔧 Rozwiązanie: architektura krok po kroku
Krok 1: RKE2 — klaster per środowisko
Postawiliśmy na RKE2 — lekką, certyfikowaną dystrybucję Kubernetes od Rancher, z wbudowanym hardening profilem CIS.
Kluczowa decyzja architektoniczna: osobny klaster per środowisko.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ RKE2 DEV │ │ RKE2 RC │ │ RKE2 PROD │
│ │ │ │ │ │
│ 3 nodes │ │ 3 nodes │ │ 5 nodes │
│ dev apps │ │ rc apps │ │ prod apps │
└─────────────┘ └─────────────┘ └─────────────┘
Dlaczego osobne klastry, a nie namespacey?
| Podejście | Zalety | Wady |
|---|---|---|
| Namespacey | Tańsze, prostsze | Wspólne zasoby, blast radius |
| Osobne klastry | Pełna izolacja, niezależne upgrady | Wyższy koszt infra |
Wybraliśmy izolację. Awaria na DEV nie dotyka produkcji. Upgrade Kubernetes na RC nie ryzykuje PROD. Każde środowisko ma własny lifecycle.
💡 Dlaczego RKE2, a nie kubeadm czy k3s? Klient miał wymóg CIS hardening out-of-the-box. RKE2 dostarcza to domyślnie. Dodatkowo, stabilny kanał aktualizacji od Rancher dawał klientowi pewność, że nie zostanie z nieaktualizowanym klastrem.
Krok 2: MetalLB + NGINX Ingress Controller — warstwa sieciowa
Klient korzystał z własnego DC bez chmurowego load balancera. Potrzebowaliśmy sposobu na wystawienie usług na zewnątrz. Odpowiedzią był MetalLB w trybie L2 + NGINX Ingress Controller.
┌─────────────────────────────────────────────────┐
│ Klient DC │
│ │
│ ┌──────────┐ ┌───────────────────────────┐ │
│ │ MetalLB │───►│ NGINX Ingress Controller │ │
│ │ (VIP L2) │ │ (routing HTTP/HTTPS) │ │
│ └──────────┘ └─────────┬─────────────────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ app1 │ │ app2 │ │ app3 │ │
│ │frontend│ │backend │ │frontend│ │
│ └────────┘ └────────┘ └────────┘ │
│ │
└─────────────────────────────────────────────────┘
MetalLB przydziela adresy IP z puli zdefiniowanej w konfiguracji. W środowisku bare-metal to jedyny sposób na uzyskanie type: LoadBalancer bez chmury:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 10.0.10.100-10.0.10.120
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
NGINX Ingress Controller dostaje VIP od MetalLB i routuje ruch HTTP/HTTPS do odpowiednich serwisów na podstawie reguł Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app1-frontend
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
ingressClassName: nginx
tls:
- hosts:
- app1.client.pl
secretName: app1-tls
rules:
- host: app1.client.pl
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app1-frontend
port:
number: 80
- path: /api
pathType: Prefix
backend:
service:
name: app1-backend
port:
number: 9000
💡 Dlaczego MetalLB L2, a nie BGP? Sieć klienta nie obsługiwała BGP na switchy dostępowych. L2 działa plug-and-play — wystarczy pula wolnych IP w tym samym segmencie. Dla klastrów na kilkanaście nodów to w zupełności wystarczające.
Krok 3: konteneryzacja aplikacji
Kluczowa decyzja architektoniczna: frontend i backend to osobne kontenery. Każdy ma własny Dockerfile, własny obraz, własny deployment w Kubernetes. Dzięki temu mogą być skalowane, wdrażane i wycofywane niezależnie od siebie.
Połowa aplikacji była już w Dockerze, ale Dockerfile wyglądały… kreatywnie. Typowe problemy:
# ❌ Tak wyglądał typowy Dockerfile (backend PHP)
FROM php:8.1-apache
COPY . /var/www/html/
RUN apt-get update && apt-get install -y \
git curl zip unzip libpng-dev libonig-dev \
libxml2-dev libzip-dev nodejs npm
RUN composer install
RUN npm install && npm run build
EXPOSE 80
Co jest nie tak?
- Frontend i backend w jednym obrazie — brak niezależnego skalowania
- Jeden wielki obraz (~1.2 GB) z narzędziami buildowymi w prodzie
- Brak multi-stage build
- Brak
.dockerignore— node_modules i.gitlądują w obrazie apt-get updatebez--no-install-recommends
Po rozdzieleniu na osobne kontenery i optymalizacji:
# ✅ Backend (PHP-FPM) — osobny kontener
FROM composer:2 AS deps
WORKDIR /build
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist
COPY . .
RUN composer dump-autoload --optimize
FROM php:8.1-fpm-alpine
RUN apk add --no-cache libpng libxml2 libzip oniguruma
COPY --from=deps /build /var/www/html
USER www-data
EXPOSE 9000
# ✅ Frontend (Node.js) — osobny kontener
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Efekt rozdzielenia i optymalizacji:
| Metryka | Przed (monolityczny) | Po (osobne kontenery) |
|---|---|---|
| Rozmiar obrazu | ~1.2 GB (jeden) | ~150 MB backend + ~30 MB frontend |
| Niezależne skalowanie | Niemożliwe | Tak — frontend i backend osobno |
| Niezależne wdrożenia | Niemożliwe | Tak — deploy backendu nie wymusza restartu frontendu |
| Czas buildu (cache) | ~6 min | ~30 sek (backend) + ~20 sek (frontend) |
| Narzędzia w prodzie | git, npm, composer | brak |
Krok 4: GitOps z ArgoCD
Zamiast imperatywnych deployów (kubectl apply, skrypty bash) wdrożyliśmy podejście deklaratywne z ArgoCD.
Zasada GitOps jest prosta:
Repozytorium Git jest jedynym źródłem prawdy o stanie infrastruktury. Żadnych ręcznych zmian w klastrze.
ArgoCD ciągle porównuje desired state (Git) z actual state (klaster) i automatycznie synchronizuje różnice.
Struktura repozytorium ArgoCD
argocd-repo/
├── base/ # Wspólna konfiguracja Helm Chart
│ ├── Chart.yaml
│ ├── values.yaml # Domyślne wartości
│ └── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ └── sealed-secret.yaml
│
└── overlays/
├── dev/
│ ├── app1/
│ │ └── values.yaml # image.tag, replicas, resources
│ ├── app2/
│ │ └── values.yaml
│ └── ...
├── rc/
│ ├── app1/
│ │ └── values.yaml
│ ├── app2/
│ │ └── values.yaml
│ └── ...
└── prod/
├── app1/
│ └── values.yaml # Tagi produkcyjne, HPA, wyższe limity
├── app2/
│ └── values.yaml
└── ...
Każda aplikacja na każdym środowisku = osobny values.yaml z nadpisanymi wartościami. Wspólna baza, różnice w overlays.
Przykład overlay dla PROD
# overlays/prod/app1/values.yaml
image:
repository: registry.client.local/app1
tag: "a1b2c3d" # SHA commitu — podmieniane przez CI
replicaCount: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
hpa:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPU: 70
ingress:
host: app1.client.pl
tls: true
Krok 5: uniwersalny Helm Chart
Zamiast utrzymywać 60 osobnych chartów, stworzyliśmy jeden uniwersalny Helm Chart pokrywający potrzeby wszystkich aplikacji.
Co obsługuje chart:
| Komponent | Konfigurowalne |
|---|---|
| Deployment | replicas, strategy, resources, probes, env, volumes |
| Service | type, ports |
| Ingress | host, path, TLS, annotations |
| HPA | min/max replicas, targetCPU/Memory |
| Sealed Secret | szyfrowane secrety per środowisko |
| ConfigMap | konfiguracja aplikacji |
| PDB | Pod Disruption Budget |
Kluczowa decyzja: frontend i backend to osobne deploymenty, każdy korzysta z tego samego chartu. Typ runtime kontrolowany jest jednym przełącznikiem:
# Backend (PHP-FPM)
runtime: php
phpFpm:
enabled: true
# Frontend (Node.js + nginx)
runtime: node
nginx:
enabled: true
# Frontend (statyczny build serwowany przez nginx)
runtime: static
nginx:
enabled: true
Jeden chart, ~60 aplikacji (osobne kontenery frontend + backend), zero duplikacji konfiguracji.
Krok 6: Sealed Secrets — bezpieczne sekrety w Git
Zarządzanie sekretami w GitOps to klasyczny problem: jak trzymać hasła i klucze w repo, nie narażając się na wyciek?
Wybraliśmy Sealed Secrets od Bitnami:
┌──────────────┐ kubeseal ┌──────────────────┐
│ Secret.yaml │ ──────────────► │ SealedSecret.yaml │ ──► Git repo
│ (plaintext) │ encrypt │ (encrypted) │
└──────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ Sealed Secrets │
│ Controller (K8s) │
│ decrypt → Secret │
└──────────────────┘
Flow:
- Deweloper tworzy
Secretz wrażliwymi danymi kubesealszyfruje go kluczem publicznym kontrolera- Zaszyfrowany
SealedSecrettrafia do Git — bezpiecznie - Kontroler w klastrze odszyfrowuje i tworzy zwykły
Secret
Ważne: Sealed Secret jest zaszyfrowany per namespace i per klaster. Nawet jeśli ktoś skopiuje go do innego namespace, kontroler odmówi odszyfrowania.
Krok 7: pipeline CI/CD w GitLab
Każda aplikacja ma w GitLab CI pipeline realizujący pełny cykl:
stages:
- build
- test
- push
- deploy
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
test:
stage: test
script:
- docker run $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA ./run-tests.sh
push:
stage: push
script:
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
deploy:
stage: deploy
script:
- >
sed -i "s|tag:.*|tag: \"${CI_COMMIT_SHA}\"|"
argocd-repo/overlays/${TARGET_ENV}/${APP_NAME}/values.yaml
- cd argocd-repo
- git add . && git commit -m "deploy ${APP_NAME} ${CI_COMMIT_SHA}"
- git push
rules:
- if: $CI_COMMIT_BRANCH == "dev"
variables:
TARGET_ENV: dev
- if: $CI_COMMIT_BRANCH == "master"
variables:
TARGET_ENV: rc
- if: $CI_COMMIT_TAG
variables:
TARGET_ENV: prod
Mapowanie środowisk:
| Zdarzenie Git | Środowisko | Automatycznie? |
|---|---|---|
Push do dev | DEV | Tak |
Push do master | RC | Tak |
| Nowy tag | PROD | Tak |
Każdy obraz tagowany jest SHA commita — nie latest, nie v1.2.3, ale dokładny hash. Zawsze wiesz, jaki kod działa na danym środowisku:
# Jaki commit jest na PROD?
$ grep "tag:" overlays/prod/app1/values.yaml
tag: "a1b2c3d4e5f6"
$ git log --oneline a1b2c3d4e5f6
a1b2c3d fix: resolve payment gateway timeout
Krok 8: co z bazami danych?
Bazy danych na współdzielonych VM świadomie zostawiliśmy na miejscu.
Dlaczego nie migrujemy baz do Kubernetes?
| Argument | Nasza ocena |
|---|---|
| ”K8s jest od stateless” | To mit — ale stateful w K8s wymaga solidnego operatora |
| Klient ma sprawdzone VM | Backup, monitoring, failover — wszystko działa |
| Ryzyko migracji | Wysokie, a zysk w tym kontekście — marginalny |
| Koszt wdrożenia operatora | Czas + kompetencje, których nie trzeba budować teraz |
Aplikacje w Kubernetes łączą się z bazami przez wewnętrzną sieć. ExternalName Service lub bezpośredni endpoint — proste i niezawodne.
💡 Nie każdy element infrastruktury musi trafić do Kubernetes. Czasem najlepsza decyzja architektoniczna to ta, której się nie podejmuje.
📊 Jak wygląda typowe wdrożenie — od A do Z
Deweloper pushuje do brancha dev
│
▼
┌─────────────────┐
│ GitLab CI/CD │
│ build → test │
│ push → deploy │
└────────┬────────┘
│ Podmienia tag w values.yaml
▼
┌─────────────────┐
│ ArgoCD repo │
│ (Git commit) │
└────────┬────────┘
│ ArgoCD wykrywa drift
▼
┌─────────────────┐
│ ArgoCD sync │
│ (K8s apply) │
└────────┬────────┘
│ Rolling update
▼
┌─────────────────┐
│ Nowa wersja │
│ działa na DEV │
└─────────────────┘
Rollback? Cofnij commit w ArgoCD repo. ArgoCD wykryje zmianę i przywróci poprzednią wersję. Zero SSH, zero paniki, pełna historia w Git.
✅ Efekt końcowy
| Aspekt | Przed | Po |
|---|---|---|
| Czas wdrożenia | 15-45 min (ręcznie) | ~5 min (automatycznie) |
| Rollback | SSH + backup (30+ min) | Git revert (~2 min) |
| Historia zmian | Brak | Pełna (Git log) |
| Powtarzalność | Niska | 100% — ten sam proces na każdym środowisku |
| Onboarding nowej osoby | Tygodnie | Dni — cały stack w kodzie |
| Separacja środowisk | Częściowa | Pełna — osobne klastry |
| Nowa aplikacja | Konfiguracja serwera od zera | Nowy overlay + values.yaml |
🎯 Wnioski
Nie każda transformacja musi być rewolucją. Czasem to uporządkowanie tego, co już istnieje, w spójny, powtarzalny proces.
Trzy zasady, które sprawdziły się w tym projekcie:
- GitOps jako fundament — Git jest źródłem prawdy. Nie człowiek z dostępem SSH.
- Jeden chart, wiele aplikacji — standaryzacja redukuje złożoność o rząd wielkości.
- Pragmatyzm ponad puryzm — bazy zostały na VM, bo tak było sensownie. Nie migrujemy dla samej migracji.
Sześćdziesiąt aplikacji. Trzy środowiska. Jeden spójny proces. Tak powinno wyglądać wdrażanie oprogramowania.
Masz podobne wyzwanie z infrastrukturą? Porozmawiajmy o Twoim przypadku.