← WSZYSTKIE WPISY

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?

ElementStan zastany
Aplikacje~60 (osobne kontenery frontend + backend)
TechnologiePHP (backend), Node.js (frontend)
Runtime~50% Docker, ~50% Apache2
Bazy danychNa współdzielonych VM
WdrożeniaMix ręcznych i półautomatycznych
RollbackSSH + 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?

  1. Brak powtarzalności — deploy na DEV wyglądał inaczej niż na PROD
  2. Brak historii zmian — “kto to wrzucił?” było pytaniem bez odpowiedzi
  3. Ręczne rollbacki — cofanie zmian wymagało dostępu SSH i nerwów ze stali
  4. Konfiguracja rozproszona — vhosty Apache, pliki .env, zmienne w różnych miejscach
  5. 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ścieZaletyWady
NamespaceyTańsze, prostszeWspólne zasoby, blast radius
Osobne klastryPełna izolacja, niezależne upgradyWyż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 .git lądują w obrazie
  • apt-get update bez --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:

MetrykaPrzed (monolityczny)Po (osobne kontenery)
Rozmiar obrazu~1.2 GB (jeden)~150 MB backend + ~30 MB frontend
Niezależne skalowanieNiemożliweTak — frontend i backend osobno
Niezależne wdrożeniaNiemożliweTak — deploy backendu nie wymusza restartu frontendu
Czas buildu (cache)~6 min~30 sek (backend) + ~20 sek (frontend)
Narzędzia w prodziegit, npm, composerbrak

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:

KomponentKonfigurowalne
Deploymentreplicas, strategy, resources, probes, env, volumes
Servicetype, ports
Ingresshost, path, TLS, annotations
HPAmin/max replicas, targetCPU/Memory
Sealed Secretszyfrowane secrety per środowisko
ConfigMapkonfiguracja aplikacji
PDBPod 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:

  1. Deweloper tworzy Secret z wrażliwymi danymi
  2. kubeseal szyfruje go kluczem publicznym kontrolera
  3. Zaszyfrowany SealedSecret trafia do Git — bezpiecznie
  4. 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ŚrodowiskoAutomatycznie?
Push do devDEVTak
Push do masterRCTak
Nowy tagPRODTak

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?

ArgumentNasza ocena
”K8s jest od stateless”To mit — ale stateful w K8s wymaga solidnego operatora
Klient ma sprawdzone VMBackup, monitoring, failover — wszystko działa
Ryzyko migracjiWysokie, a zysk w tym kontekście — marginalny
Koszt wdrożenia operatoraCzas + 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

AspektPrzedPo
Czas wdrożenia15-45 min (ręcznie)~5 min (automatycznie)
RollbackSSH + backup (30+ min)Git revert (~2 min)
Historia zmianBrakPełna (Git log)
PowtarzalnośćNiska100% — ten sam proces na każdym środowisku
Onboarding nowej osobyTygodnieDni — cały stack w kodzie
Separacja środowiskCzęściowaPełna — osobne klastry
Nowa aplikacjaKonfiguracja serwera od zeraNowy 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:

  1. GitOps jako fundament — Git jest źródłem prawdy. Nie człowiek z dostępem SSH.
  2. Jeden chart, wiele aplikacji — standaryzacja redukuje złożoność o rząd wielkości.
  3. 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.