Implementacja zasad 12-factor app w kontekście Dockera
Czym jest metodologia 12-factor app?
Metodologia 12-factor app to zbiór najlepszych praktyk tworzenia aplikacji SaaS (Software as a Service), opracowany przez firmę Heroku. Zasady te zostały zaprojektowane, aby pomóc deweloperom tworzyć aplikacje, które: - są łatwe do skalowania - są przenośne między środowiskami - są odpowiednie do wdrażania w nowoczesnych chmurach - minimalizują rozbieżności między środowiskami deweloperskimi i produkcyjnymi - umożliwiają ciągłe wdrażanie (continuous deployment)
12 czynników
- Codebase (Baza kodu) - Jedna baza kodu w systemie kontroli wersji, wiele wdrożeń
- Dependencies (Zależności) - Jawne deklarowanie i izolowanie zależności
- Config (Konfiguracja) - Przechowywanie konfiguracji w środowisku
- Backing services (Usługi wspierające) - Traktowanie usług wspierających jako dołączanych zasobów
- Build, release, run (Budowanie, wydanie, uruchomienie) - Ścisłe oddzielenie etapów budowania i uruchamiania
- Processes (Procesy) - Uruchamianie aplikacji jako bezstanowe procesy
- Port binding (Wiązanie portów) - Eksponowanie usług przez wiązanie portów
- Concurrency (Współbieżność) - Skalowanie przez model procesów
- Disposability (Jednorazowość) - Maksymalizacja odporności przez szybki start i eleganckie zamknięcie
- Dev/prod parity (Równoważność dev/prod) - Utrzymywanie środowisk dev, staging i produkcyjnego jak najbardziej podobnych
- Logs (Logi) - Traktowanie logów jako strumieni zdarzeń
- Admin processes (Procesy administracyjne) - Uruchamianie zadań administracyjnych jako jednorazowe procesy
Więcej informacji można znaleźć na oficjalnej stronie 12-factor app.
Jak Docker wspiera zasady 12-factor app?
Docker doskonale wpisuje się w metodologię 12-factor app, ułatwiając implementację wielu z tych zasad:
Codebase: Docker pozwala na budowanie identycznych obrazów z tego samego kodu źródłowego, które następnie mogą być wdrażane w różnych środowiskach.
Dependencies: Dockerfile jawnie deklaruje wszystkie zależności, a Docker umieszcza je w obrazie, zapewniając spójność środowiska.
Config: Docker obsługuje zmienne środowiskowe, które są preferowanym sposobem konfiguracji w 12-factor app.
Backing services: Docker ułatwia łączenie aplikacji z usługami wspierającymi (bazy danych, kolejki, itp.) poprzez sieci Docker i Docker Compose.
Build, release, run: Docker jasno oddziela etapy budowania obrazu (
docker build), oznaczania wersji (tagowanie) i uruchamiania (docker run).Processes: Kontenery Docker są z natury bezstanowe, co wspiera zasadę przechowywania stanu w usługach wspierających.
Port binding: Docker umożliwia łatwe mapowanie portów kontenera na porty hosta.
Concurrency: Docker pozwala na łatwe skalowanie poprzez uruchamianie wielu instancji tego samego kontenera.
Disposability: Kontenery Docker są zaprojektowane do szybkiego uruchamiania i zatrzymywania, wspierając eleganckie zamknięcie.
Dev/prod parity: Docker zapewnia identyczne środowisko niezależnie od tego, gdzie jest uruchamiany.
Logs: Docker przechwytuje standardowe wyjście (stdout i stderr) kontenerów, ułatwiając agregację logów.
Admin processes: Docker umożliwia uruchamianie jednorazowych zadań administracyjnych w identycznym środowisku jak aplikacja.
Konfiguracja przez zmienne środowiskowe
Jednym z kluczowych aspektów metodologii 12-factor app jest przechowywanie konfiguracji w zmiennych środowiskowych. Docker doskonale wspiera ten mechanizm.
Przekazywanie zmiennych środowiskowych do kontenera:
docker run -e ZMIENNA=wartość nazwa_obrazuDefiniowanie zmiennych środowiskowych w Dockerfile:
ENV ZMIENNA=wartość_domyślnaUżywanie pliku ze zmiennymi środowiskowymi:
docker run --env-file .env nazwa_obrazuPlik .env:
ZMIENNA1=wartość1
ZMIENNA2=wartość2
Zmienne środowiskowe w Docker Compose:
services:
app:
image: nazwa_obrazu
environment:
- ZMIENNA1=wartość1
- ZMIENNA2=wartość2
# lub
env_file:
- .envWstrzykiwanie konfiguracji do aplikacji frontendowych
Aplikacje frontendowe (np. React, Angular, Vue) są zwykle budowane do statycznych plików, które są następnie serwowane przez serwer WWW (np. nginx). W takim przypadku zmienne środowiskowe są dostępne tylko podczas budowania obrazu, a nie podczas jego uruchamiania. Istnieją dwa główne podejścia do rozwiązania tego problemu:
1. Zmienne środowiskowe podczas budowania (Create React App)
W aplikacji React utworzonej za pomocą Create React App,
możesz użyć zmiennych środowiskowych zaczynających się od
REACT_APP_:
// Dostęp do zmiennej środowiskowej w kodzie React
const apiUrl = process.env.REACT_APP_API_URL;Jednak to podejście wymaga ponownego zbudowania aplikacji przy każdej zmianie konfiguracji, co jest niezgodne z zasadą “jeden obraz, wiele wdrożeń”.
2. Wstrzykiwanie konfiguracji w czasie wykonania
Lepszym podejściem jest utworzenie mechanizmu, który wstrzykuje konfigurację do aplikacji w czasie uruchamiania kontenera. Można to zrobić na kilka sposobów:
Przykład skryptu wstrzykiwania konfiguracji:
#!/bin/sh
# env.sh
# Plik konfiguracyjny, który zostanie utworzony/podmieniony
config_file="/usr/share/nginx/html/config.js"
# Zapisz konfigurację jako obiekt globalny dostępny w przeglądarce
echo "window.ENV = {" > $config_file
echo " API_URL: '${API_URL:-http://localhost:3001}'," >> $config_file
echo " ENVIRONMENT: '${ENVIRONMENT:-production}'," >> $config_file
echo " APP_TITLE: '${APP_TITLE:-Moja Aplikacja}'" >> $config_file
echo "};" >> $config_file
# Wykonaj następne polecenie (np. uruchomienie serwera nginx)
exec "$@"Dodanie tego skryptu do obrazu Docker:
# Etap budowania
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etap produkcyjny
FROM nginx:alpine
# Kopiowanie zbudowanej aplikacji
COPY --from=build /app/build /usr/share/nginx/html
# Kopiowanie skryptu wstrzykiwania konfiguracji
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
# Domyślne wartości zmiennych środowiskowych
ENV API_URL=https://api.example.com \
ENVIRONMENT=production \
APP_TITLE="Moja Aplikacja"
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Korzystanie z konfiguracji w aplikacji React:
// src/api.js
const config = window.ENV || {
API_URL: 'http://localhost:3001',
ENVIRONMENT: 'development',
APP_TITLE: 'Moja Aplikacja'
};
export default config;Zalety wstrzykiwania konfiguracji w czasie wykonania:
- Ten sam obraz można uruchomić w różnych środowiskach z różną konfiguracją
- Nie ma potrzeby rekompilacji aplikacji przy zmianie konfiguracji
- Można używać różnych konfiguracji dla wielu instancji tej samej aplikacji
Jeden obraz, wiele konfiguracji
Dzięki podejściu z wstrzykiwaniem konfiguracji w czasie wykonania, możemy przestrzegać zasady “jeden obraz, wiele wdrożeń”. Oto jak można uruchomić ten sam obraz z różnymi konfiguracjami:
# Środowisko produkcyjne
docker run -p 80:80 \
-e API_URL=https://api.example.com \
-e ENVIRONMENT=production \
-e APP_TITLE="Produkcyjna Aplikacja" \
moja-aplikacja:latest
# Środowisko testowe (staging)
docker run -p 8080:80 \
-e API_URL=https://api-staging.example.com \
-e ENVIRONMENT=staging \
-e APP_TITLE="Testowa Aplikacja" \
moja-aplikacja:latest
# Środowisko deweloperskie
docker run -p 8081:80 \
-e API_URL=http://localhost:3001 \
-e ENVIRONMENT=development \
-e APP_TITLE="Deweloperska Aplikacja" \
moja-aplikacja:latestKonfiguracją za pomocą Docker Compose
Docker Compose pozwala na definicję wielu usług i ich konfiguracji w jednym pliku, co jeszcze bardziej ułatwia zarządzanie zmiennymi środowiskowymi:
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "80:80"
environment:
- API_URL=http://backend:3000
- ENVIRONMENT=development
- APP_TITLE=Moja Aplikacja
backend:
build: ./backend
ports:
- "3000:3000"
environment:
- PORT=3000
- DB_HOST=db
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=password
- DB_NAME=myapp
db:
image: postgres:14-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=password
- POSTGRES_DB=myapp
volumes:
postgres_data:Ćwiczenia praktyczne
Ćwiczenie 1: Tworzenie konfigurowalnej aplikacji React
Utwórz prostą aplikację React:
npx create-react-app moja-aplikacja cd moja-aplikacjaUtwórz plik
public/config.js(będzie nadpisywany podczas uruchamiania kontenera):window.ENV = { API_URL: 'http://localhost:3001', ENVIRONMENT: 'development', APP_TITLE: 'Moja Aplikacja' };Dodaj odniesienie do tego pliku w
public/index.html:<script src="%PUBLIC_URL%/config.js"></script>Utwórz moduł konfiguracji w
src/config.js:const config = window.ENV || { API_URL: 'http://localhost:3001', ENVIRONMENT: 'development', APP_TITLE: 'Moja Aplikacja' }; export default config;Użyj konfiguracji w aplikacji (np. w
src/App.js):import React from 'react'; import config from './config'; function App() { return ( <div className="App"> <header className="App-header"> <h1>{config.APP_TITLE}</h1> <p>Środowisko: {config.ENVIRONMENT}</p> <p>API URL: {config.API_URL}</p> </header> </div> ); } export default App;
Ćwiczenie 2: Tworzenie skryptu wstrzykiwania konfiguracji
Utwórz plik env.sh w głównym katalogu
projektu:
#!/bin/sh
# Plik konfiguracyjny
config_file="/usr/share/nginx/html/config.js"
# Nadpisz plik konfiguracyjny
echo "window.ENV = {" > $config_file
echo " API_URL: '${API_URL:-http://localhost:3001}'," >> $config_file
echo " ENVIRONMENT: '${ENVIRONMENT:-production}'," >> $config_file
echo " APP_TITLE: '${APP_TITLE:-Moja Aplikacja}'" >> $config_file
echo "};" >> $config_file
# Wykonaj następne polecenie
exec "$@"Ćwiczenie 3: Tworzenie Dockerfile z obsługą konfiguracji
Utwórz plik Dockerfile w głównym katalogu
projektu:
# Etap budowania
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etap produkcyjny
FROM nginx:alpine
# Kopiowanie zbudowanej aplikacji
COPY --from=build /app/build /usr/share/nginx/html
# Kopiowanie skryptu wstrzykiwania konfiguracji
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
# Domyślne wartości zmiennych środowiskowych
ENV API_URL=https://api.example.com \
ENVIRONMENT=production \
APP_TITLE="Moja Aplikacja"
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Ćwiczenie 4: Uruchamianie tego samego obrazu z różnymi konfiguracjami
# Zbuduj obraz
docker build -t moja-aplikacja:latest .
# Uruchom z różnymi konfiguracjami
docker run -p 8080:80 -e ENVIRONMENT=production -e APP_TITLE="Produkcja" moja-aplikacja:latest
docker run -p 8081:80 -e ENVIRONMENT=staging -e APP_TITLE="Staging" moja-aplikacja:latest
docker run -p 8082:80 -e ENVIRONMENT=development -e APP_TITLE="Development" moja-aplikacja:latestĆwiczenie 5: Konfiguracja za pomocą Docker Compose
Utwórz plik docker-compose.yml:
version: '3.8'
services:
frontend:
build: .
ports:
- "8080:80"
environment:
- API_URL=http://localhost:3001
- ENVIRONMENT=development
- APP_TITLE=Moja Aplikacja (Dev)
frontend-prod:
image: moja-aplikacja:latest
ports:
- "80:80"
environment:
- API_URL=https://api.example.com
- ENVIRONMENT=production
- APP_TITLE=Moja Aplikacja (Prod)Uruchom usługi:
docker-compose up -d