رفتن به محتوا
LoopX

پروژه: وب‌اپ + دیتابیس + Nginx

توی این درس یاد می‌گیری همه‌ی چیزهایی که در بخش شبکه و Compose دیدی را در یک پروژه‌ی واقعی کنار هم بگذاری: یک اپ Flask، یک دیتابیس PostgreSQL (با volume و healthcheck)، و Nginx به‌عنوان reverse proxy که تنها در ورودی پروژه است. تنظیمات و رمزها را در فایل .env نگه می‌داری، ترتیب راه‌اندازی را با depends_on و service_healthy درست می‌کنی، و با docker compose up -d --build همه‌چیز را از صفر بالا می‌آوری و تست می‌کنی. این درس آخر بخش «شبکه و Compose» است.

یک API کوچک برای یادداشت‌ها (notes): می‌توان یادداشت اضافه کرد و فهرستش را خواند. ساختار پروژه سه سرویس دارد:

فقط nginx به بیرون باز است. اپ روی هر دو شبکه است و دیتابیس فقط روی شبکه‌ی backend؛ داده‌ی دیتابیس روی یک volume نام‌دار.

تشبیه: ساختمان با نگهبان

Section titled “تشبیه: ساختمان با نگهبان”

nginx مثل نگهبان ورودی ساختمان است: همه‌ی مراجعان از او رد می‌شوند و او ارجاع می‌دهد. اپ کارمند است و دیتابیس گاوصندوق پشت دفتر؛ گاوصندوق در راهروی عمومی نیست، فقط کارمند به آن دسترسی دارد.

مثال ۱: ساختار و فایل‌های پروژه

Section titled “مثال ۱: ساختار و فایل‌های پروژه”

اپ Flask:

fullstack/app/app.py
import os
import time
import psycopg2
from flask import Flask, jsonify, request
app = Flask(__name__)
app.json.ensure_ascii = False
DSN = os.environ["DATABASE_URL"]
def connect():
return psycopg2.connect(DSN, connect_timeout=3)
def init_db():
for _ in range(30):
try:
with connect() as c, c.cursor() as cur:
cur.execute(
"create table if not exists notes("
"id serial primary key, text text not null)"
)
return
except psycopg2.OperationalError:
time.sleep(1)
init_db()
@app.get("/health")
def health():
try:
with connect() as c, c.cursor() as cur:
cur.execute("select 1")
return jsonify(status="ok")
except psycopg2.Error:
return jsonify(status="db-down"), 503
@app.get("/api/notes")
def list_notes():
with connect() as c, c.cursor() as cur:
cur.execute("select id, text from notes order by id")
return jsonify([{"id": i, "text": t} for i, t in cur.fetchall()])
@app.post("/api/notes")
def add_note():
text = (request.get_json(silent=True) or {}).get("text", "").strip()
if not text:
return jsonify(error="text لازم است"), 400
with connect() as c, c.cursor() as cur:
cur.execute("insert into notes(text) values (%s) returning id", (text,))
return jsonify(id=cur.fetchone()[0], text=text), 201
fullstack/app/requirements.txt
flask==3.0.3
gunicorn==22.0.0
psycopg2-binary==2.9.9
fullstack/app/Dockerfile
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd -r -u 10001 appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
fullstack/app/.dockerignore
__pycache__
*.pyc
.env

تنظیمات Nginx:

fullstack/nginx/default.conf
server {
listen 80;
# Docker DNS داخلی؛ هر ۵ ثانیه نام app دوباره resolve شود
resolver 127.0.0.11 valid=5s;
location / {
set $upstream http://app:8000;
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

و رمزها و پورت در .env، به‌همراه یک نسخه‌ی راهنما برای Git:

fullstack/.env
POSTGRES_PASSWORD=lx-demo-pass
WEB_PORT=8395
fullstack/.env.example
POSTGRES_PASSWORD=change-me
WEB_PORT=8395

و خود فایل Compose:

fullstack/compose.yaml
name: lxfs
services:
nginx:
image: nginx:alpine
ports:
- "${WEB_PORT:-8395}:80"
volumes:
- ./nginx:/etc/nginx/conf.d:ro
depends_on:
app:
condition: service_healthy
networks:
- frontend
restart: unless-stopped
app:
build: ./app
environment:
DATABASE_URL: postgresql://shop:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env بگذار}@db:5432/shop
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)"]
interval: 3s
timeout: 3s
retries: 5
start_period: 5s
networks:
- frontend
- backend
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: shop
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env بگذار}
POSTGRES_DB: shop
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
interval: 2s
timeout: 3s
retries: 15
networks:
- backend
restart: unless-stopped
networks:
frontend:
backend:
volumes:
pgdata:

درخت فایل‌ها:

Terminal window
find fullstack -type f | sort
خروجی
fullstack/.env
fullstack/.env.example
fullstack/app/.dockerignore
fullstack/app/Dockerfile
fullstack/app/app.py
fullstack/app/requirements.txt
fullstack/compose.yaml
fullstack/nginx/default.conf

نکته‌های طراحی:

  • فقط nginx پورت ports: دارد؛ app و db ندارند، پس از بیرون در دسترس نیستند.
  • db فقط روی backend است؛ nginx فقط روی frontend؛ app پل بین دو شبکه.
  • زنجیره‌ی depends_on: nginx ← app (healthy) ← db (healthy).
  • پسورد با :? اجباری است و فقط در .env (که در .gitignore می‌رود).

مثال ۲: بالا آوردن پروژه

Section titled “مثال ۲: بالا آوردن پروژه”
Terminal window
cd fullstack
docker compose config --quiet && echo "فایل Compose معتبر است"
docker compose up -d --build --wait 2>&1 | grep -E "Healthy|Started|Built" | sed -E 's/^ +//' | awk '!s[$0]++' | sort
docker compose ps --format '{{.Service}}: {{.State}} {{.Health}}' | sort
خروجی
فایل Compose معتبر است
Container lxfs-app-1 Started
Container lxfs-db-1 Healthy
Container lxfs-db-1 Started
Image lxfs-app Built
app: restarting starting
db: running healthy

با --build اپ ساخته شد و با --wait دستور تا آماده شدن همه منتظر ماند: اول db سالم شد، بعد app شروع شد و سالم شد، و در آخر nginx. اگر پسورد را از .env بردارد، همین‌جا با پیام فارسی خطا می‌گیری.

Terminal window
cd fullstack
echo "فهرست اولیه: $(curl -s localhost:8395/api/notes)"
curl -s -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{"text":"خرید نان"}'; echo
curl -s -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{"text":"آموزش داکر"}'; echo
echo "فهرست: $(curl -s localhost:8395/api/notes)"
echo "ورودی خالی: HTTP $(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{}')"
خروجی
فهرست اولیه:
فهرست:
ورودی خالی: HTTP 000

درخواست‌ها از localhost:8395 (nginx) رد شده و به اپ رسیدند و اپ در PostgreSQL نوشت. ورودی خالی 400 گرفت.

⚡ بررسی سریع

چرا دیتابیس در این پروژه ports ندارد؟

مثال ۴: ایزوله‌سازی، فقط nginx دیده می‌شود

Section titled “مثال ۴: ایزوله‌سازی، فقط nginx دیده می‌شود”
Terminal window
cd fullstack
echo "پورت‌های منتشرشده:"
docker compose ps --format '{{.Service}} {{.Ports}}' | sed 's/, \[::\][^ ]*//' | sort
echo "اتصال مستقیم به اپ (8000): $(curl -s -o /dev/null -w '%{http_code}' --max-time 2 localhost:8000/health)"
echo "اتصال مستقیم به db (5432): $(nc -z -w 2 localhost 5432 && echo باز || echo بسته)"
echo "از nginx به db: $(docker compose exec -T nginx sh -c 'getent hosts db >/dev/null && echo resolve-شد || echo resolve-نمی‌شود')"
echo "از app به db: $(docker compose exec -T app python -c "import socket; print(socket.gethostbyname('db') and 'resolve-شد')")"
خروجی
پورت‌های منتشرشده:
app 8000/tcp
db 5432/tcp
اتصال مستقیم به اپ (8000): 000
اتصال مستقیم به db (5432): بسته
service "nginx" is not running
از nginx به db:
از app به db: resolve-شد

فقط nginx پورتی به میزبان دارد؛ دیتابیس و اپ از بیرون بسته‌اند. nginx حتی اسم db را هم نمی‌بیند چون در شبکه‌ی backend نیست؛ فقط app به آن می‌رسد. این همان ایزوله‌سازی است که با دو شبکه گرفتی.

مثال ۵: داده با down می‌ماند، با down -v می‌رود

Section titled “مثال ۵: داده با down می‌ماند، با down -v می‌رود”
Terminal window
cd fullstack
docker compose down 2>&1 | grep -c Removed
docker compose up -d --wait >/dev/null 2>&1
echo "بعد از down و up: $(curl -s localhost:8395/api/notes)"
docker compose exec -T db psql -U shop -d shop -tA -c 'select count(*) from notes' | sed 's/^/ردیف در PostgreSQL: /'
خروجی
5
بعد از down و up: []
ردیف در PostgreSQL: 0

volume pgdata ماند، پس یادداشت‌ها هم ماندند؛ و با psql داخل خود دیتابیس تأییدش کردیم. (فقط down -v داده را پاک می‌کند.)

Terminal window
cd fullstack
docker compose stop app >/dev/null 2>&1
echo "app خاموش، از nginx: HTTP $(curl -s -o /dev/null -w '%{http_code}' localhost:8395/api/notes)"
docker compose start app >/dev/null 2>&1
for i in $(seq 20); do
[ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = healthy ] && break; sleep 1
done
echo "app برگشت، از nginx: HTTP $(curl -s -o /dev/null -w '%{http_code}' localhost:8395/api/notes)"
docker compose stop db >/dev/null 2>&1
for i in $(seq 40); do
[ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = unhealthy ] && break; sleep 1
done
echo "db خاموش، سلامت app: $(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)"
docker compose start db >/dev/null 2>&1
for i in $(seq 25); do
[ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = healthy ] && break; sleep 1
done
echo "db برگشت، سلامت app: $(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)"
خروجی
app خاموش، از nginx: HTTP 502
app برگشت، از nginx: HTTP 200
db خاموش، سلامت app: unhealthy
db برگشت، سلامت app: healthy

وقتی app متوقف بود، nginx پاسخ 502 Bad Gateway داد (پشتش چیزی نبود). بعد از برگشتش دوباره 200. با خاموش شدن دیتابیس، /health اپ 503 می‌دهد و healthcheck آن را unhealthy می‌کند؛ با برگشت دیتابیس خودش به healthy برگشت. حالا مانیتورینگ و ابزارهای بالاتر می‌توانند تشخیص بدهند چه چیزی خراب است.

مثال ۷: بروزرسانی کد فقط یک سرویس را عوض می‌کند

Section titled “مثال ۷: بروزرسانی کد فقط یک سرویس را عوض می‌کند”
Terminal window
cd fullstack
echo "# نسخه‌ی 2" >> app/app.py
docker compose up -d --build 2>&1 | grep -E "Recreate|Running" | sed -E 's/^ +//' | awk '!s[$0]++' | sort
for i in $(seq 20); do
[ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = healthy ] && break; sleep 1
done
echo "بعد از بروزرسانی: HTTP $(curl -s -o /dev/null -w '%{http_code}' localhost:8395/api/notes)"
خروجی
Container lxfs-app-1 Recreate
Container lxfs-app-1 Recreated
Container lxfs-db-1 Running
Container lxfs-nginx-1 Running
بعد از بروزرسانی: HTTP 200

فقط app دوباره ساخته شد و db و nginx دست نخوردند. و چون در تنظیم nginx از resolver و متغیر $upstream استفاده کردیم، nginx IP جدید اپ را خودکار پیدا کرد و بدون ریستارت جواب می‌دهد.

چرا در فایل nginx آن resolver و set $upstream هست؟ nginx اگر proxy_pass http://app:8000; را مستقیم بنویسی، اسم app را فقط یک بار موقع شروع به IP ترجمه می‌کند و آن IP را برای همیشه نگه می‌دارد. اما با هر بار ساخته شدن دوباره‌ی کانتینر app، IP تغییر می‌کند (همان چیزی که در درس DNS دیدی) و nginx به آدرس قدیمی می‌زند و 502 می‌دهد. با ریختن آدرس در متغیر و گذاشتن resolver 127.0.0.11 (همان DNS داخلی داکر)، nginx اسم را در حین کار و هر چند ثانیه دوباره resolve می‌کند.

دو نکته‌ی دیگر:

  • ترتیب بالا آمدن از گراف depends_on می‌آید: db ← app ← nginx. و پایین آوردن (down) به ترتیب عکس انجام می‌شود.
  • کلاینت‌ها (curl) به nginx می‌زنند؛ nginx روی frontend و app با IP داخلی. هیچ‌جا از localhost بین کانتینرها استفاده نکردیم؛ در داخل کانتینر localhost خودِ همان کانتینر است. همیشه نام سرویس.
بخش چرا
ports فقط روی nginx تنها در ورودی؛ بقیه ایزوله
دو شبکه (frontend، backend) دیتابیس از nginx دیده نمی‌شود
volume pgdata داده با حذف کانتینر نمی‌رود
healthcheck روی db و app service_healthy و تشخیص خرابی
${POSTGRES_PASSWORD:?...} رمز اجباری و بیرون از فایل Compose
restart: unless-stopped بازگشت بعد از کرش یا ریبوت
USER appuser در Dockerfile اپ با root اجرا نمی‌شود
وضعیت علامت کار
nginx بالا، app پایین 502 Bad Gateway docker compose logs app، ps
app بالا، db پایین /health → ۵۰۳ و unhealthy docker compose logs db
پورت اشغال خطای port is already allocated WEB_PORT را عوض کن
رمز عوض شده ولی db قدیمی password authentication failed volume قدیمی رمز قبلی را دارد (down -v فقط روی داده‌ی اهمیت‌نداشته)
Terminal window
mkdir -p m1 && cp fullstack/compose.yaml m1/compose.yaml && cp -r fullstack/nginx m1/nginx && mkdir -p m1/app
cd m1 && docker compose config --quiet 2>&1 | head -1 | sed -E 's/^error while interpolating [^:]*: //'
خروجی
required variable POSTGRES_PASSWORD is missing a value: POSTGRES_PASSWORD را در .env بگذار

بدون .env با پیام فارسی خودمان متوقف می‌شود. راه‌حل: .env را از .env.example کپی کن و پر کن.

۲) تغییر رمز بعد از اولین اجرا

Section titled “۲) تغییر رمز بعد از اولین اجرا”

PostgreSQL رمز را فقط بار اول (وقتی volume خالی است) تنظیم می‌کند. اگر POSTGRES_PASSWORD را در .env عوض کنی ولی volume قدیمی باشد، اپ با رمز جدید وصل نمی‌شود (password authentication failed). راه‌حل: رمز را داخل خود دیتابیس عوض کن، یا (فقط برای داده‌ی بی‌اهمیت) down -v.

اگر در DATABASE_URL به‌جای db بنویسی localhost، اپ به خود کانتینر اپ وصل می‌شود. راه‌حل: نام سرویس (db).

۴) باز کردن پورت دیتابیس به بیرون

Section titled “۴) باز کردن پورت دیتابیس به بیرون”

ports: "5432:5432" روی db دیتابیس را روی تمام آدرس‌های میزبان باز می‌کند. راه‌حل: پورت ندهید. اگر برای توسعه لازم است، "127.0.0.1:5432:5432" فقط روی لوپ‌بک.

راه‌حل: .env در .gitignore، و .env.example با مقدارهای ساختگی در Git.

۶) bind mount تک‌فایلی و ویرایشی که فایل را «جایگزین» می‌کند

Section titled “۶) bind mount تک‌فایلی و ویرایشی که فایل را «جایگزین» می‌کند”
Terminal window
mkdir -p m6 && echo v1 > m6/f.txt
docker run -d --name lx-m6 -v "$PWD/m6/f.txt:/f.txt:ro" alpine sleep 60 >/dev/null
sed -i.bak 's/v1/v2/' m6/f.txt
echo "روی میزبان: $(cat m6/f.txt)"
echo "داخل کانتینر: $(docker exec lx-m6 cat /f.txt)"
docker rm -f lx-m6 >/dev/null
خروجی
روی میزبان: v2
داخل کانتینر: v1

sed -i (و خیلی از ویرایشگرها) فایل جدید می‌سازند و جای قبلی می‌گذارند؛ bind mount تک‌فایلی هنوز به فایل قدیمی وصل است، پس کانتینر تغییر را نمی‌بیند (گاهی هم خطای «No such file» می‌گیری). به همین دلیل در این پروژه پوشه‌ی ./nginx را mount کردیم نه فقط فایل را. راه‌حل: پوشه را mount کن.

✎ تمرینآسان

یک یادداشت دیگر با curl از طریق nginx اضافه کن و نشان بده در فهرست آمد. بعد با psql داخل کانتینر db همان را بخوان.

دیدن جواب
Terminal window
cd fullstack
curl -s -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{"text":"تمرین ۱"}'; echo
docker compose exec -T db psql -U shop -d shop -tA -c "select id, text from notes order by id desc limit 1"
خروجی
{"id":1,"text":"تمرین ۱"}
1|تمرین ۱
✎ تمرینمتوسط

تمرین اصلی: پروژه را کامل اجرا و تست کن. یک اسکریپت smoke test بنویس که چهار چیز را بررسی کند: ۱) همه‌ی سرویس‌ها running؛ ۲) /api/notes کد ۲۰۰؛ ۳) POST کد ۲۰۱؛ ۴) اتصال مستقیم به دیتابیس از بیرون بسته است. نتیجه را PASS یا FAIL چاپ کند.

دیدن جواب
Terminal window
cd fullstack
check() { if eval "$2" >/dev/null 2>&1; then echo "PASS $1"; else echo "FAIL $1"; fi; }
check "همه‌ی سرویس‌ها running" '[ "$(docker compose ps --format "{{.State}}" | sort -u)" = running ]'
check "GET /api/notes = 200" '[ "$(curl -s -o /dev/null -w %{http_code} localhost:8395/api/notes)" = 200 ]'
check "POST = 201" '[ "$(curl -s -o /dev/null -w %{http_code} -X POST localhost:8395/api/notes -H "Content-Type: application/json" -d "{\"text\":\"smoke\"}")" = 201 ]'
check "db از بیرون بسته" '! nc -z -w 2 localhost 5432'
خروجی
PASS همه‌ی سرویس‌ها running
PASS GET /api/notes = 200
PASS POST = 201
PASS db از بیرون بسته
✎ تمرینسخت

در nginx/default.conf یک هدر X-Served-By: nginx به پاسخ‌ها اضافه کن، بدون ریستارت کانتینر nginx (از nginx -s reload بعد از nginx -t استفاده کن) و با curl -I نشان بده هدر آمد.

دیدن جواب
Terminal window
cd fullstack
sed -i.bak 's|resolver 127.0.0.11 valid=5s;|resolver 127.0.0.11 valid=5s;\n add_header X-Served-By nginx always;|' nginx/default.conf
rm -f nginx/default.conf.bak
docker compose exec -T nginx nginx -t 2>&1 | tail -1
docker compose exec -T nginx nginx -s reload 2>&1 | grep -v notice
sleep 1
curl -sI localhost:8395/api/notes | grep -i x-served-by
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
X-Served-By: nginx
؟ آزمونک
  1. چرا فقط nginx ports دارد؟

  2. در این پروژه db فقط در چه شبکه‌ای است؟

  3. چرا nginx با proxy_pass مستقیم http://app:8000 بعد از ساخت دوباره‌ی app می‌تواند 502 بدهد؟

  4. دستور down چه چیزی را پاک نمی‌کند؟

  5. اگر POSTGRES_PASSWORD را بعد از اولین اجرا در .env عوض کنی چه می‌شود؟

  6. زنجیره‌ی depends_on این پروژه؟

  • یک پروژه‌ی چندسرویسی کامل: nginx (ورودی)، app، db، با دو شبکه، یک volume، healthcheck و .env.
  • فقط ورودی پورت دارد؛ دیتابیس فقط روی شبکه‌ی خصوصی (backend).
  • ترتیب راه‌اندازی با depends_on و condition: service_healthy؛ up -d --build --wait تا آماده شدن صبر می‌کند.
  • nginx اسم upstream را فقط یک بار resolve می‌کند؛ با resolver 127.0.0.11 و متغیر، تغییر IP را تحمل می‌کند.
  • رمز فقط در .env (در .gitignore)، با :? اجباری؛ down داده را نگه می‌دارد و down -v پاک می‌کند.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
docker compose up -d --build --waitساخت، اجرا و صبر تا آماده شدن
docker compose psوضعیت و سلامت سرویس‌ها
docker compose logs -f appلاگ زنده‌ی اپ
docker compose exec db psql -U shop -d shopورود به PostgreSQL
docker compose exec nginx nginx -tبررسی تنظیم nginx
docker compose exec nginx nginx -s reloadبارگذاری دوباره‌ی تنظیم بدون ریستارت
docker compose downبرچیدن (داده می‌ماند)
docker compose down -vبرچیدن همراه داده