Implementacja zasad 12-factor app w kontekście Dockera

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

  1. Codebase (Baza kodu) - Jedna baza kodu w systemie kontroli wersji, wiele wdrożeń
  2. Dependencies (Zależności) - Jawne deklarowanie i izolowanie zależności
  3. Config (Konfiguracja) - Przechowywanie konfiguracji w środowisku
  4. Backing services (Usługi wspierające) - Traktowanie usług wspierających jako dołączanych zasobów
  5. Build, release, run (Budowanie, wydanie, uruchomienie) - Ścisłe oddzielenie etapów budowania i uruchamiania
  6. Processes (Procesy) - Uruchamianie aplikacji jako bezstanowe procesy
  7. Port binding (Wiązanie portów) - Eksponowanie usług przez wiązanie portów
  8. Concurrency (Współbieżność) - Skalowanie przez model procesów
  9. Disposability (Jednorazowość) - Maksymalizacja odporności przez szybki start i eleganckie zamknięcie
  10. Dev/prod parity (Równoważność dev/prod) - Utrzymywanie środowisk dev, staging i produkcyjnego jak najbardziej podobnych
  11. Logs (Logi) - Traktowanie logów jako strumieni zdarzeń
  12. 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:

  1. 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.

  2. Dependencies: Dockerfile jawnie deklaruje wszystkie zależności, a Docker umieszcza je w obrazie, zapewniając spójność środowiska.

  3. Config: Docker obsługuje zmienne środowiskowe, które są preferowanym sposobem konfiguracji w 12-factor app.

  4. Backing services: Docker ułatwia łączenie aplikacji z usługami wspierającymi (bazy danych, kolejki, itp.) poprzez sieci Docker i Docker Compose.

  5. Build, release, run: Docker jasno oddziela etapy budowania obrazu (docker build), oznaczania wersji (tagowanie) i uruchamiania (docker run).

  6. Processes: Kontenery Docker są z natury bezstanowe, co wspiera zasadę przechowywania stanu w usługach wspierających.

  7. Port binding: Docker umożliwia łatwe mapowanie portów kontenera na porty hosta.

  8. Concurrency: Docker pozwala na łatwe skalowanie poprzez uruchamianie wielu instancji tego samego kontenera.

  9. Disposability: Kontenery Docker są zaprojektowane do szybkiego uruchamiania i zatrzymywania, wspierając eleganckie zamknięcie.

  10. Dev/prod parity: Docker zapewnia identyczne środowisko niezależnie od tego, gdzie jest uruchamiany.

  11. Logs: Docker przechwytuje standardowe wyjście (stdout i stderr) kontenerów, ułatwiając agregację logów.

  12. 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_obrazu

Definiowanie zmiennych środowiskowych w Dockerfile:

ENV ZMIENNA=wartość_domyślna

Używanie pliku ze zmiennymi środowiskowymi:

docker run --env-file .env nazwa_obrazu

Plik .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:
      - .env

Wstrzykiwanie 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:

  1. Ten sam obraz można uruchomić w różnych środowiskach z różną konfiguracją
  2. Nie ma potrzeby rekompilacji aplikacji przy zmianie konfiguracji
  3. 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:latest

Konfiguracją 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

  1. Utwórz prostą aplikację React:

    npx create-react-app moja-aplikacja
    cd moja-aplikacja
  2. Utwórz plik public/config.js (będzie nadpisywany podczas uruchamiania kontenera):

    window.ENV = {
      API_URL: 'http://localhost:3001',
      ENVIRONMENT: 'development',
      APP_TITLE: 'Moja Aplikacja'
    };
  3. Dodaj odniesienie do tego pliku w public/index.html:

    <script src="%PUBLIC_URL%/config.js"></script>
  4. 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;
  5. 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

Dodatkowe zasoby