Docker Compose ابزاری است که با آن چند کانتینر مرتبط را با یک فایل تعریف و با یک دستور اجرا می‌کنید. به‌جای این‌که برای برنامه، پایگاه‌داده و کش سه دستور طولانی docker run با ده‌ها پارامتر بنویسید و یادتان بماند کدام شبکه و کدام volume به کدام وصل است، همه را در فایلی به اسم compose.yaml می‌نویسید و با docker compose up -d کل مجموعه را بالا می‌آورید. کامپوز خودش شبکه‌ی مشترک می‌سازد، volumeها را ایجاد می‌کند و سرویس‌ها را به ترتیب درست اجرا می‌کند.

در این نوشته یک مثال واقعی می‌سازیم: یک برنامه‌ی وب کوچک همراه PostgreSQL و Redis. در همین مثال می‌بینیم volumeهای نام‌دار چطور داده را نگه می‌دارند، healthcheck چیست و چرا depends_on بدون آن کافی نیست، و دستورهای روزمره کدام‌اند، از جمله دستوری که می‌تواند کل پایگاه‌داده‌تان را پاک کند. اگر هنوز داکر نصب نکرده‌اید، اول نصب داکر روی اوبونتو ۲۴.۰۴ را ببینید؛ آن‌جا افزونه‌ی Compose هم نصب می‌شود.

docker-compose یا docker compose؟

اول این ابهام را برطرف کنیم، چون در مثال‌های اینترنت هر دو را می‌بینید:

  • docker-compose (با خط تیره) نسخه‌ی اول کامپوز بود، برنامه‌ای جدا که با پایتون نوشته شده بود. این نسخه از تابستان ۲۰۲۳ دیگر به‌روزرسانی نمی‌گیرد.
  • docker compose (با فاصله) نسخه‌ی دوم است که با Go بازنویسی شده و به شکل افزونه‌ی خود دستور docker نصب می‌شود (بسته‌ی docker-compose-plugin).

فرمت فایل تقریباً یکی است و بیشتر فایل‌های قدیمی بدون تغییر کار می‌کنند. دو تفاوت که به چشم می‌آید: اسم پیش‌فرض فایل حالا compose.yaml است (docker-compose.yml هم هنوز خوانده می‌شود)، و کلید version: در بالای فایل منسوخ شده و فقط یک هشدار تولید می‌کند؛ آن را ننویسید. در این نوشته همه جا از docker compose استفاده می‌کنیم.

مثال: برنامه‌ی وب + PostgreSQL + Redis

ساختار پوشه‌ی پروژه این است:

text
myapp/
├── compose.yaml
├── .env
├── Dockerfile
└── src/...

فرض می‌کنیم برنامه‌ی شما یک Dockerfile دارد، روی پورت ۸۰۰۰ گوش می‌دهد و یک مسیر /health دارد که اگر برنامه سالم بود کد ۲۰۰ برمی‌گرداند. زبان و فریم‌ورک مهم نیست؛ لاراول، جنگو یا Node فرقی نمی‌کند.

فایل .env

.env
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=a-long-random-password

DB_HOST=db
DB_PORT=5432
REDIS_HOST=redis

این فایل رمز دارد؛ آن را در .gitignore بگذارید و هرگز commit نکنید. یک .env.example بدون مقدارهای واقعی کنار آن نگه دارید.

فایل compose.yaml

compose.yaml
services:
  app:
    build: .
    env_file: .env
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    restart: unless-stopped

  db:
    image: postgres:17
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped

  redis:
    image: redis:7
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:

حالا بخش‌به‌بخش ببینیم هر چیز چه می‌کند.

سرویس‌ها و شبکه

هر کلید زیر services یک سرویس است و کامپوز برای هر کدام یک کانتینر می‌سازد. همه‌ی سرویس‌های یک فایل به‌طور خودکار در یک شبکه‌ی مشترک قرار می‌گیرند و اسم سرویس همان hostname آن است. برای همین در .env نوشتیم DB_HOST=db: برنامه برای رسیدن به پایگاه‌داده به آدرس db:5432 وصل می‌شود، نه localhost. این رایج‌ترین اشتباه کسانی است که تازه کامپوز را شروع می‌کنند؛ داخل کانتینر برنامه، localhost یعنی خود همان کانتینر.

دقت کنید که برای db و redis هیچ ports ننوشتیم. لازم نیست؛ برنامه از داخل شبکه به آن‌ها می‌رسد و از بیرون سرور هم کسی نمی‌تواند به آن‌ها وصل شود. برای خود برنامه هم پورت را فقط روی 127.0.0.1 منتشر کردیم. داکر برای پورت‌های منتشرشده مستقیم قانون iptables می‌سازد و ufw را دور می‌زند، پس هر پورتی که بی‌آدرس منتشر کنید روی اینترنت باز است.

volumeهای نام‌دار: داده کجا می‌ماند؟

کانتینر موقتی است. هر بار که آن را دوباره بسازید (مثلاً بعد از تغییر ایمیج)، هر چه داخلش نوشته شده بود از بین می‌رود. پایگاه‌داده نمی‌تواند این‌طور باشد؛ برای همین مسیر داده‌ی PostgreSQL را به یک volume وصل کردیم:

yaml
volumes:
  - pgdata:/var/lib/postgresql/data

pgdata یک volume نام‌دار است که در بخش volumes پایین فایل تعریف شده. داکر آن را در /var/lib/docker/volumes/ نگه می‌دارد و با پاک‌شدن کانتینر پاک نمی‌شود. اسم واقعی‌اش اسم پروژه به‌علاوه‌ی اسم volume است، مثلاً myapp_pgdata:

bash
docker volume ls
docker volume inspect myapp_pgdata

نوع دیگر، bind mount است که یک پوشه‌ی مشخص از میزبان را داخل کانتینر می‌گذارد (./src:/app). bind mount برای کد در محیط توسعه خوب است، چون تغییرات فوراً دیده می‌شوند. برای داده‌ی پایگاه‌داده، volume نام‌دار انتخاب بهتری است: مسئله‌ی مالکیت و دسترسی فایل‌ها پیش نمی‌آید و داکر خودش مدیریتش می‌کند.

یک نکته درباره‌ی نسخه: از PostgreSQL 18 به بعد، ایمیج رسمی مسیر داده را عوض کرده و volume باید به /var/lib/postgresql وصل شود. برای همین در مثال نسخه را صریح postgres:17 نوشتیم. همیشه نسخه‌ی اصلی را pin کنید و از latest برای پایگاه‌داده استفاده نکنید؛ ارتقای نسخه‌ی اصلی PostgreSQL مهاجرت داده لازم دارد و نباید با یک pull بی‌خبر اتفاق بیفتد.

و یادتان باشد: volume پشتیبان نیست. اگر دیسک خراب شود یا volume پاک شود، داده رفته است. از پایگاه‌داده جداگانه dump بگیرید:

bash
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip > backup-$(date +%F).sql.gz

این دستور را می‌شود با cron زمان‌بندی کرد (کرون جاب در لینوکس) و نسخه‌ها را طبق قاعده‌ی ۳-۲-۱ جای دیگری هم نگه داشت.

env_file و .env: دو کار متفاوت

اینجا دو مفهوم شبیه هم هست که بهتر است قاطی نشوند:

  1. env_file: .env در تعریف سرویس، متغیرها را داخل کانتینر می‌گذارد. PostgreSQL از روی POSTGRES_USER و POSTGRES_PASSWORD کاربر و رمز را می‌سازد و برنامه از روی DB_HOST آدرس پایگاه‌داده را می‌خواند.
  2. فایلی به اسم .env کنار compose.yaml، جدا از این، برای جای‌گذاری متغیر در خود فایل کامپوز هم خوانده می‌شود. یعنی اگر در compose.yaml بنویسید image: postgres:${PG_VERSION}، مقدار از .env می‌آید.

برای همین در healthcheck نوشتیم $${POSTGRES_USER} با دو علامت دلار. $$ یعنی «این را جای‌گذاری نکن»، تا متغیر دست‌نخورده به شل داخل کانتینر برسد و آن‌جا مقدار بگیرد.

healthcheck و depends_on

depends_on به‌تنهایی فقط ترتیب شروع را تعیین می‌کند: کانتینر db اول ساخته می‌شود و بعد app. ولی «کانتینر شروع شد» با «PostgreSQL آماده‌ی پذیرش اتصال است» فرق دارد. PostgreSQL در اجرای اول چند ثانیه وقت می‌گذارد تا پوشه‌ی داده را بسازد، و برنامه‌ای که در این فاصله وصل شود خطا می‌گیرد.

راه‌حل همین دو بخش است که با هم کار می‌کنند:

  • healthcheck دستوری است که داکر مرتب داخل کانتینر اجرا می‌کند. برای PostgreSQL، pg_isready و برای Redis، redis-cli ping. اگر دستور با کد صفر تمام شود، کانتینر healthy است.
  • condition: service_healthy در depends_on به کامپوز می‌گوید app را تا وقتی db و redis سالم نشده‌اند اجرا نکن.

پارامترهای healthcheck:

پارامتر معنی
interval هر چند وقت یک بار بررسی شود
timeout اگر دستور بیشتر از این طول کشید، شکست حساب شود
retries بعد از چند شکست پشت سر هم، کانتینر unhealthy شود
start_period مهلت اولیه که در آن شکست‌ها شمرده نمی‌شوند

healthcheck خود app به curl داخل ایمیج نیاز دارد. اگر ایمیج شما curl ندارد (مثلاً ایمیج‌های slim یا distroless)، یا آن را در Dockerfile نصب کنید یا دستور دیگری بگذارید که در ایمیج موجود است.

وضعیت سلامت را در خروجی docker compose ps می‌بینید. یک نکته‌ی مهم: داکر کانتینر unhealthy را خودبه‌خود ری‌استارت نمی‌کند؛ healthcheck فقط وضعیت را گزارش می‌دهد و برای depends_on استفاده می‌شود.

restart policy

restart: unless-stopped یعنی اگر کانتینر از کار افتاد یا سرور ری‌استارت شد، داکر دوباره اجرایش کند، مگر این‌که خودتان آن را دستی متوقف کرده باشید. گزینه‌های دیگر no (پیش‌فرض)، always و on-failure هستند. برای سرویس‌های همیشه‌روشن روی سرور، unless-stopped معمولاً انتخاب درستی است. شرطش این است که خود سرویس داکر روی بوت فعال باشد، که روی اوبونتو به‌طور پیش‌فرض هست.

دستورهای روزمره

همه‌ی این دستورها را در پوشه‌ای که compose.yaml در آن است اجرا کنید:

bash
docker compose up -d            # ساختن و اجرای همه‌ی سرویس‌ها در پس‌زمینه
docker compose up -d --build    # بعد از تغییر کد یا Dockerfile، ایمیج را دوباره بساز
docker compose ps               # وضعیت سرویس‌ها و سلامتشان
docker compose logs -f app      # دنبال‌کردن زنده‌ی لاگ یک سرویس
docker compose logs --tail=100  # صد خط آخر لاگ همه‌ی سرویس‌ها
docker compose exec db psql -U myapp myapp   # اجرای دستور داخل کانتینر در حال اجرا
docker compose exec app sh      # شل داخل کانتینر برنامه
docker compose restart app      # ری‌استارت یک سرویس
docker compose pull             # گرفتن نسخه‌ی تازه‌ی ایمیج‌ها
docker compose config           # فایل نهایی بعد از جای‌گذاری متغیرها؛ برای پیدا کردن خطا
docker compose down             # توقف و حذف کانتینرها و شبکه

فرق stop و down: stop فقط کانتینرها را متوقف می‌کند و آن‌ها سر جایشان می‌مانند؛ down آن‌ها را حذف می‌کند و شبکه را هم برمی‌دارد. در هر دو حالت volumeها دست‌نخورده می‌مانند و داده از بین نمی‌رود.

خطر docker compose down -v

bash
docker compose down -v

پرچم -v volumeهای نام‌دار پروژه را هم پاک می‌کند. یعنی pgdata و هر چه در پایگاه‌داده بود، بدون هیچ پرسشی از بین می‌رود. در محیط توسعه، وقتی می‌خواهید از صفر شروع کنید، دستور مفیدی است. روی سرور واقعی، تقریباً هیچ‌وقت آن را لازم ندارید. اگر لازم شد، اول مطمئن شوید پشتیبان سالم و تازه دارید.

یک چک‌لیست کوتاه برای کامپوز روی سرور

  • نسخه‌ی ایمیج‌ها را pin کنید (postgres:17، نه postgres یا latest).
  • فقط پورت‌هایی را منتشر کنید که واقعاً از بیرون لازم‌اند، و تا جای ممکن روی 127.0.0.1.
  • رمزها در .env باشند و .env در git نباشد.
  • برای هر سرویسی که داده دارد volume نام‌دار تعریف کنید و جداگانه از آن پشتیبان بگیرید.
  • برای پایگاه‌داده و کش healthcheck بگذارید و در depends_on از service_healthy استفاده کنید.
  • restart: unless-stopped را فراموش نکنید.

جمع‌بندی

Docker Compose یعنی کل محیط برنامه در یک فایل خوانا که می‌شود آن را در git نگه داشت، بررسی کرد و روی هر سرور دیگری دوباره ساخت. در مثال این نوشته، برنامه از روی اسم سرویس به پایگاه‌داده و Redis وصل می‌شود، داده در volumeهای نام‌دار می‌ماند، و healthcheck همراه depends_on باعث می‌شود برنامه پیش از آماده‌شدن پایگاه‌داده اجرا نشود. دستورهای روزمره همین چند تا هستند: up -d، ps، logs -f، exec و down. فقط پرچم -v را جدی بگیرید و پیش از زدنش یک بار دیگر فکر کنید.