Aplikacja fullstack: Frontend + Backend w kontenerach
Wprowadzenie do aplikacji fullstack
Aplikacja fullstack (full-stack) składa się z kilku warstw: - Frontend - interfejs użytkownika, z którym bezpośrednio wchodzi w interakcję użytkownik (React, Angular, Vue.js) - Backend - warstwa logiki biznesowej i API (Node.js/Express, Python/Django, Java/Spring) - Baza danych - warstwa przechowująca dane (MongoDB, PostgreSQL, MySQL)
Docker pozwala na konteneryzację każdej z tych warstw, co umożliwia: - Jednolitą konfigurację środowiska - Łatwą komunikację między komponentami - Niezależne skalowanie poszczególnych części - Przenośność całej aplikacji między środowiskami
Tworzenie backendu w Node.js/Express
Struktura projektu
Typowa struktura projektu backendowego w Node.js/Express wygląda następująco:
backend/
├── src/
│ ├── index.js # Główny plik aplikacji
│ ├── routes/ # Definicje tras API
│ ├── controllers/ # Kontrolery obsługujące logikę biznesową
│ ├── models/ # Modele danych
│ └── middleware/ # Middleware (np. dla autoryzacji)
├── package.json # Zależności projektu
├── .env.example # Przykładowy plik zmiennych środowiskowych
├── .dockerignore # Pliki ignorowane przez Dockera
└── Dockerfile # Instrukcje budowania obrazu Docker
Podstawowy kod backendu
Oto przykład prostego API w Express.js:
// backend/src/index.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
// Konfiguracja CORS - pozwala na komunikację z frontendem
app.use(cors({
origin: process.env.CORS_ORIGIN || '*'
}));
// Parsowanie JSON w zapytaniach
app.use(express.json());
// Przykładowa trasa API
app.get('/api/data', (req, res) => {
res.json({
message: 'Hello from Docker container!',
environment: process.env.NODE_ENV || 'development',
timestamp: new Date().toISOString()
});
});
// Uruchomienie serwera
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});Dockerfile dla backendu
FROM node:18-alpine
WORKDIR /app
# Kopiowanie tylko plików zależności i instalacja
COPY package*.json ./
RUN npm ci
# Kopiowanie reszty kodu
COPY . .
# Port, który będzie nasłuchiwać kontener
EXPOSE 3000
# Komenda uruchamiająca aplikację
CMD ["node", "src/index.js"]Plik .dockerignore
node_modules
npm-debug.log
.git
.env
.env.local
.DS_Store
Komunikacja między kontenerami
Metody komunikacji
W aplikacji konteneryzowanej, frontend i backend muszą komunikować się ze sobą. Oto różne metody:
- Komunikacja przez sieć Docker:
- Kontenery mogą komunikować się przez wewnętrzną sieć Docker
- W Docker Compose można używać nazw usług jako nazw hostów
- Komunikacja przez opublikowane porty:
- Kontenery mogą publikować porty na hosta
- Inne kontenery lub aplikacje mogą łączyć się przez adres hosta i port
Konfiguracja frontendu do komunikacji z backendem
// frontend/src/App.js
import React, { useState, useEffect } from 'react';
import config from './config';
function App() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(`${config.API_URL}/api/data`);
const result = await response.json();
setData(result);
setLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setLoading(false);
}
};
fetchData();
}, []);
return (
<div className="App">
<header className="App-header">
<h1>{config.APP_TITLE}</h1>
<p>Środowisko: {config.ENVIRONMENT}</p>
{loading ? (
<p>Ładowanie danych...</p>
) : data ? (
<div>
<p>Wiadomość z backendu: {data.message}</p>
<p>Środowisko backendu: {data.environment}</p>
<p>Czas odpowiedzi: {data.timestamp}</p>
</div>
) : (
<p>Brak danych</p>
)}
</header>
</div>
);
}
export default App;Konfiguracja CORS w backendzie
CORS (Cross-Origin Resource Sharing) to mechanizm bezpieczeństwa, który kontroluje dostęp do zasobów poza domeną. Należy go odpowiednio skonfigurować, aby frontend mógł komunikować się z backendem:
// Instalacja pakietu cors
// npm install cors
// Konfiguracja w backend/src/index.js
const cors = require('cors');
// Dla środowiska deweloperskiego
app.use(cors({ origin: 'http://localhost:3000' }));
// LUB dla wielu domen
app.use(cors({
origin: ['http://localhost:3000', 'https://myapp.example.com']
}));
// LUB konfiguracja przez zmienne środowiskowe
app.use(cors({
origin: process.env.CORS_ORIGIN || '*'
}));Sieci w Dockerze
Rodzaje sieci
Docker oferuje kilka typów sieci:
- bridge (domyślna) - izolowana sieć na hoście
- host - używa sieci hosta bezpośrednio (bez izolacji sieciowej)
- none - brak sieci
- overlay - komunikacja między wieloma hostami Docker (dla trybu swarm)
- macvlan - przypisanie adresu MAC do kontenera
Tworzenie sieci i uruchamianie kontenerów
# Tworzenie sieci
docker network create app-network
# Uruchamianie backendu w sieci
docker run -d --name backend --network app-network -p 3001:3000 my-backend-image
# Uruchamianie frontendu w tej samej sieci
docker run -d --name frontend --network app-network -p 3000:80 -e API_URL=http://backend:3000 my-frontend-imageAdresowanie kontenerów w sieci
W sieci Docker, kontenery mogą odwoływać się do siebie nawzajem po nazwach:
# Z kontenera frontend
curl http://backend:3000/api/data
# Z kontenera backend (jeśli mamy bazę danych)
curl http://db:5432Praktyczny przykład: Pełna aplikacja full-stack
Spójrzmy na przykład aplikacji full-stack z frontenden, backendem i bazą danych:
1. Backend (Express.js)
// backend/src/index.js
const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://db:27017/myapp';
// Połączenie z bazą danych
mongoose.connect(MONGO_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
// Model danych
const Task = mongoose.model('Task', {
text: String,
completed: Boolean,
createdAt: { type: Date, default: Date.now }
});
// Middleware
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json());
// Trasy API
app.get('/api/tasks', async (req, res) => {
try {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
} catch (error) {
res.status(500).json({ error: 'Nie udało się pobrać zadań' });
}
});
app.post('/api/tasks', async (req, res) => {
try {
const { text } = req.body;
const task = new Task({ text, completed: false });
await task.save();
res.status(201).json(task);
} catch (error) {
res.status(500).json({ error: 'Nie udało się dodać zadania' });
}
});
app.put('/api/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
const { completed } = req.body;
const task = await Task.findByIdAndUpdate(
id,
{ completed },
{ new: true }
);
res.json(task);
} catch (error) {
res.status(500).json({ error: 'Nie udało się zaktualizować zadania' });
}
});
app.delete('/api/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
await Task.findByIdAndDelete(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Nie udało się usunąć zadania' });
}
});
// Uruchomienie serwera
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});2. Dockerfile dla backendu
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]3. Frontend (React z prostą listą zadań)
// frontend/src/App.js
import React, { useState, useEffect } from 'react';
import config from './config';
import './App.css';
function App() {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const apiUrl = config.API_URL;
// Pobieranie zadań
const fetchTasks = async () => {
try {
const response = await fetch(`${apiUrl}/api/tasks`);
if (!response.ok) throw new Error('Nie udało się pobrać zadań');
const data = await response.json();
setTasks(data);
setLoading(false);
} catch (error) {
setError(error.message);
setLoading(false);
}
};
// Pobierz zadania przy montowaniu komponentu
useEffect(() => {
fetchTasks();
}, []);
// Dodawanie nowego zadania
const addTask = async (e) => {
e.preventDefault();
if (!newTask.trim()) return;
try {
const response = await fetch(`${apiUrl}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newTask })
});
if (!response.ok) throw new Error('Nie udało się dodać zadania');
const task = await response.json();
setTasks([task, ...tasks]);
setNewTask('');
} catch (error) {
setError(error.message);
}
};
// Przełączanie statusu zadania
const toggleTask = async (id, completed) => {
try {
const response = await fetch(`${apiUrl}/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !completed })
});
if (!response.ok) throw new Error('Nie udało się zaktualizować zadania');
const updatedTask = await response.json();
setTasks(tasks.map(task =>
task._id === id ? updatedTask : task
));
} catch (error) {
setError(error.message);
}
};
// Usuwanie zadania
const deleteTask = async (id) => {
try {
const response = await fetch(`${apiUrl}/api/tasks/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Nie udało się usunąć zadania');
setTasks(tasks.filter(task => task._id !== id));
} catch (error) {
setError(error.message);
}
};
if (loading) return <div className="App">Ładowanie zadań...</div>;
if (error) return <div className="App">Błąd: {error}</div>;
return (
<div className="App">
<h1>{config.APP_TITLE || 'Lista zadań'}</h1>
<p>Środowisko: {config.ENVIRONMENT}</p>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Dodaj nowe zadanie..."
/>
<button type="submit">Dodaj</button>
</form>
<ul className="task-list">
{tasks.length === 0 ? (
<li className="empty">Brak zadań</li>
) : (
tasks.map(task => (
<li key={task._id} className={task.completed ? 'completed' : ''}>
<span onClick={() => toggleTask(task._id, task.completed)}>
{task.text}
</span>
<button onClick={() => deleteTask(task._id)}>Usuń</button>
</li>
))
)}
</ul>
</div>
);
}
export default App;4. Uruchamianie z Docker Compose
Docker Compose pozwala na uruchomienie wszystkich komponentów aplikacji za pomocą jednej komendy:
# docker-compose.yml
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "3000:80"
environment:
- API_URL=http://backend:3000
- ENVIRONMENT=development
- APP_TITLE=Lista Zadań
depends_on:
- backend
backend:
build: ./backend
ports:
- "3001:3000"
environment:
- PORT=3000
- MONGO_URI=mongodb://db:27017/myapp
- CORS_ORIGIN=http://localhost:3000
- NODE_ENV=development
depends_on:
- db
db:
image: mongo:5
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:Uruchamianie:
docker-compose up -dDebugowanie aplikacji full-stack
Sprawdzanie logów
# Logi wszystkich kontenerów
docker-compose logs
# Logi konkretnego kontenera
docker-compose logs backend
# Logi w czasie rzeczywistym
docker-compose logs -fWykonywanie poleceń w kontenerze
# Uruchomienie powłoki w kontenerze
docker exec -it backend sh
# Sprawdzenie dostępności innego kontenera
docker exec -it frontend curl http://backend:3000/api/dataInspekcja sieci
# Lista sieci
docker network ls
# Szczegóły sieci
docker network inspect app-networkProblemy i rozwiązania
Problem 1: Nie można połączyć się z backendem
Symptomy: - Frontend pokazuje błędy połączenia - Zapytania AJAX/fetch nie wracają
Rozwiązania: 1. Sprawdź czy backend
działa: docker ps i
docker logs backend 2. Sprawdź czy backend
nasłuchuje na poprawnym porcie:
docker exec backend netstat -tulpn 3. Sprawdź
adres API w konfiguracji frontendu 4. Sprawdź czy kontenery
są w tej samej sieci:
docker network inspect
Problem 2: Błędy CORS
Symptomy: - W konsoli przeglądarki błędy typu “Access-Control-Allow-Origin”
Rozwiązania: 1. Dodaj odpowiedni middleware CORS w backendzie 2. Sprawdź czy CORS_ORIGIN ma poprawną wartość 3. Upewnij się, że protokół (http/https) i port są uwzględnione w konfiguracji CORS
Problem 3: Problemy z bazą danych
Symptomy: - Backend nie może połączyć się z bazą danych - Błędy “Connection refused” lub timeouty
Rozwiązania: 1. Sprawdź czy kontener bazy danych działa 2. Upewnij się, że adres URL bazy danych jest poprawny 3. Sprawdź czy używasz nazwy hosta zgodnej z nazwą usługi w docker-compose 4. Sprawdź czy kontenery są w tej samej sieci
Ćwiczenia praktyczne
Ćwiczenie 1: Tworzenie backendu i Dockerfile
- Stwórz prosty serwer Express.js z kilkoma trasami API
- Utwórz Dockerfile dla aplikacji backend
- Zbuduj obraz i uruchom kontener
- Przetestuj API za pomocą narzędzia curl lub Postman
Ćwiczenie 2: Łączenie backendu z frontendem
- Zaktualizuj frontend, aby komunikował się z API z ćwiczenia 1
- Utwórz własną sieć Docker
- Uruchom oba kontenery w tej samej sieci
- Zweryfikuj, czy frontend może pobierać dane z backendu
Ćwiczenie 3: Dodanie bazy danych
- Dodaj MongoDB do aplikacji
- Zaktualizuj backend, aby wykorzystywał bazę danych
- Utwórz docker-compose.yml dla całego stosu
- Uruchom całą aplikację za pomocą docker-compose up