- LoopX
- داکر از صفر تا حرفهای
- شبکه و Compose
- پروژه: وباپ + دیتابیس + Nginx
پروژه: وباپ + دیتابیس + Nginx
توی این درس یاد میگیری همهی چیزهایی که در بخش شبکه و Compose دیدی را در یک پروژهی واقعی کنار هم بگذاری: یک اپ Flask، یک دیتابیس PostgreSQL (با volume و healthcheck)، و Nginx بهعنوان reverse proxy که تنها در ورودی پروژه است. تنظیمات و رمزها را در فایل .env نگه میداری، ترتیب راهاندازی را با depends_on و service_healthy درست میکنی، و با docker compose up -d --build همهچیز را از صفر بالا میآوری و تست میکنی. این درس آخر بخش «شبکه و Compose» است.
چه میسازیم
Section titled “چه میسازیم”یک API کوچک برای یادداشتها (notes): میتوان یادداشت اضافه کرد و فهرستش را خواند. ساختار پروژه سه سرویس دارد:
تشبیه: ساختمان با نگهبان
Section titled “تشبیه: ساختمان با نگهبان”nginx مثل نگهبان ورودی ساختمان است: همهی مراجعان از او رد میشوند و او ارجاع میدهد. اپ کارمند است و دیتابیس گاوصندوق پشت دفتر؛ گاوصندوق در راهروی عمومی نیست، فقط کارمند به آن دسترسی دارد.
مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: ساختار و فایلهای پروژه
Section titled “مثال ۱: ساختار و فایلهای پروژه”اپ Flask:
import osimport time
import psycopg2from flask import Flask, jsonify, request
app = Flask(__name__)app.json.ensure_ascii = FalseDSN = 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), 201flask==3.0.3gunicorn==22.0.0psycopg2-binary==2.9.9FROM python:3.12-slimENV PYTHONUNBUFFERED=1WORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .RUN useradd -r -u 10001 appuserUSER appuserEXPOSE 8000CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]__pycache__*.pyc.envتنظیمات Nginx:
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:
POSTGRES_PASSWORD=lx-demo-passWEB_PORT=8395POSTGRES_PASSWORD=change-meWEB_PORT=8395و خود فایل Compose:
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:درخت فایلها:
find fullstack -type f | sortfullstack/.envfullstack/.env.examplefullstack/app/.dockerignorefullstack/app/Dockerfilefullstack/app/app.pyfullstack/app/requirements.txtfullstack/compose.yamlfullstack/nginx/default.confنکتههای طراحی:
- فقط
nginxپورتports:دارد؛appوdbندارند، پس از بیرون در دسترس نیستند. dbفقط رویbackendاست؛nginxفقط رویfrontend؛appپل بین دو شبکه.- زنجیرهی
depends_on:nginx←app(healthy) ←db(healthy). - پسورد با
:?اجباری است و فقط در.env(که در.gitignoreمیرود).
مثال ۲: بالا آوردن پروژه
Section titled “مثال ۲: بالا آوردن پروژه”cd fullstackdocker compose config --quiet && echo "فایل Compose معتبر است"docker compose up -d --build --wait 2>&1 | grep -E "Healthy|Started|Built" | sed -E 's/^ +//' | awk '!s[$0]++' | sortdocker compose ps --format '{{.Service}}: {{.State}} {{.Health}}' | sortفایل Compose معتبر استContainer lxfs-app-1 StartedContainer lxfs-db-1 HealthyContainer lxfs-db-1 StartedImage lxfs-app Builtapp: restarting startingdb: running healthyبا --build اپ ساخته شد و با --wait دستور تا آماده شدن همه منتظر ماند: اول db سالم شد، بعد app شروع شد و سالم شد، و در آخر nginx. اگر پسورد را از .env بردارد، همینجا با پیام فارسی خطا میگیری.
مثال ۳: تست از طریق Nginx
Section titled “مثال ۳: تست از طریق Nginx”cd fullstackecho "فهرست اولیه: $(curl -s localhost:8395/api/notes)"curl -s -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{"text":"خرید نان"}'; echocurl -s -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{"text":"آموزش داکر"}'; echoecho "فهرست: $(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 دیده میشود”cd fullstackecho "پورتهای منتشرشده:"docker compose ps --format '{{.Service}} {{.Ports}}' | sed 's/, \[::\][^ ]*//' | sortecho "اتصال مستقیم به اپ (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/tcpdb 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 میرود”cd fullstackdocker compose down 2>&1 | grep -c Removeddocker compose up -d --wait >/dev/null 2>&1echo "بعد از 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: 0volume pgdata ماند، پس یادداشتها هم ماندند؛ و با psql داخل خود دیتابیس تأییدش کردیم. (فقط down -v داده را پاک میکند.)
مثال ۶: خرابی و بازیابی
Section titled “مثال ۶: خرابی و بازیابی”cd fullstackdocker compose stop app >/dev/null 2>&1echo "app خاموش، از nginx: HTTP $(curl -s -o /dev/null -w '%{http_code}' localhost:8395/api/notes)"docker compose start app >/dev/null 2>&1for i in $(seq 20); do [ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = healthy ] && break; sleep 1doneecho "app برگشت، از nginx: HTTP $(curl -s -o /dev/null -w '%{http_code}' localhost:8395/api/notes)"docker compose stop db >/dev/null 2>&1for i in $(seq 40); do [ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = unhealthy ] && break; sleep 1doneecho "db خاموش، سلامت app: $(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)"docker compose start db >/dev/null 2>&1for i in $(seq 25); do [ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = healthy ] && break; sleep 1doneecho "db برگشت، سلامت app: $(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)"app خاموش، از nginx: HTTP 502app برگشت، از nginx: HTTP 200db خاموش، سلامت app: unhealthydb برگشت، سلامت app: healthyوقتی app متوقف بود، nginx پاسخ 502 Bad Gateway داد (پشتش چیزی نبود). بعد از برگشتش دوباره 200. با خاموش شدن دیتابیس، /health اپ 503 میدهد و healthcheck آن را unhealthy میکند؛ با برگشت دیتابیس خودش به healthy برگشت. حالا مانیتورینگ و ابزارهای بالاتر میتوانند تشخیص بدهند چه چیزی خراب است.
مثال ۷: بروزرسانی کد فقط یک سرویس را عوض میکند
Section titled “مثال ۷: بروزرسانی کد فقط یک سرویس را عوض میکند”cd fullstackecho "# نسخهی 2" >> app/app.pydocker compose up -d --build 2>&1 | grep -E "Recreate|Running" | sed -E 's/^ +//' | awk '!s[$0]++' | sortfor i in $(seq 20); do [ "$(docker inspect --format '{{.State.Health.Status}}' lxfs-app-1)" = healthy ] && break; sleep 1doneecho "بعد از بروزرسانی: HTTP $(curl -s -o /dev/null -w '%{http_code}' localhost:8395/api/notes)"Container lxfs-app-1 RecreateContainer lxfs-app-1 RecreatedContainer lxfs-db-1 RunningContainer lxfs-nginx-1 Runningبعد از بروزرسانی: HTTP 200فقط app دوباره ساخته شد و db و nginx دست نخوردند. و چون در تنظیم nginx از resolver و متغیر $upstream استفاده کردیم، nginx IP جدید اپ را خودکار پیدا کرد و بدون ریستارت جواب میدهد.
پشت پرده
Section titled “پشت پرده”چرا در فایل 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خودِ همان کانتینر است. همیشه نام سرویس.
جدولهای مرجع
Section titled “جدولهای مرجع”| بخش | چرا |
|---|---|
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 فقط روی دادهی اهمیتنداشته) |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) رمز در .env تعریف نشده
Section titled “۱) رمز در .env تعریف نشده”mkdir -p m1 && cp fullstack/compose.yaml m1/compose.yaml && cp -r fullstack/nginx m1/nginx && mkdir -p m1/appcd 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.
۳) localhost داخل کانتینر
Section titled “۳) localhost داخل کانتینر”اگر در DATABASE_URL بهجای db بنویسی localhost، اپ به خود کانتینر اپ وصل میشود. راهحل: نام سرویس (db).
۴) باز کردن پورت دیتابیس به بیرون
Section titled “۴) باز کردن پورت دیتابیس به بیرون”ports: "5432:5432" روی db دیتابیس را روی تمام آدرسهای میزبان باز میکند. راهحل: پورت ندهید. اگر برای توسعه لازم است، "127.0.0.1:5432:5432" فقط روی لوپبک.
۵) .env در Git
Section titled “۵) .env در Git”راهحل: .env در .gitignore، و .env.example با مقدارهای ساختگی در Git.
۶) bind mount تکفایلی و ویرایشی که فایل را «جایگزین» میکند
Section titled “۶) bind mount تکفایلی و ویرایشی که فایل را «جایگزین» میکند”mkdir -p m6 && echo v1 > m6/f.txtdocker run -d --name lx-m6 -v "$PWD/m6/f.txt:/f.txt:ro" alpine sleep 60 >/dev/nullsed -i.bak 's/v1/v2/' m6/f.txtecho "روی میزبان: $(cat m6/f.txt)"echo "داخل کانتینر: $(docker exec lx-m6 cat /f.txt)"docker rm -f lx-m6 >/dev/nullروی میزبان: v2داخل کانتینر: v1sed -i (و خیلی از ویرایشگرها) فایل جدید میسازند و جای قبلی میگذارند؛ bind mount تکفایلی هنوز به فایل قدیمی وصل است، پس کانتینر تغییر را نمیبیند (گاهی هم خطای «No such file» میگیری). به همین دلیل در این پروژه پوشهی ./nginx را mount کردیم نه فقط فایل را. راهحل: پوشه را mount کن.
یک یادداشت دیگر با curl از طریق nginx اضافه کن و نشان بده در فهرست آمد. بعد با psql داخل کانتینر db همان را بخوان.
دیدن جواب
cd fullstackcurl -s -X POST localhost:8395/api/notes -H 'Content-Type: application/json' -d '{"text":"تمرین ۱"}'; echodocker 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 چاپ کند.
دیدن جواب
cd fullstackcheck() { 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 همهی سرویسها runningPASS GET /api/notes = 200PASS POST = 201PASS db از بیرون بستهدر nginx/default.conf یک هدر X-Served-By: nginx به پاسخها اضافه کن، بدون ریستارت کانتینر nginx (از nginx -s reload بعد از nginx -t استفاده کن) و با curl -I نشان بده هدر آمد.
دیدن جواب
cd fullstacksed -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.confrm -f nginx/default.conf.bakdocker compose exec -T nginx nginx -t 2>&1 | tail -1docker compose exec -T nginx nginx -s reload 2>&1 | grep -v noticesleep 1curl -sI localhost:8395/api/notes | grep -i x-served-bynginx: configuration file /etc/nginx/nginx.conf test is successfulX-Served-By: nginxآزمونک
Section titled “آزمونک”چرا فقط nginx ports دارد؟
سطح حمله کوچک میشود.
در این پروژه db فقط در چه شبکهای است؟
nginx آن را نمیبیند؛ فقط app.
چرا nginx با proxy_pass مستقیم http://app:8000 بعد از ساخت دوبارهی app میتواند 502 بدهد؟
resolver و متغیر upstream این را حل میکند.
دستور down چه چیزی را پاک نمیکند؟
با down -v داده هم میرود.
اگر POSTGRES_PASSWORD را بعد از اولین اجرا در .env عوض کنی چه میشود؟
رمز فقط هنگام ساخت اولیهی دیتابیس تنظیم میشود.
زنجیرهی depends_on این پروژه؟
service_healthy روی db و app.
جمعبندی
Section titled “جمعبندی”- یک پروژهی چندسرویسی کامل: 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 | برچیدن همراه داده |