توی این درس یاد میگیری همهی دوره را در یک پروژهی واقعی و کامل به کار ببری: یک اپ Flask با image چندمرحلهای، سبک و غیر root، یک PostgreSQL با رمز که از فایل secret خوانده میشود (نه از متغیر محیطی)، یک Nginx بهعنوان تنها در ورودی، همه با healthcheck، restart policy، سقف حافظه، چرخش لاگ و حداقل دسترسی در یک فایل Compose. بعد پروژه را روی یک «سرور» (یک daemon جدا با ssh) مستقر میکنی، بهروزرسانی میکنی بدون از دست رفتن داده، بکاپ میگیری و ریبوت سرور را شبیهسازی میکنی. تمرین اصلی خودِ همین است: پروژه را از صفر بساز و روی یک سرور اجرا کن.
چه میسازیم
Section titled “چه میسازیم”یک سرویس یادداشت (notes) با API ساده. معماری و تصمیمهای هر بخش همانهایی است که در درسهای قبل دیدی:
مثالهای عملی: پروژه، مرحله به مرحله
Section titled “مثالهای عملی: پروژه، مرحله به مرحله”مثال ۱: فایلهای پروژه
Section titled “مثال ۱: فایلهای پروژه”اپ، با رمز دیتابیس از فایل و نسخه در /health:
import osimport time
import psycopg2from 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), 201flask==3.0.3gunicorn==22.0.0psycopg2-binary==2.9.9image چندمرحلهای، غیر root، با HEALTHCHECK:
FROM python:3.12-slim AS builderWORKDIR /buildCOPY requirements.txt .RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slimARG APP_VERSION=devENV PYTHONUNBUFFERED=1 APP_VERSION=${APP_VERSION}WORKDIR /appCOPY --from=builder /install /usr/localCOPY app.py .RUN useradd -r -u 10001 appUSER 10001EXPOSE 8000HEALTHCHECK --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"]__pycache__*.pyc.envتنظیم nginx (پوشهای mount میشود، نه فایل تکی):
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، با همهی عادتهای چکلیست:
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:APP_TAG=1.0.0WEB_PORT=8370.envsecrets/چند نکتهی طراحی: 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 داخل کانتینر باید بتواند آن را بخواند؛ محافظت از رمز با پوشهی بسته انجام میشود:
cd fp/notesmkdir -p secretsopenssl rand -hex 16 > secrets/db_password.txtchmod 700 secretschmod 644 secrets/db_password.txtecho "رمز ساخته شد (طول: $(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مثال ۲: بررسی قبل از build
Section titled “مثال ۲: بررسی قبل از build”cd fp/notesdocker 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 “مثال ۳: ساخت و اجرای محلی”cd fp/notesdocker build -q -t lx-fp-app:1.0.0 --build-arg APP_VERSION=1.0.0 app >/dev/nullecho "حجم 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-70docker compose up -d --wait --wait-timeout 180 2>&1 | grep -E 'Healthy|Started' | sed -E 's/^ +//' | awk '!s[$0]++' | sortحجم image: 236 MBکاربر پروسه: 10001HEALTHCHECK: ["CMD","python","-c","import urllib.request; urllib.requeContainer notes-app-1 HealthyContainer notes-app-1 StartedContainer notes-db-1 HealthyContainer notes-db-1 StartedContainer notes-nginx-1 HealthyContainer notes-nginx-1 Startedimage ساخته شد، با کاربر app و healthcheck داخلش؛ و up -d --wait به ترتیب db ← app ← nginx را بالا آورد و منتظر سالم شدن ماند. تست:
cd fp/notescurl -s localhost:8370/health; echocurl -s -X POST localhost:8370/api/notes -H 'Content-Type: application/json' -d '{"text":"یادداشت اول"}'; echocurl -s -X POST localhost:8370/api/notes -H 'Content-Type: application/json' -d '{"text":"یادداشت دوم"}'; echocurl -s localhost:8370/api/notes; echoecho "هدر امنیتی: $(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و بررسی اینکه سختسازیها واقعاً فعالاند (نه فقط در فایل نوشته شدهاند):
cd fp/notesfor 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}}')"doneecho "پورتهای منتشرشده: $(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=healthyapp: user=10001 ro=true capdrop=[ALL] mem=256MiB health=healthydb: 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 نیست (عدد آخر صفر). حالا بیایم سراغ سرور.
مثال ۴: ساخت «سرور» و ssh
Section titled “مثال ۴: ساخت «سرور» و ssh”docker run -d --privileged --name lx-fp-srv -p 2397:22 -p 8371:80 -e DOCKER_TLS_CERTDIR= docker:dind >/dev/nulluntil docker exec lx-fp-srv docker info >/dev/null 2>&1; do sleep 1; donedocker 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/keydocker cp fp/key.pub lx-fp-srv:/root/.ssh/authorized_keysdocker 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 1SSHOPT="-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 روی سرور: /"'وصل شدیم: cbab2e843d80image ها روی سرور: 0compose روی سرور: 5.5.1سرور تازه است: هیچ imageای ندارد، فقط داکر و Compose. (کلید ssh فقط برای همین درس و موقتی است و بعدش پاک میشود.)
چرا پوشهی secrets را 700 و فایل رمز را 644 گذاشتیم؟
فایل secret با bind mount و با همان مالک/دسترسی میزبان داخل کانتینر میآید.
مثال ۵: استقرار اول
Section titled “مثال ۵: استقرار اول”سرور به اینترنت یا registry نیازی ندارد: image ها را همانطور که در درس قبل دیدی با save | ssh load میفرستیم، و فایلهای پروژه را با scp:
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.yamlnginxsecretsنکتههای استقرار: فایل .env سرور با .env لپتاپ فرق دارد (پورت ۸۰ بهجای ۸۳۷۰)؛ رمز داخل پوشهای با دسترسی 700 (فقط root) روی سرور است؛ و کد برنامه روی سرور نمیرود (فقط image).
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 2echo "از بیرون سرور: $(curl -s localhost:8371/health)"srv 'cd /srv/notes && docker compose ps --format "{{.Service}}: {{.State}} {{.Health}}" | sort'Container notes-app-1 HealthyContainer notes-app-1 StartedContainer notes-db-1 HealthyContainer notes-db-1 StartedContainer notes-nginx-1 HealthyContainer notes-nginx-1 Startedاز بیرون سرور: {"status":"ok","version":"1.0.0"}app: running healthydb: running healthynginx: running healthyپروژه روی «سرور» بالا آمد، از بیرون با curl localhost:8371 (که به پورت ۸۰ داخل سرور میرسد) جواب میدهد، و هر سه سرویس healthy هستند.
مثال ۶: داده و بهروزرسانی بدون از دست رفتن آن
Section titled “مثال ۶: داده و بهروزرسانی بدون از دست رفتن آن”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":"یادداشت روی سرور"}'; echoecho "نسخهی فعلی: $(curl -s localhost:8371/health)"{"id":1,"text":"یادداشت روی سرور"}
نسخهی فعلی: {"status":"ok","version":"1.0.0"}حالا نسخهی ۱.۰.۱ را میسازیم (تغییر کوچک در کد)، فقط image اپ را میفرستیم، APP_TAG را عوض میکنیم و up -d میزنیم:
cd fp/notesSSHOPT="-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.pydocker build -q -t lx-fp-app:1.0.1 --build-arg APP_VERSION=1.0.1 app >/dev/nulldocker 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 2echo "نسخهی جدید: $(curl -s localhost:8371/health)"echo "دادهی قبلی: $(curl -s localhost:8371/api/notes)"ارسال شد: lx-fp-app:1.0.1Container notes-app-1 RecreateContainer notes-app-1 RecreatedContainer notes-app-1 StartedContainer notes-db-1 RunningContainer 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 قدیمی هنوز روی سرور است).
مثال ۷: بکاپ و بازیابی
Section titled “مثال ۷: بکاپ و بازیابی”cd fp/notesSSHOPT="-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 ../backupsrv 'cd /srv/notes && docker compose exec -T db pg_dump -U notes notes' > ../backup/notes.sqlecho "بکاپ روی لپتاپ: $(wc -l < ../backup/notes.sql | tr -d ' ') خط، ردیفهای notes: $(grep -c '^[0-9]' ../backup/notes.sql)"بکاپ روی لپتاپ: 95 خط، ردیفهای notes: 1بکاپ از دیتابیس زنده به لپتاپ منتقل شد (خارج از سرور، مثل آنچه در «داکر در production» گفتیم). برای تست بازیابی، آن را روی دیتابیس تازهی محلی برمیگردانیم:
cd fp/notesdocker run -d --name lx-fp-restore -e POSTGRES_PASSWORD=x -e POSTGRES_USER=notes -e POSTGRES_DB=notes postgres:16-alpine >/dev/nullfor i in $(seq 30); do docker exec lx-fp-restore pg_isready -U notes >/dev/null 2>&1 && break; sleep 1; done; sleep 2docker exec -i lx-fp-restore psql -U notes -q notes < ../backup/notes.sql >/dev/null 2>&1docker 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/null1 یادداشت بازیابی شد، آخرین: یادداشت روی سرورمثال ۸: ریبوت سرور
Section titled “مثال ۸: ریبوت سرور”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/nullfor i in $(seq 40); do docker exec lx-fp-srv docker info >/dev/null 2>&1 && break; sleep 1; donedocker 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 2donefor i in $(seq 60); do srv 'cd /srv/notes && docker compose ps --format "{{.Health}}"' | grep -q starting || break; sleep 2doneecho "بعد از ریبوت: $(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 healthydb: running healthynginx: 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 میزنی؛ بقیهی مراحل تغییری نمیکند.
جدولهای مرجع
Section titled “جدولهای مرجع”| مرحله | ابزار/دستور | درس مرتبط |
|---|---|---|
| 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 |
امنیت |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) فراموش کردن .env مخصوص سرور
Section titled “۱) فراموش کردن .env مخصوص سرور”اگر .env لپتاپ را عیناً بفرستی، پورت و tag محلی روی سرور مینشیند. راهحل: .env هر محیط جدا و خارج از Git (فقط .env.example در Git).
۲) فایل secret که کاربر برنامه نمیتواند بخواند
Section titled “۲) فایل secret که کاربر برنامه نمیتواند بخواند”فایل secret در Compose با bind mount داخل کانتینر میآید و مالک و دسترسی فایل روی میزبان همان میماند. اپ ما با کاربر 10001 اجرا میشود، پس اگر فایل روی سرور root:root و 600 باشد، اپ نمیتواند رمز را بخواند:
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 -1srv '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.
۴) تست نکردن ریبوت
Section titled “۴) تست نکردن ریبوت”سرویسی که فقط تا ریبوت اول زنده میماند، خرابی بعدی است. راهحل: مثل مرحله ۸ آزمایش کن (و systemctl enable docker).
۵) رمز در environment:
Section titled “۵) رمز در environment:”cd fp/notesecho "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 اپ شامل رمز است؟ 0env اپ فقط مسیر فایل را دارد: DB_PASSWORD_FILE=/run/secrets/db_passwordمتغیر محیطی رمز در docker inspect دیده میشد؛ اینجا فقط مسیر فایل در env است و خود رمز نه. راهحل: *_FILE و secret.
با curl یک یادداشت جدید به سرور اضافه کن و نشان بده فهرست آن را برمیگرداند.
دیدن جواب
curl -s -X POST localhost:8371/api/notes -H 'Content-Type: application/json' -d '{"text":"تمرین ساده"}' ; echocurl -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 را اجرا کن.
دیدن جواب
cd fp/notescp compose.yaml compose.extended.yamlpython3 - <<'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)LXEOFdocker compose -f compose.extended.yaml config --quiet && echo "compose.extended.yaml معتبر است"docker compose -f compose.extended.yaml up -d --wait cache >/dev/null 2>&1docker compose -f compose.extended.yaml ps --format '{{.Service}}: {{.State}} {{.Health}}' | sortdocker compose -f compose.extended.yaml rm -sf cache >/dev/null 2>&1rm compose.extended.yamlcompose.extended.yaml معتبر استapp: running healthycache: running healthydb: running healthynginx: running healthyrollback را عملی کن: روی «سرور» APP_TAG را به 1.0.0 برگردان، up -d بزن، نشان بده نسخه به ۱.۰.۰ برگشت و دادهی دیتابیس سالم است؛ سپس دوباره به 1.0.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 "$@"; }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.0echo "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.1echo "دوباره جلو: $(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چکلیست نهایی پروژه
Section titled “چکلیست نهایی پروژه”آزمونک
Section titled “آزمونک”چرا رمز دیتابیس را بهجای environment از secret فایلی میدهیم؟
POSTGRES_PASSWORD_FILE و DB_PASSWORD_FILE.
در این پروژه کدام سرویس پورت منتشر میکند؟
تنها در ورودی.
برای بهروزرسانی روی سرور چه کار میکنیم؟
db و nginx دست نمیخورند و داده میماند.
rollback چگونه است؟
image قدیمی هنوز روی سرور است.
چرا بعد از ریبوت سرویسها برگشتند؟
داده هم چون روی volume بود ماند.
بکاپ چرا به بیرون از سرور منتقل شد؟
و بازیابی هم آزمایش شد.
جمعبندی
Section titled “جمعبندی”- پروژهی کامل: 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 | تنظیم مشترک بین سرویسها |