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

پروژه‌ی پایانی

توی این درس یاد می‌گیری همه‌ی دوره را در یک پروژه‌ی واقعی و کامل به کار ببری: یک اپ Flask با image چندمرحله‌ای، سبک و غیر root، یک PostgreSQL با رمز که از فایل secret خوانده می‌شود (نه از متغیر محیطی)، یک Nginx به‌عنوان تنها در ورودی، همه با healthcheck، restart policy، سقف حافظه، چرخش لاگ و حداقل دسترسی در یک فایل Compose. بعد پروژه را روی یک «سرور» (یک daemon جدا با ssh) مستقر می‌کنی، به‌روزرسانی می‌کنی بدون از دست رفتن داده، بکاپ می‌گیری و ریبوت سرور را شبیه‌سازی می‌کنی. تمرین اصلی خودِ همین است: پروژه را از صفر بساز و روی یک سرور اجرا کن.

یک سرویس یادداشت (notes) با API ساده. معماری و تصمیم‌های هر بخش همان‌هایی است که در درس‌های قبل دیدی:

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

مثال‌های عملی: پروژه، مرحله به مرحله

Section titled “مثال‌های عملی: پروژه، مرحله به مرحله”

اپ، با رمز دیتابیس از فایل و نسخه در /health:

fp/notes/app/app.py
import os
import time
import psycopg2
from flask import Flask, jsonify, request
app = Flask(__name__)
app.json.ensure_ascii = False
def db_password():
path = os.environ.get("DB_PASSWORD_FILE")
if path:
return open(path).read().strip()
return os.environ["DB_PASSWORD"]
def connect():
return psycopg2.connect(
host=os.environ.get("DB_HOST", "db"), dbname="notes", user="notes",
password=db_password(), 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", version=os.environ.get("APP_VERSION", "dev"))
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
fp/notes/app/requirements.txt
flask==3.0.3
gunicorn==22.0.0
psycopg2-binary==2.9.9

image چندمرحله‌ای، غیر root، با HEALTHCHECK:

fp/notes/app/Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
ARG APP_VERSION=dev
ENV PYTHONUNBUFFERED=1 APP_VERSION=${APP_VERSION}
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app.py .
RUN useradd -r -u 10001 app
USER 10001
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "app:app"]
fp/notes/app/.dockerignore
__pycache__
*.pyc
.env

تنظیم nginx (پوشه‌ای mount می‌شود، نه فایل تکی):

fp/notes/nginx/app.conf
server {
listen 80;
server_tokens off;
resolver 127.0.0.11 valid=5s;
add_header X-Content-Type-Options nosniff always;
gzip on;
gzip_types application/json text/plain;
location / {
set $upstream http://app:8000;
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

فایل Compose، با همه‌ی عادت‌های چک‌لیست:

fp/notes/compose.yaml
name: notes
x-common: &common
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
nginx:
<<: *common
image: nginx:alpine
ports:
- "${WEB_PORT:-8370}:80"
volumes:
- ./nginx:/etc/nginx/conf.d:ro
read_only: true
tmpfs: [/var/cache/nginx, /var/run, /tmp]
cap_drop: [ALL]
cap_add: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
mem_limit: 64m
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
interval: 10s
timeout: 3s
retries: 3
networks: [frontend]
app:
<<: *common
image: lx-fp-app:${APP_TAG:-1.0.0}
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
mem_limit: 256m
pids_limit: 100
depends_on:
db:
condition: service_healthy
networks: [frontend, backend]
db:
<<: *common
image: postgres:16-alpine
environment:
POSTGRES_USER: notes
POSTGRES_DB: notes
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 5s
timeout: 3s
retries: 10
networks: [backend]
secrets:
db_password:
file: ./secrets/db_password.txt
networks:
frontend:
backend:
volumes:
pgdata:
fp/notes/.env
APP_TAG=1.0.0
WEB_PORT=8370
fp/notes/.gitignore
.env
secrets/

چند نکته‌ی طراحی: x-common و <<: *common یک ویژگی YAML است تا restart و logging را برای همه یکجا بنویسی. db عمداً cap_drop ندارد (PostgreSQL در راه‌اندازی به چند capability نیاز دارد، همان درس تجربه کردیم که cap-drop ALL همیشه رایگان نیست). و رمز در فایل secrets/db_password.txt است که در .gitignore و در image نیست؛ همین حالا یک رمز تصادفی می‌سازیم. به دسترسی‌ها دقت کن: پوشه 700 (فقط مالک وارد می‌شود) ولی خود فایل 644. دلیلش را در «اشتباهات رایج» با یک خطای واقعی می‌بینی: فایل secret در Compose مستقیماً mount می‌شود و کاربر غیر root داخل کانتینر باید بتواند آن را بخواند؛ محافظت از رمز با پوشه‌ی بسته انجام می‌شود:

Terminal window
cd fp/notes
mkdir -p secrets
openssl rand -hex 16 > secrets/db_password.txt
chmod 700 secrets
chmod 644 secrets/db_password.txt
echo "رمز ساخته شد (طول: $(wc -c < secrets/db_password.txt | tr -d ' ') کاراکتر) — محتوا را نمایش نمی‌دهیم"
ls -ld secrets secrets/db_password.txt | awk '{print $1, $9}'
find . -type f | sort | grep -v '^./secrets'
خروجی
رمز ساخته شد (طول: 33 کاراکتر) — محتوا را نمایش نمی‌دهیم
drwx------@ secrets
-rw-r--r--@ secrets/db_password.txt
./.env
./.gitignore
./app/.dockerignore
./app/Dockerfile
./app/app.py
./app/requirements.txt
./compose.yaml
./nginx/app.conf
Terminal window
cd fp/notes
docker run --rm -i hadolint/hadolint hadolint --no-color - < app/Dockerfile && echo "hadolint: Dockerfile تمیز است"
docker compose config --quiet && echo "compose config: معتبر است"
echo "تعداد تنظیمات امنیتی و منابع در Compose نهایی: $(docker compose config | grep -cE 'cap_drop|read_only|mem_limit|restart')"
خروجی
hadolint: Dockerfile تمیز است
compose config: معتبر است
تعداد تنظیمات امنیتی و منابع در Compose نهایی: 12

قبل از اینکه چیزی بسازی، دو بررسی ارزان: Dockerfile با hadolint و Compose با config. (در CI همین‌ها را بگذار.)

مثال ۳: ساخت و اجرای محلی

Section titled “مثال ۳: ساخت و اجرای محلی”
Terminal window
cd fp/notes
docker build -q -t lx-fp-app:1.0.0 --build-arg APP_VERSION=1.0.0 app >/dev/null
echo "حجم image: $(docker image inspect lx-fp-app:1.0.0 --format '{{.Size}}' | awk '{printf "%d MB", $1/1000000}')"
echo "کاربر پروسه: $(docker image inspect lx-fp-app:1.0.0 --format '{{.Config.User}}')"
docker image inspect lx-fp-app:1.0.0 --format 'HEALTHCHECK: {{json .Config.Healthcheck.Test}}' | cut -c1-70
docker compose up -d --wait --wait-timeout 180 2>&1 | grep -E 'Healthy|Started' | sed -E 's/^ +//' | awk '!s[$0]++' | sort
خروجی
حجم image: 236 MB
کاربر پروسه: 10001
HEALTHCHECK: ["CMD","python","-c","import urllib.request; urllib.reque
Container notes-app-1 Healthy
Container notes-app-1 Started
Container notes-db-1 Healthy
Container notes-db-1 Started
Container notes-nginx-1 Healthy
Container notes-nginx-1 Started

image ساخته شد، با کاربر app و healthcheck داخلش؛ و up -d --wait به ترتیب db ← app ← nginx را بالا آورد و منتظر سالم شدن ماند. تست:

Terminal window
cd fp/notes
curl -s localhost:8370/health; echo
curl -s -X POST localhost:8370/api/notes -H 'Content-Type: application/json' -d '{"text":"یادداشت اول"}'; echo
curl -s -X POST localhost:8370/api/notes -H 'Content-Type: application/json' -d '{"text":"یادداشت دوم"}'; echo
curl -s localhost:8370/api/notes; echo
echo "هدر امنیتی: $(curl -sI localhost:8370/health | grep -i x-content-type | tr -d '\r')"
خروجی
{"status":"ok","version":"1.0.0"}
{"id":1,"text":"یادداشت اول"}
{"id":2,"text":"یادداشت دوم"}
[{"id":1,"text":"یادداشت اول"},{"id":2,"text":"یادداشت دوم"}]
هدر امنیتی: X-Content-Type-Options: nosniff

و بررسی اینکه سخت‌سازی‌ها واقعاً فعال‌اند (نه فقط در فایل نوشته شده‌اند):

Terminal window
cd fp/notes
for s in nginx app db; do
c=notes-$s-1
echo "$s: user=$(docker inspect $c --format '{{if .Config.User}}{{.Config.User}}{{else}}root(پیش‌فرض image){{end}}') ro=$(docker inspect $c --format '{{.HostConfig.ReadonlyRootfs}}') capdrop=$(docker inspect $c --format '{{.HostConfig.CapDrop}}') mem=$(( $(docker inspect $c --format '{{.HostConfig.Memory}}') / 1048576 ))MiB health=$(docker inspect $c --format '{{.State.Health.Status}}')"
done
echo "پورت‌های منتشرشده: $(docker compose ps --format '{{.Service}}={{.Ports}}' | sed 's/, \[::\][^ ]*//' | grep -E '=[0-9]' | tr '\n' ' ')"
echo "رمز در image هست؟ $(docker save lx-fp-app:1.0.0 | tar -xO 2>/dev/null | grep -c "$(cat secrets/db_password.txt)")"
خروجی
nginx: user=root(پیش‌فرض image) ro=true capdrop=[ALL] mem=64MiB health=healthy
app: user=10001 ro=true capdrop=[ALL] mem=256MiB health=healthy
db: user=root(پیش‌فرض image) ro=false capdrop=[] mem=512MiB health=healthy
پورت‌های منتشرشده: app=8000/tcp db=5432/tcp nginx=0.0.0.0:8370->80/tcp
رمز در image هست؟ 0

سه سرویس سالم‌اند؛ app و db به بیرون پورت منتشر نکرده‌اند؛ رمز در هیچ لایه‌ای از image نیست (عدد آخر صفر). حالا بیایم سراغ سرور.

Terminal window
docker run -d --privileged --name lx-fp-srv -p 2397:22 -p 8371:80 -e DOCKER_TLS_CERTDIR= docker:dind >/dev/null
until docker exec lx-fp-srv docker info >/dev/null 2>&1; do sleep 1; done
docker exec lx-fp-srv sh -c 'apk add --no-cache openssh >/dev/null 2>&1; ssh-keygen -A >/dev/null; sed -i "s/^root:!:/root:*:/" /etc/shadow; mkdir -p /root/.ssh /srv/notes'
ssh-keygen -q -t ed25519 -N '' -C lx-fp -f fp/key
docker cp fp/key.pub lx-fp-srv:/root/.ssh/authorized_keys
docker exec lx-fp-srv sh -c 'chown root:root /root/.ssh/authorized_keys; chmod 700 /root/.ssh; chmod 600 /root/.ssh/authorized_keys; /usr/sbin/sshd'
sleep 1
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
srv 'echo "وصل شدیم: $(hostname)"; echo "image ها روی سرور: $(docker images -q | wc -l)"; docker compose version --short | sed "s/^/compose روی سرور: /"'
خروجی
وصل شدیم: cbab2e843d80
image ها روی سرور: 0
compose روی سرور: 5.5.1

سرور تازه است: هیچ imageای ندارد، فقط داکر و Compose. (کلید ssh فقط برای همین درس و موقتی است و بعدش پاک می‌شود.)

⚡ بررسی سریع

چرا پوشه‌ی secrets را 700 و فایل رمز را 644 گذاشتیم؟

سرور به اینترنت یا registry نیازی ندارد: image ها را همان‌طور که در درس قبل دیدی با save | ssh load می‌فرستیم، و فایل‌های پروژه را با scp:

Terminal window
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
echo "ارسال image ها (app، postgres، nginx)…"
docker save lx-fp-app:1.0.0 postgres:16-alpine nginx:alpine | gzip | srv 'gunzip | docker load' | sed -E 's/^Loaded image: / /'
echo "ارسال فایل‌های پروژه…"
( cd fp/notes && COPYFILE_DISABLE=1 tar cf - compose.yaml nginx secrets ) | srv 'tar xf - -C /srv/notes && chown -R root:root /srv/notes && chmod 700 /srv/notes/secrets && chmod 644 /srv/notes/secrets/db_password.txt'
srv 'printf "APP_TAG=1.0.0\nWEB_PORT=80\n" > /srv/notes/.env; ls /srv/notes'
خروجی
ارسال image ها (app، postgres، nginx)…
lx-fp-app:1.0.0
postgres:16-alpine
nginx:alpine
ارسال فایل‌های پروژه…
compose.yaml
nginx
secrets

نکته‌های استقرار: فایل .env سرور با .env لپ‌تاپ فرق دارد (پورت ۸۰ به‌جای ۸۳۷۰)؛ رمز داخل پوشه‌ای با دسترسی 700 (فقط root) روی سرور است؛ و کد برنامه روی سرور نمی‌رود (فقط image).

Terminal window
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
srv 'cd /srv/notes && docker compose up -d --wait --wait-timeout 180 2>&1 | grep -E "Healthy|Started" | sed -E "s/^ +//" | sort -u'
sleep 2
echo "از بیرون سرور: $(curl -s localhost:8371/health)"
srv 'cd /srv/notes && docker compose ps --format "{{.Service}}: {{.State}} {{.Health}}" | sort'
خروجی
Container notes-app-1 Healthy
Container notes-app-1 Started
Container notes-db-1 Healthy
Container notes-db-1 Started
Container notes-nginx-1 Healthy
Container notes-nginx-1 Started
از بیرون سرور: {"status":"ok","version":"1.0.0"}
app: running healthy
db: running healthy
nginx: running healthy

پروژه روی «سرور» بالا آمد، از بیرون با curl localhost:8371 (که به پورت ۸۰ داخل سرور می‌رسد) جواب می‌دهد، و هر سه سرویس healthy هستند.

مثال ۶: داده و به‌روزرسانی بدون از دست رفتن آن

Section titled “مثال ۶: داده و به‌روزرسانی بدون از دست رفتن آن”
Terminal window
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
curl -s -X POST localhost:8371/api/notes -H 'Content-Type: application/json' -d '{"text":"یادداشت روی سرور"}'; echo
echo "نسخه‌ی فعلی: $(curl -s localhost:8371/health)"
خروجی
{"id":1,"text":"یادداشت روی سرور"}
نسخه‌ی فعلی: {"status":"ok","version":"1.0.0"}

حالا نسخه‌ی ۱.۰.۱ را می‌سازیم (تغییر کوچک در کد)، فقط image اپ را می‌فرستیم، APP_TAG را عوض می‌کنیم و up -d می‌زنیم:

Terminal window
cd fp/notes
SSHOPT="-i $PWD/../key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
echo "# نسخه‌ی 1.0.1" >> app/app.py
docker build -q -t lx-fp-app:1.0.1 --build-arg APP_VERSION=1.0.1 app >/dev/null
docker save lx-fp-app:1.0.1 | gzip | srv 'gunzip | docker load' | sed -E 's/^Loaded image: /ارسال شد: /'
srv "sed -i 's/^APP_TAG=.*/APP_TAG=1.0.1/' /srv/notes/.env && cd /srv/notes && docker compose up -d --wait --wait-timeout 180 2>&1 | grep -E 'Recreate|Running|Started' | sed -E 's/^ +//' | awk '!s[\$0]++' | sort"
sleep 2
echo "نسخه‌ی جدید: $(curl -s localhost:8371/health)"
echo "داده‌ی قبلی: $(curl -s localhost:8371/api/notes)"
خروجی
ارسال شد: lx-fp-app:1.0.1
Container notes-app-1 Recreate
Container notes-app-1 Recreated
Container notes-app-1 Started
Container notes-db-1 Running
Container notes-nginx-1 Running
نسخه‌ی جدید: {"status":"ok","version":"1.0.1"}
داده‌ی قبلی: [{"id":1,"text":"یادداشت روی سرور"}]

فقط app دوباره ساخته شد؛ db و nginx دست نخوردند، داده ماند و نسخه عوض شد. اگر مشکلی بود، rollback یعنی APP_TAG=1.0.0 و up -d (image قدیمی هنوز روی سرور است).

Terminal window
cd fp/notes
SSHOPT="-i $PWD/../key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
mkdir -p ../backup
srv 'cd /srv/notes && docker compose exec -T db pg_dump -U notes notes' > ../backup/notes.sql
echo "بکاپ روی لپ‌تاپ: $(wc -l < ../backup/notes.sql | tr -d ' ') خط، ردیف‌های notes: $(grep -c '^[0-9]' ../backup/notes.sql)"
خروجی
بکاپ روی لپ‌تاپ: 95 خط، ردیف‌های notes: 1

بکاپ از دیتابیس زنده به لپ‌تاپ منتقل شد (خارج از سرور، مثل آنچه در «داکر در production» گفتیم). برای تست بازیابی، آن را روی دیتابیس تازه‌ی محلی برمی‌گردانیم:

Terminal window
cd fp/notes
docker run -d --name lx-fp-restore -e POSTGRES_PASSWORD=x -e POSTGRES_USER=notes -e POSTGRES_DB=notes postgres:16-alpine >/dev/null
for i in $(seq 30); do docker exec lx-fp-restore pg_isready -U notes >/dev/null 2>&1 && break; sleep 1; done; sleep 2
docker exec -i lx-fp-restore psql -U notes -q notes < ../backup/notes.sql >/dev/null 2>&1
docker exec lx-fp-restore psql -U notes -tA notes -c "select count(*) || ' یادداشت بازیابی شد، آخرین: ' || (select text from notes order by id desc limit 1) from notes"
docker rm -f lx-fp-restore >/dev/null
خروجی
1 یادداشت بازیابی شد، آخرین: یادداشت روی سرور
Terminal window
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
docker restart lx-fp-srv >/dev/null
for i in $(seq 40); do docker exec lx-fp-srv docker info >/dev/null 2>&1 && break; sleep 1; done
docker exec lx-fp-srv sh -c '/usr/sbin/sshd'
for i in $(seq 60); do
curl -s -m 2 localhost:8371/health 2>/dev/null | grep -q '"status":"ok"' && break; sleep 2
done
for i in $(seq 60); do
srv 'cd /srv/notes && docker compose ps --format "{{.Health}}"' | grep -q starting || break; sleep 2
done
echo "بعد از ریبوت: $(curl -s localhost:8371/health)"
echo "داده: $(curl -s localhost:8371/api/notes | python3 -c "import json,sys; print(len(json.load(sys.stdin)), 'یادداشت')")"
srv 'cd /srv/notes && docker compose ps --format "{{.Service}}: {{.State}} {{.Health}}" | sort'
خروجی
بعد از ریبوت: {"status":"ok","version":"1.0.1"}
داده: 1 یادداشت
app: running healthy
db: running healthy
nginx: running healthy

بعد از «ریبوت» (restart کل daemon) هر سه سرویس خودشان با restart: unless-stopped برگشتند، به ترتیب depends_on سالم شدند، و داده‌ی دیتابیس (volume) سر جایش بود. (روی سرور واقعی، سرویس docker باید با بوت فعال باشد: sudo systemctl enable docker.)

پشت پرده: چرا این پروژه «درست» است؟

Section titled “پشت پرده: چرا این پروژه «درست» است؟”
  • image: multi-stage و slim، کاربر غیر root، healthcheck داخل خود image (هر جا اجرا شود، سلامتش قابل سنجش است)، رمز نه در ENV نه در لایه.
  • Compose: ترتیب راه‌اندازی با service_healthy، فقط ورودی پورت دارد، دو شبکه، volume برای داده، restart، سقف منابع و چرخش لاگ، و حداقل دسترسی (read_only، cap_drop، no-new-privileges) برای سرویس‌هایی که تحمل می‌کنند.
  • رازها: فایل secret در /run/secrets داخل کانتینر می‌آید (نه در docker inspect)، پوشه‌ی آن روی میزبان 700 است (فایل 644 تا کاربر برنامه بتواند بخواند) و در .gitignore است.
  • استقرار: مبتنی بر image تغییرناپذیر (نه کد روی سرور)، با tag دقیق، قابل‌برگشت، با انتقال امن از ssh.
  • عملیات: بکاپ بیرونی و آزمایش بازیابی، رفتار بعد از ریبوت تست‌شده.

اینجا از registry استفاده نکردیم تا درس مستقل از شبکه بماند؛ در پروژه‌ی واقعی image را به یک registry خصوصی می‌فرستی (docker push) و روی سرور docker compose pull می‌زنی؛ بقیه‌ی مراحل تغییری نمی‌کند.

مرحله ابزار/دستور درس مرتبط
lint قبل از build hadolint، docker compose config --quiet بهترین شیوه‌ها
ساخت docker build --build-arg APP_VERSION=… Dockerfile، multi-stage
اجرای محلی docker compose up -d --wait Compose، healthcheck
ارسال docker save | gzip | ssh … docker load انتقال آفلاین
استقرار docker compose up -d --wait روی سرور Compose، production
به‌روزرسانی tag جدید + up -d production
بکاپ pg_dump production
سخت‌سازی read_only، cap_drop، secrets امنیت

۱) فراموش کردن .env مخصوص سرور

Section titled “۱) فراموش کردن .env مخصوص سرور”

اگر .env لپ‌تاپ را عیناً بفرستی، پورت و tag محلی روی سرور می‌نشیند. راه‌حل: .env هر محیط جدا و خارج از Git (فقط .env.example در Git).

۲) فایل secret که کاربر برنامه نمی‌تواند بخواند

Section titled “۲) فایل secret که کاربر برنامه نمی‌تواند بخواند”

فایل secret در Compose با bind mount داخل کانتینر می‌آید و مالک و دسترسی فایل روی میزبان همان می‌ماند. اپ ما با کاربر 10001 اجرا می‌شود، پس اگر فایل روی سرور root:root و 600 باشد، اپ نمی‌تواند رمز را بخواند:

Terminal window
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
srv 'chmod 600 /srv/notes/secrets/db_password.txt; ls -l /srv/notes/secrets/db_password.txt | cut -c1-10,16-30'
echo "--- خواندن رمز داخل کانتینر app (کاربر 10001):"
srv 'cd /srv/notes && docker compose exec -T app python -c "open(\"/run/secrets/db_password\").read()"' 2>&1 | tail -1
srv 'chmod 644 /srv/notes/secrets/db_password.txt'
srv 'cd /srv/notes && docker compose exec -T app python -c "print(len(open(\"/run/secrets/db_password\").read().strip()), \"کاراکتر خوانده شد\")"'
خروجی
-rw------- root root
--- خواندن رمز داخل کانتینر app (کاربر 10001):
PermissionError: [Errno 13] Permission denied: '/run/secrets/db_password'
32 کاراکتر خوانده شد

با 600 خطای Permission denied آمد؛ با 644 اپ رمز را خواند. محافظت واقعی از رمز با پوشه‌ی 700 انجام می‌شود: هیچ کاربر عادی روی سرور نمی‌تواند وارد پوشه شود، ولی Docker (که root است) فایل را داخل کانتینر می‌گذارد. (روی Docker Desktop مک این مشکل را معمولاً نمی‌بینی چون سیستم اشتراک فایل مالک‌ها را نگاشت می‌کند؛ روی سرور لینوکس پیدا می‌شود. نمونه‌ی کلاسیک «روی لپ‌تاپ من کار می‌کرد».) راه‌حل دیگر: مالک فایل را uid برنامه (chown 10001) و دسترسی را 400 بگذار.

۳) docker compose down -v روی سرور

Section titled “۳) docker compose down -v روی سرور”

حذف volume = حذف داده. راه‌حل: فقط up -d برای به‌روزرسانی؛ down بدون -v.

سرویسی که فقط تا ریبوت اول زنده می‌ماند، خرابی بعدی است. راه‌حل: مثل مرحله ۸ آزمایش کن (و systemctl enable docker).

Terminal window
cd fp/notes
echo "env اپ شامل رمز است؟ $(docker inspect notes-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -ci 'password=')"
echo "env اپ فقط مسیر فایل را دارد: $(docker inspect notes-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i 'password_file')"
خروجی
env اپ شامل رمز است؟ 0
env اپ فقط مسیر فایل را دارد: DB_PASSWORD_FILE=/run/secrets/db_password

متغیر محیطی رمز در docker inspect دیده می‌شد؛ اینجا فقط مسیر فایل در env است و خود رمز نه. راه‌حل: *_FILE و secret.

✎ تمرینآسان

با curl یک یادداشت جدید به سرور اضافه کن و نشان بده فهرست آن را برمی‌گرداند.

دیدن جواب
Terminal window
curl -s -X POST localhost:8371/api/notes -H 'Content-Type: application/json' -d '{"text":"تمرین ساده"}' ; echo
curl -s localhost:8371/api/notes | python3 -c "import json,sys; d=json.load(sys.stdin); print('آخرین یادداشت:', d[-1]['text'])"
خروجی
{"id":2,"text":"تمرین ساده"}
آخرین یادداشت: تمرین ساده
✎ تمرینمتوسط

تمرین اصلی: پروژه را از صفر بساز و روی یک سرور اجرا کن. همین پروژه را با یک سرویس چهارم گسترش بده: redis:alpine (فقط روی شبکه‌ی backend، با healthcheck و سقف حافظه) به Compose اضافه کن، روی لپ‌تاپ docker compose up -d --wait بزن و نشان بده هر چهار سرویس healthy یا running هستند، سپس docker compose config --quiet را اجرا کن.

دیدن جواب
Terminal window
cd fp/notes
cp compose.yaml compose.extended.yaml
python3 - <<'LXEOF'
p = "compose.extended.yaml"
s = open(p).read()
s = s.replace("secrets:\n db_password:", """ cache:
<<: *common
image: redis:alpine
mem_limit: 64m
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks: [backend]
secrets:
db_password:""", 1)
open(p, "w").write(s)
LXEOF
docker compose -f compose.extended.yaml config --quiet && echo "compose.extended.yaml معتبر است"
docker compose -f compose.extended.yaml up -d --wait cache >/dev/null 2>&1
docker compose -f compose.extended.yaml ps --format '{{.Service}}: {{.State}} {{.Health}}' | sort
docker compose -f compose.extended.yaml rm -sf cache >/dev/null 2>&1
rm compose.extended.yaml
خروجی
compose.extended.yaml معتبر است
app: running healthy
cache: running healthy
db: running healthy
nginx: running healthy
✎ تمرینسخت

rollback را عملی کن: روی «سرور» APP_TAG را به 1.0.0 برگردان، up -d بزن، نشان بده نسخه به ۱.۰.۰ برگشت و داده‌ی دیتابیس سالم است؛ سپس دوباره به 1.0.1 برو.

دیدن جواب
Terminal window
SSHOPT="-i $PWD/fp/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes"
srv() { ssh $SSHOPT -p 2397 root@localhost "$@"; }
go() { srv "sed -i 's/^APP_TAG=.*/APP_TAG=$1/' /srv/notes/.env && cd /srv/notes && docker compose up -d --wait --wait-timeout 180 >/dev/null 2>&1"; sleep 2; }
go 1.0.0
echo "rollback: $(curl -s localhost:8371/health) | یادداشت‌ها: $(curl -s localhost:8371/api/notes | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')"
go 1.0.1
echo "دوباره جلو: $(curl -s localhost:8371/health) | یادداشت‌ها: $(curl -s localhost:8371/api/notes | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')"
خروجی
rollback: {"status":"ok","version":"1.0.0"} | یادداشت‌ها: 2
دوباره جلو: {"status":"ok","version":"1.0.1"} | یادداشت‌ها: 2
✓ پروژه‌ی پایانی داکر
؟ آزمونک
  1. چرا رمز دیتابیس را به‌جای environment از secret فایلی می‌دهیم؟

  2. در این پروژه کدام سرویس پورت منتشر می‌کند؟

  3. برای به‌روزرسانی روی سرور چه کار می‌کنیم؟

  4. rollback چگونه است؟

  5. چرا بعد از ریبوت سرویس‌ها برگشتند؟

  6. بکاپ چرا به بیرون از سرور منتقل شد؟

  • پروژه‌ی کامل: image سبک و غیر root با healthcheck، Compose با ترتیب سالم، شبکه‌ی داخلی، volume، secret، محدودیت منابع و لاگ، و سخت‌سازی (read-only، cap-drop).
  • استقرار با ssh و image تغییرناپذیر: save | gzip | ssh load + scp فایل‌ها + up -d --wait.
  • به‌روزرسانی با tag جدید و up -d (فقط سرویس تغییرکرده)، rollback با tag قبلی؛ هرگز down -v.
  • بکاپ بیرونی با pg_dump و بازیابی آزمایش‌شده؛ رفتار بعد از ریبوت تست‌شده.
  • چک‌لیست بهترین شیوه‌ها را برای هر پروژه‌ی بعدی تکرار کن؛ بخش «داکر» با همین پروژه کامل می‌شود. 🎉
برگه‌ی تقلب این درس
دستورکاری که می‌کند
docker compose up -d --waitبالا آوردن و صبر تا سالم
docker save IMG … | gzip | ssh S 'gunzip | docker load'ارسال image ها بدون registry
scp / tar | ssh S 'tar xf - -C DIR'ارسال فایل‌های پروژه
ssh S "cd DIR && docker compose up -d --wait"استقرار روی سرور
sed -i s/^APP_TAG=.*/APP_TAG=X/ .envتغییر نسخه (به‌روزرسانی/rollback)
docker compose exec -T db pg_dump -U u dbبکاپ منطقی
secrets: + *_FILE=/run/secrets/…رمز از فایل
x-common: &common + <<: *commonتنظیم مشترک بین سرویس‌ها