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

Nginx در داکر و Compose

توی این درس یاد می‌گیری Nginx را به‌جای نصب روی سرور، با ایمیج رسمی داکر (nginx:alpine) اجرا کنی و جلوی سرویس‌های دیگر یک پروژه‌ی Compose بگذاری. تنظیمات و فایل‌های سایت را mount می‌کنی (و می‌بینی چرا mount یک فایل تنها دردسر می‌سازد)، سرویس‌ها را با اسمشان صدا می‌زنی (proxy_pass http://api:8080)، تنظیمات را بدون ریستارت با docker compose exec nginx nginx -s reload اعمال می‌کنی، و دو خطای معروف داکری را از نزدیک می‌بینی: host not found in upstream و 502 بعد از عوض شدن IP یک کانتینر.

مسئله: همه‌چیز در داکر است، به‌جز Nginx

Section titled “مسئله: همه‌چیز در داکر است، به‌جز Nginx”

اگر دوره‌ی داکر را دیده باشی، اپ‌هایت احتمالاً در کانتینرند: API، فرانت، دیتابیس، همه با یک compose.yaml. حالا Nginx را کجا بگذاری؟

  • روی خود سرور (مثل درس‌های قبل): Nginx باید به پورت‌هایی که کانتینرها روی میزبان باز کرده‌اند وصل شود؛ پس هر سرویس یک پورت روی میزبان لازم دارد، و نسخه و تنظیمات Nginx جدا از پروژه مدیریت می‌شود.
  • داخل همان Compose: Nginx یک سرویس کنار بقیه است، سرویس‌ها را با اسم صدا می‌زند، فقط خودش پورت به بیرون دارد، و کل پروژه (با تنظیمات Nginx) با یک docker compose up روی هر ماشینی بالا می‌آید.

این درس راه دوم است. همه‌ی چیزهایی که درباره‌ی تنظیمات Nginx یاد گرفتی همان است؛ فقط چند تفاوت داکری دارد که اگر ندانی، ساعت‌ها وقتت را می‌گیرد.

تشبیه: پذیرش یک ساختمان اداری

Section titled “تشبیه: پذیرش یک ساختمان اداری”

Compose مثل یک ساختمان اداری است و هر سرویس یک دفتر با اسم روی در (web، api). Nginx میز پذیرش طبقه‌ی همکف است: تنها در ورودی ساختمان (تنها پورت باز به بیرون). مراجع می‌گوید «با حسابداری کار دارم» و پذیرش او را به دفتر api می‌فرستد. پذیرش برای پیدا کردن دفترها از راهنمای ساختمان (DNS داخلی داکر) استفاده می‌کند. مشکل وقتی است که پذیرش شماره‌ی اتاق را صبح یک بار از راهنما بپرسد و روی کاغذ بنویسد؛ اگر حسابداری ظهر به اتاق دیگری برود، پذیرش مراجع‌ها را به اتاق قدیمی می‌فرستد.

فقط کانتینر nginx پورت روی میزبان دارد (127.0.0.1:8099). داخل شبکه‌ی Compose، nginx سرویس‌ها را با اسمشان (web و api) پیدا می‌کند؛ DNS داخلی داکر اسم را به IP کانتینر تبدیل می‌کند.

ایمیج رسمی Nginx در یک نگاه

Section titled “ایمیج رسمی Nginx در یک نگاه”

قبل از ساختن، ببینیم داخل nginx:alpine چه خبر است:

Terminal window
docker run --rm nginx:alpine nginx -v
docker run --rm nginx:alpine sh -c 'ls /etc/nginx/conf.d; grep -n "include\|error_log\|access_log" /etc/nginx/nginx.conf; ls -l /var/log/nginx'
خروجی
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: ipv6 not available
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
nginx version: nginx/1.31.6
default.conf
5:error_log /var/log/nginx/error.log notice;
15: include /etc/nginx/mime.types;
22: access_log /var/log/nginx/access.log main;
31: include /etc/nginx/conf.d/*.conf;
total 0
lrwxrwxrwx 1 root root 11 Sep 22 21:19 access.log -> /dev/stdout
lrwxrwxrwx 1 root root 11 Sep 22 21:19 error.log -> /dev/stderr

چند چیز مهم:

  • اسکریپت‌های شروع: هر بار که کانتینر با دستور nginx شروع می‌شود، docker-entrypoint.sh اسکریپت‌های پوشه‌ی /docker-entrypoint.d/ را اجرا می‌کند: فعال کردن IPv6 (اینجا نبود)، پردازش قالب‌ها با envsubst (مثال ۷) و تنظیم تعداد workerها. خط‌های بالای خروجی همین‌ها هستند.
  • نسخه: nginx/1.31.6، خیلی جدیدتر از ۱.۲۴ Ubuntu. یکی از مزیت‌های ایمیج رسمی همین است.
  • include /etc/nginx/conf.d/*.conf;: این ایمیج sites-available و sites-enabled ندارد. تنظیمات سایت‌ها در conf.d است که فعلاً فقط default.conf دارد.
  • لاگ‌ها: access.log و error.log در واقع لینک به /dev/stdout و /dev/stderr هستند. پس Nginx در فایل نمی‌نویسد و همه‌چیز با docker logs دیده می‌شود (روش استاندارد داکر).

این درس روی خود داکر اجرا شده (Docker Compose v5، ایمیج nginx:alpine). همه‌ی پورت‌ها فقط روی 127.0.0.1 باز شده‌اند و کانتینرها پیشوند lx- دارند.

مثال ۱: یک سایت استاتیک با mount تنظیمات

Section titled “مثال ۱: یک سایت استاتیک با mount تنظیمات”

یک پوشه برای سایت و یک پوشه برای تنظیمات Nginx:

site/index.html
<h1>Hello from Nginx in Docker</h1>
conf/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ =404;
}
}
Terminal window
docker run -d --name lx-ngx -p 127.0.0.1:8099:80 \
-v ./conf:/etc/nginx/conf.d:ro \
-v ./site:/usr/share/nginx/html:ro \
nginx:alpine
sleep 1
curl -s http://127.0.0.1:8099/
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8099/nope
docker logs lx-ngx 2>&1 | tail -n 2
خروجی
22f0707ce013dbd9d8aec4a519ca77b12c1329a3dfdafecd69477b7b4fdb6a18
<h1>Hello from Nginx in Docker</h1>
404
172.17.0.1 - - [04/Oct/2026:14:23:44 +0000] "GET / HTTP/1.1" 200 36 "-" "curl/8.5.0" "-"
172.17.0.1 - - [04/Oct/2026:14:23:44 +0000] "GET /nope HTTP/1.1" 404 153 "-" "curl/8.5.0" "-"
  • -v ./conf:/etc/nginx/conf.d:ro کل پوشه‌ی conf ما را جای conf.d ایمیج گذاشت (پس default.conf خود ایمیج کنار رفت و مال ما جایش نشست). :ro یعنی فقط خواندنی؛ کانتینر نمی‌تواند تنظیمات را عوض کند.
  • -v ./site:/usr/share/nginx/html:ro فایل‌های سایت.
  • -p 127.0.0.1:8099:80 پورت ۸۰ کانتینر را فقط روی 127.0.0.1:8099 میزبان باز کرد.
  • خط اول خروجی شناسه‌ی کانتینر است. صفحه آمد، /nope 404 شد، و docker logs هر دو درخواست را نشان داد. IP کاربر 172.17.0.1 است: درگاه شبکه‌ی پیش‌فرض داکر، چون درخواست از میزبان آمده و از NAT داکر رد شده.

مثال ۲: تست و reload داخل کانتینر

Section titled “مثال ۲: تست و reload داخل کانتینر”

تنظیمات را روی میزبان عوض می‌کنیم (یک هدر اضافه)، داخل کانتینر تستش می‌کنیم و reload می‌کنیم:

Terminal window
sed -i 's|^ root /usr/share/nginx/html;| root /usr/share/nginx/html;\n add_header X-Served-By docker-nginx always;|' conf/default.conf
docker exec lx-ngx nginx -t
docker exec lx-ngx nginx -s reload
sleep 1
curl -s -I http://127.0.0.1:8099/ | grep -i '^x-served-by'
docker exec lx-ngx ps -o pid,args | grep -v ps
خروجی
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/10/04 14:23:44 [notice] 30#30: signal process started
X-Served-By: docker-nginx
PID COMMAND
1 nginx: master process nginx -g daemon off;
36 nginx: worker process
37 nginx: worker process
38 nginx: worker process
39 nginx: worker process
  • docker exec lx-ngx nginx -t همان nginx -t همیشگی است، ولی داخل کانتینر و با فایل‌هایی که کانتینر می‌بیند. (فایل را روی میزبان عوض کردیم؛ چون پوشه mount شده، کانتینر فوراً نسخه‌ی جدید را می‌بیند.)
  • nginx -s reload به پروسه‌ی master سیگنال reload می‌فرستد (همان کار systemctl reload روی سرور). هدر جدید آمد.
  • ps: در کانتینر، master Nginx PID 1 است (با daemon off;، یعنی در پیش‌زمینه می‌ماند تا کانتینر زنده بماند) و ۴ worker دارد. نه systemd هست و نه systemctl؛ مدیریت با docker و سیگنال‌های خود Nginx است.

مثال ۳: Nginx جلوی دو سرویس Compose

Section titled “مثال ۳: Nginx جلوی دو سرویس Compose”

حالا تمرین اصلی درس: یک پروژه‌ی Compose با سه سرویس. web (فایل‌های استاتیک، با همان ایمیج nginx:alpine) و api (یک برنامه‌ی کوچک Go که JSON برمی‌گرداند)، و جلوی هر دو، nginx.

API (اگر Go بلد نیستی، نگران نباش: فقط اسم کانتینر و مسیر درخواست را به‌صورت JSON برمی‌گرداند):

api/main.go
// tiny JSON API: reports which container answered
package main
import (
"encoding/json"
"net/http"
"os"
)
func main() {
host, _ := os.Hostname()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"service": "api", "container": host, "path": r.URL.Path,
"real_ip": r.Header.Get("X-Real-IP"),
})
})
http.ListenAndServe(":8080", nil)
}
api/Dockerfile
# build stage: compile a static binary
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY main.go .
RUN CGO_ENABLED=0 go build -o /api main.go
# run stage: just the binary
FROM scratch
COPY --from=build /api /api
EXPOSE 8080
ENTRYPOINT ["/api"]

تنظیمات Nginx. به‌جای IP، اسم سرویس‌ها:

nginx/default.conf
server {
listen 80;
server_name _;
location / {
proxy_pass http://web:80;
}
location /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
compose.yaml
services:
nginx:
image: nginx:alpine
ports:
- "127.0.0.1:8099:80"
volumes:
- ./nginx:/etc/nginx/conf.d:ro
depends_on:
- web
- api
web:
image: nginx:alpine
volumes:
- ./site:/usr/share/nginx/html:ro
api:
build: ./api
image: lx-nd-api
Terminal window
docker rm -f lx-ngx > /dev/null
docker compose -p lx-nd build --quiet api
docker compose -p lx-nd up -d 2> /dev/null
sleep 1
docker compose -p lx-nd ps --format '{{.Service}}\t{{.Status}}\t{{.Ports}}'
docker images lx-nd-api --format '{{.Repository}} {{.Size}}'
echo "=== /"
curl -s http://127.0.0.1:8099/
echo "=== /api/users"
curl -s http://127.0.0.1:8099/api/users
خروجی
Image lx-nd-api Building
Image lx-nd-api Built
api Up 1 second 8080/tcp
nginx Up 1 second 127.0.0.1:8099->80/tcp
web Up 1 second 80/tcp
lx-nd-api 13.2MB
=== /
<h1>Hello from Nginx in Docker</h1>
=== /api/users
{"container":"a53aa58b7c63","path":"/api/users","real_ip":"172.18.0.1","service":"api"}
  • فقط nginx پورت روی میزبان دارد (127.0.0.1:8099->80/tcp). web و api فقط داخل شبکه‌ی Compose در دسترس‌اند (80/tcp و 8080/tcp بدون فلش). یعنی API مستقیم از اینترنت قابل دسترس نیست؛ همه از Nginx رد می‌شوند.
  • ایمیج API فقط ۱۳٫۲ مگابایت است: build چندمرحله‌ای (درس multi-stage در دوره‌ی داکر) و FROM scratch (هیچ سیستم‌عاملی، فقط فایل اجرایی Go).
  • / به web رفت و /api/users به api. در proxy_pass http://api:8080;، api اسم سرویس در compose.yaml است؛ Compose یک شبکه برای پروژه می‌سازد و داخل آن اسم هر سرویس به IP کانتینرش ترجمه می‌شود.
  • API هدر X-Real-IP را 172.18.0.1 دید: درگاه شبکه‌ی Compose (این بار شبکه‌ی پروژه 172.18.0.0/16 است، نه شبکه‌ی پیش‌فرض).

مثال ۴: docker compose exec nginx nginx -s reload

Section titled “مثال ۴: docker compose exec nginx nginx -s reload”

یک تغییر در تنظیمات: پاسخ‌های API نباید کش شوند، و یک location برای سلامت (/healthz) که خود Nginx جواب بدهد. بعد reload، بدون ریستارت کانتینر:

Terminal window
sed -i 's|^ location /api/ {| location = /healthz {\n default_type text/plain;\n return 200 "ok\\n";\n }\n\n location /api/ {\n add_header Cache-Control "no-store" always;|' nginx/default.conf
started=$(docker inspect -f '{{.State.StartedAt}}' lx-nd-nginx-1)
docker compose -p lx-nd exec nginx nginx -t
docker compose -p lx-nd exec nginx nginx -s reload
sleep 1
curl -s http://127.0.0.1:8099/healthz
curl -s -I http://127.0.0.1:8099/api/x | grep -i '^cache-control'
[ "$started" = "$(docker inspect -f '{{.State.StartedAt}}' lx-nd-nginx-1)" ] && echo "same container, not restarted"
خروجی
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/10/04 14:23:48 [notice] 30#30: signal process started
ok
Cache-Control: no-store
same container, not restarted

این همان دستور اصلی درس است: docker compose exec nginx nginx -s reload. exec یک دستور را داخل کانتینرِ در حال اجرای سرویس nginx اجرا می‌کند. location /healthz و هدر Cache-Control اعمال شدند، و زمان شروع کانتینر (StartedAt) عوض نشد: کانتینر ریستارت نشده و هیچ اتصالی قطع نشده. (اگر docker compose restart nginx می‌زدی، کانتینر کامل متوقف و دوباره اجرا می‌شد و درخواست‌های در جریان قطع می‌شدند.)

مثال ۵: IP کانتینر عوض شد، Nginx خبر نداشت

Section titled “مثال ۵: IP کانتینر عوض شد، Nginx خبر نداشت”

حالا سناریوی اول درس را بازسازی کنیم. API را متوقف می‌کنیم، یک کانتینر دیگر در همان شبکه بالا می‌آید (و IP آزاد شده را می‌گیرد)، و API دوباره شروع می‌شود؛ با یک IP جدید:

Terminal window
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
echo "api before: $(ip lx-nd-api-1)"
docker compose -p lx-nd stop api 2> /dev/null
docker compose -p lx-nd run -d --rm --name lx-nd-tmp web > /dev/null 2>&1
docker compose -p lx-nd start api 2> /dev/null
echo "api after: $(ip lx-nd-api-1) (old IP now belongs to lx-nd-tmp: $(ip lx-nd-tmp))"
docker compose -p lx-nd exec nginx getent hosts api
curl -s -o /dev/null -w 'GET /api/users -> %{http_code}\n' http://127.0.0.1:8099/api/users
docker compose -p lx-nd logs nginx 2>&1 | grep -o 'connect() failed.*upstream: "[^"]*"' | tail -n 1 | sed 's/ while connecting.*upstream:/ ... upstream:/'
خروجی
api before: 172.18.0.2
api after: 172.18.0.5 (old IP now belongs to lx-nd-tmp: 172.18.0.2)
172.18.0.5 api api
GET /api/users -> 502
connect() failed (111: Connection refused) ... upstream: "http://172.18.0.2:8080/api/users"

خط‌به‌خط:

  • API از IP قبلی به IP جدید رفت، و IP قبلی را حالا یک کانتینر دیگر (lx-nd-tmp، یک نسخه‌ی web) دارد.
  • getent hosts api داخل کانتینر Nginx: DNS داکر درست جواب می‌دهد و IP جدید را می‌گوید.
  • ولی Nginx 502 داد و لاگش می‌گوید به IP قدیمی وصل شده (و آنجا کسی روی ۸۰۸۰ گوش نمی‌داد: Connection refused).

چرا؟ Nginx اسم‌هایی که در proxy_pass (یا server یک upstream) نوشته شده‌اند را فقط یک بار، موقع شروع یا reload، resolve می‌کند و IP را تا reload بعدی نگه می‌دارد. این همان معمای سناریوی اول درس است: بعد از دیپلوی API، کانتینر جدید IP جدید گرفته بود. یک nginx -s reload درستش می‌کرد، چون اسم را دوباره resolve می‌کرد. بدتر از 502 هم ممکن است: اگر کانتینر صاحب IP قدیمی روی همان پورت گوش بدهد، درخواست‌ها به سرویس اشتباه می‌روند.

مثال ۶: راه‌حل، resolver و resolve

Section titled “مثال ۶: راه‌حل، resolver و resolve”

راه‌حل این است که Nginx اسم را هر چند ثانیه دوباره از DNS داکر بپرسد. همان کاری که در درس load balancing برای scale کردن کردیم: resolver 127.0.0.11 (آدرس DNS داخلی داکر در هر کانتینر) و یک upstream با پارامتر resolve (Nginx ۱.۲۷.۳ به بعد؛ ایمیج ما ۱.۳۱ است):

Terminal window
cat > nginx/default.conf <<'EOF'
# Docker's embedded DNS; re-resolve names every 5s
resolver 127.0.0.11 valid=5s;
upstream api {
zone api 64k;
server api:8080 resolve;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://web:80;
}
location /api/ {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
docker compose -p lx-nd exec nginx nginx -t 2>&1 | tail -n 1
docker compose -p lx-nd exec nginx nginx -s reload
sleep 1
curl -s -o /dev/null -w 'after reload: %{http_code}\n' http://127.0.0.1:8099/api/users
echo "=== move api to a new IP again:"
echo "api before: $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lx-nd-api-1)"
docker compose -p lx-nd stop api 2> /dev/null
docker compose -p lx-nd run -d --rm --name lx-nd-tmp2 web > /dev/null 2>&1
docker compose -p lx-nd start api 2> /dev/null
echo "api after: $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lx-nd-api-1)"
curl -s -o /dev/null -w 'no reload, right away: %{http_code}\n' http://127.0.0.1:8099/api/users
sleep 6
curl -s -o /dev/null -w 'no reload, 6s later: %{http_code}\n' http://127.0.0.1:8099/api/users
docker rm -f lx-nd-tmp lx-nd-tmp2 > /dev/null
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/10/04 14:23:50 [notice] 52#52: signal process started
after reload: 200
=== move api to a new IP again:
api before: 172.18.0.5
api after: 172.18.0.6
no reload, right away: 502
no reload, 6s later: 200
  • بعد از reload (با اسم دوباره resolve شد) API دوباره 200 داد.
  • API را دوباره جابه‌جا کردیم (IP جدید). بلافاصله هنوز 502، چون Nginx تا ۵ ثانیه (valid=5s) جواب قبلی DNS را معتبر می‌داند.
  • ۶ ثانیه بعد، بدون هیچ reload ای: 200. Nginx اسم را دوباره پرسید و عضو upstream را به‌روز کرد.

valid را کوتاه بگذار (چند ثانیه)؛ پرسیدن از DNS محلی داکر تقریباً هزینه‌ای ندارد. در Nginx قدیمی‌تر از ۱.۲۷.۳ (مثل ۱.۲۴ Ubuntu) resolve نیست. آنجا ترفند رایج این است که اسم را در یک متغیر بگذاری (set $api http://api:8080; proxy_pass $api;)؛ وقتی proxy_pass متغیر دارد، Nginx در هر درخواست با resolver اسم را resolve می‌کند. (ولی دقت کن: با متغیر، رفتار مسیر هم فرق می‌کند و upstream و keepalive هم از دست می‌رود.)

مثال ۷: قالب‌ها با متغیر محیطی (envsubst)

Section titled “مثال ۷: قالب‌ها با متغیر محیطی (envsubst)”

گاهی یک تنظیم باید در محیط‌های مختلف فرق کند (مثلاً اسم دامنه، یا آدرس API). ایمیج رسمی Nginx یک امکان داخلی دارد: هر فایل *.template در /etc/nginx/templates/، موقع شروع کانتینر با envsubst پردازش می‌شود (متغیرهای ${NAME} با مقدار متغیر محیطی جایگزین می‌شوند) و نتیجه در /etc/nginx/conf.d/ نوشته می‌شود:

templates/default.conf.template
server {
listen 80;
server_name ${SITE_NAME};
location / {
default_type text/plain;
return 200 "site: ${SITE_NAME}, env: ${APP_ENV}\n";
}
}
Terminal window
docker run -d --name lx-ngx-tpl -p 127.0.0.1:8098:80 \
-e SITE_NAME=shop.example -e APP_ENV=staging \
-v ./templates:/etc/nginx/templates:ro \
nginx:alpine
sleep 1
curl -s http://127.0.0.1:8098/
docker logs lx-ngx-tpl 2>&1 | grep -i template
docker exec lx-ngx-tpl head -n 3 /etc/nginx/conf.d/default.conf
docker rm -f lx-ngx-tpl > /dev/null
خروجی
e77d116ceaf248b9c9077d9ff2cdeabed31f7435779066747ce3758d5fb7e7c1
site: shop.example, env: staging
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/default.conf.template to /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name shop.example;
  • -e SITE_NAME=... -e APP_ENV=... متغیرهای محیطی کانتینر.
  • اسکریپت 20-envsubst-on-templates.sh (همان که در اول درس دیدیم) فایل default.conf.template را خواند، ${SITE_NAME} و ${APP_ENV} را با مقدارشان جایگزین کرد و نتیجه را در /etc/nginx/conf.d/default.conf نوشت.
  • پس یک فایل تنظیمات داری که در staging و production با متغیرهای مختلف اجرا می‌شود.

دو نکته: ۱) این پردازش فقط موقع شروع کانتینر است؛ تغییر متغیر یعنی ساختن دوباره‌ی کانتینر (docker compose up -d)، نه reload. ۲) متغیرهای خود Nginx (مثل $host و $remote_addr) هم شبیه همین‌اند! envsubst فقط متغیرهایی را جایگزین می‌کند که در محیط کانتینر تعریف شده‌اند، پس $host دست‌نخورده می‌ماند؛ ولی اگر روزی متغیر محیطی‌ای به اسم host تعریف کنی، تنظیمت خراب می‌شود. (با متغیر NGINX_ENVSUBST_FILTER می‌شود فقط متغیرهای با پیشوند خاص را جایگزین کرد.)

پشت پرده: DNS داکر و «یک بار resolve»

Section titled “پشت پرده: DNS داکر و «یک بار resolve»”

هر کانتینر در یک شبکه‌ی Compose، به‌جای DNS میزبان از سرور DNS داخلی داکر روی آدرس جادویی 127.0.0.11 استفاده می‌کند. این سرور اسم سرویس‌ها (و اسم کانتینرها) را به IP های فعلی‌شان ترجمه می‌کند:

Terminal window
docker compose -p lx-nd exec nginx cat /etc/resolv.conf | grep -v '^#'
docker compose -p lx-nd exec nginx getent hosts web api
docker compose -p lx-nd exec nginx nginx -T 2> /dev/null | grep -n 'resolver\|server api'
خروجی
nameserver 127.0.0.11
options timeout:2 attempts:3 ndots:0
172.18.0.3 web web
172.18.0.6 api api
138:resolver 127.0.0.11 valid=5s;
142: server api:8080 resolve;
  • /etc/resolv.conf کانتینر: nameserver 127.0.0.11. این آدرس، سرور DNS داخلی داکر است که داخل هر کانتینرِ یک شبکه‌ی سفارشی (مثل شبکه‌ی Compose) در دسترس است. (در شبکه‌ی پیش‌فرض bridge، که docker run ساده به آن وصل می‌شود، اسم‌گذاری سرویس‌ها کار نمی‌کند؛ برای همین Compose برای هر پروژه شبکه‌ی جدا می‌سازد.)
  • getent hosts هر دو سرویس را با IP فعلی‌شان نشان می‌دهد.
  • nginx -T کل تنظیمات نهایی را (بعد از همه‌ی include ها) چاپ می‌کند؛ با grep دیدیم resolver و server api:8080 resolve واقعاً در تنظیمات در حال اجرا هستند.

خلاصه‌ی رفتار Nginx با اسم‌ها:

نوشته کی resolve می‌شود اگر موقع شروع اسم نباشد
proxy_pass http://api:8080; یک بار، موقع شروع یا reload Nginx شروع نمی‌شود (host not found in upstream)
upstream { server api:8080; } یک بار، موقع شروع یا reload Nginx شروع نمی‌شود
upstream { zone ...; server api:8080 resolve; } + resolver هر valid ثانیه شروع می‌شود؛ تا آمدن سرویس ۵۰۲
set $u http://api:8080; proxy_pass $u; + resolver در هر درخواست (با کش valid) شروع می‌شود؛ تا آمدن سرویس ۵۰۲

مسیرهای مهم در ایمیج رسمی:

مسیر چیست
/etc/nginx/nginx.conf تنظیم اصلی (include /etc/nginx/conf.d/*.conf;)
/etc/nginx/conf.d/default.conf سایت پیش‌فرض؛ معمولاً کل پوشه را با تنظیمات خودت mount می‌کنی
/etc/nginx/templates/*.template قالب‌های envsubst، نتیجه در conf.d
/usr/share/nginx/html ریشه‌ی سایت پیش‌فرض
/var/log/nginx/access.log و error.log لینک به /dev/stdout و /dev/stderr (پس docker logs)

دستورها:

دستور کار
docker compose exec nginx nginx -t تست تنظیمات داخل کانتینر
docker compose exec nginx nginx -s reload اعمال تنظیمات بدون ریستارت
docker compose exec nginx nginx -T چاپ کل تنظیمات نهایی (با همه‌ی include ها)
docker compose logs -f nginx دنبال کردن لاگ‌ها
docker compose exec nginx getent hosts api IP فعلی یک سرویس از دید Nginx
docker compose restart nginx ریستارت کامل (اتصال‌های جاری قطع می‌شوند)

۱) mount یک فایل تنها، و ویرایش با sed -i

Section titled “۱) mount یک فایل تنها، و ویرایش با sed -i”

اگر به‌جای پوشه، یک فایل را mount کنی، داکر در واقع آن inode (شناسه‌ی فایل روی دیسک) را به کانتینر وصل می‌کند. خیلی از ویرایشگرها و sed -i فایل را عوض نمی‌کنند، بلکه یک فایل جدید با همان اسم می‌سازند:

Terminal window
echo 'version one' > single.conf
docker run -d --name lx-ngx-file -v ./single.conf:/etc/single.conf:ro nginx:alpine > /dev/null
docker exec lx-ngx-file cat /etc/single.conf
ls -i single.conf | cut -d' ' -f1
sed -i 's/one/two/' single.conf
ls -i single.conf | cut -d' ' -f1
cat single.conf
docker exec lx-ngx-file cat /etc/single.conf
docker rm -f lx-ngx-file > /dev/null
خروجی
version one
1925413
1925405
version two
version one

sed -i یک فایل جدید ساخت (دو عدد وسط خروجی، شماره‌ی inode قبل و بعد از sed -i هستند و فرق دارند) و قدیمی را پاک کرد. میزبان version two را می‌بیند، ولی کانتینر هنوز به inode قدیمی وصل است و version one را می‌بیند؛ و هیچ reload ای درستش نمی‌کند، چون از دید کانتینر فایل اصلاً عوض نشده. فقط ساختن دوباره‌ی کانتینر (یا ریستارتش) mount را تازه می‌کند. خیلی از ویرایشگرها (مثل vim با تنظیمات پیش‌فرض بعضی سیستم‌ها) هم همین کار را می‌کنند. راه‌حل: همیشه پوشه را mount کن (مثل ./nginx:/etc/nginx/conf.d)، نه فایل تنها.

Terminal window
docker compose -p lx-nd stop api 2> /dev/null
docker compose -p lx-nd rm -f api > /dev/null 2>&1
mkdir -p old-style
printf 'server {\n listen 80;\n location / { proxy_pass http://api:8080; }\n}\n' > old-style/default.conf
docker run -d --name lx-ngx-old --network lx-nd_default -v ./old-style:/etc/nginx/conf.d:ro nginx:alpine > /dev/null
sleep 1
docker ps -a --filter name=lx-ngx-old --format '{{.Names}}: {{.Status}}'
docker logs lx-ngx-old 2>&1 | grep emerg | tail -n 1
docker rm -f lx-ngx-old > /dev/null
echo "=== with resolve (our nginx service), api still gone:"
curl -s -o /dev/null -w '/api/ -> %{http_code}, ' http://127.0.0.1:8099/api/users
curl -s -o /dev/null -w '/ -> %{http_code}\n' http://127.0.0.1:8099/
docker compose -p lx-nd up -d api 2> /dev/null
sleep 6
curl -s -o /dev/null -w 'api back: /api/ -> %{http_code}\n' http://127.0.0.1:8099/api/users
خروجی
lx-ngx-old: Exited (1) 1 second ago
nginx: [emerg] host not found in upstream "api" in /etc/nginx/conf.d/default.conf:3
=== with resolve (our nginx service), api still gone:
/api/ -> 502, / -> 200
api back: /api/ -> 200

API را کامل حذف کردیم و یک Nginx با تنظیم «قدیمی» (proxy_pass http://api:8080; بدون resolve) در همان شبکه اجرا کردیم: Exited (1) با پیام host not found in upstream "api". Nginx نتوانست اسم را موقع شروع resolve کند و کلاً بالا نیامد؛ یعنی کل سایت (حتی بخش‌هایی که به API ربطی ندارند) به خاطر یک سرویس پایین است. depends_on هم فقط ترتیب شروع را تضمین می‌کند، نه اینکه سرویس همیشه باشد.

سرویس nginx پروژه‌ی ما (با resolve) در همان وضعیت: /api/ 502 ولی / 200؛ یعنی فقط بخش خراب از دسترس خارج شد. وقتی API برگشت، چند ثانیه بعد /api/ خودش درست شد.

Terminal window
sed 's|proxy_pass http://web:80;|proxy_pass http://localhost:8080;|' nginx/default.conf > nginx/default.conf.new
mv nginx/default.conf nginx/default.conf.good
mv nginx/default.conf.new nginx/default.conf
docker compose -p lx-nd exec nginx nginx -s reload
sleep 1
curl -s -o /dev/null -w '/ -> %{http_code}\n' http://127.0.0.1:8099/
docker compose -p lx-nd logs nginx 2>&1 | grep -o 'connect() failed[^,]*' | tail -n 1
mv nginx/default.conf.good nginx/default.conf
docker compose -p lx-nd exec nginx nginx -s reload
خروجی
2026/10/04 14:24:09 [notice] 81#81: signal process started
/ -> 502
connect() failed (111: Connection refused) while connecting to upstream
2026/10/04 14:24:11 [notice] 91#91: signal process started

/ با 502 و Connection refused. داخل کانتینر، localhost (یا 127.0.0.1) یعنی خود کانتینر Nginx، نه میزبان و نه کانتینرهای دیگر. هر کانتینر شبکه‌ی جدای خودش را دارد. این اشتباه معمولاً وقتی پیش می‌آید که تنظیمات Nginx روی سرور را بدون تغییر به داکر می‌آوری. راه‌حل: اسم سرویس (http://web:80). (برای رسیدن به سرویسی روی خود میزبان، در لینوکس extra_hosts: ["host.docker.internal:host-gateway"] در Compose و بعد http://host.docker.internal:PORT.)

Terminal window
cp nginx/default.conf nginx/default.conf.good
sed -i 's|proxy_pass http://web:80;|proxy_pass http://web:80|' nginx/default.conf
docker compose -p lx-nd exec nginx nginx -s reload; echo "exit code: $?"
sleep 1
curl -s -o /dev/null -w 'site still up: %{http_code}\n' http://127.0.0.1:8099/
cp nginx/default.conf.good nginx/default.conf
docker compose -p lx-nd exec nginx nginx -s reload
خروجی
2026/10/04 14:24:11 [emerg] 101#101: unexpected "}" in /etc/nginx/conf.d/default.conf:15
nginx: [emerg] unexpected "}" in /etc/nginx/conf.d/default.conf:15
exit code: 1
site still up: 200
2026/10/04 14:24:12 [notice] 107#107: signal process started

یک ; جا افتاد. nginx -s reload خطا داد و کد خروج ۱، ولی سایت همچنان 200 است: Nginx با تنظیم قبلی ادامه داد. این بار خوش‌شانس بودیم چون خطای syntax در همان دستور reload دیده شد؛ ولی خطاهایی که فقط موقع اعمال پیدا می‌شوند (مثل عوض شدن کلید یک zone در درس محدودسازی) فقط در لاگ ظاهر می‌شوند. راه‌حل: همیشه nginx -t قبل از reload، و نگاه به docker compose logs nginx بعد از آن (تمرین سخت همین را خودکار می‌کند).

✎ تمرینآسان

در پروژه‌ی مثال ۳، متن صفحه‌ی اصلی (site/index.html) را عوض کن. آیا برای دیدن تغییر، reload یا ریستارت لازم است؟ آزمایش کن.

دیدن جواب
Terminal window
echo '<h1>Hello again, nothing restarted</h1>' > site/index.html
curl -s http://127.0.0.1:8099/
خروجی
<h1>Hello again, nothing restarted</h1>

نه. فایل‌های سایت با یک پوشه mount شده‌اند و Nginx هر فایل را موقع درخواست از دیسک می‌خواند؛ پس تغییر فوراً دیده می‌شود. reload فقط برای تنظیمات Nginx لازم است. (و چون پوشه mount شده، نه فایل تنها، دام اشتباه ۱ هم پیش نمی‌آید: echo > محتوای همان فایل را عوض می‌کند و حتی ویرایشگری که فایل جدید بسازد هم درون پوشه‌ی mount شده دیده می‌شود.)

✎ تمرینمتوسط

تمرین اصلی درس، نسخه‌ی دو دامنه: همان دو سرویس (web و api) را با دو اسم دامنه پشت Nginx بگذار: www.lx.test به web و api.lx.test به api. با curl --resolve (بدون دست زدن به /etc/hosts) نشان بده هر دامنه به سرویس درست می‌رسد و دامنه‌ی ناشناخته 444 (بستن اتصال بدون جواب) می‌گیرد.

دیدن جواب
Terminal window
cp nginx/default.conf nginx/default.conf.good
cat > nginx/default.conf <<'EOF'
resolver 127.0.0.11 valid=5s;
upstream api {
zone api 64k;
server api:8080 resolve;
}
# unknown names: close the connection without a response
server {
listen 80 default_server;
return 444;
}
server {
listen 80;
server_name www.lx.test;
location / {
proxy_pass http://web:80;
}
}
server {
listen 80;
server_name api.lx.test;
location / {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
docker compose -p lx-nd exec nginx nginx -t 2>&1 | tail -n 1
docker compose -p lx-nd exec nginx nginx -s reload
sleep 1
for h in www.lx.test api.lx.test other.lx.test; do
printf '%-14s -> ' "$h"
curl -s --resolve "$h:8099:127.0.0.1" "http://$h:8099/" -w ' [%{http_code}]\n'
done
cp nginx/default.conf.good nginx/default.conf
docker compose -p lx-nd exec nginx nginx -s reload
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/10/04 14:24:12 [notice] 123#123: signal process started
www.lx.test -> <h1>Hello again, nothing restarted</h1>
[200]
api.lx.test -> {"container":"b9aee8eb1fa8","path":"/","real_ip":"172.18.0.1","service":"api"}
[200]
other.lx.test -> [000]
2026/10/04 14:24:14 [notice] 133#133: signal process started
  • www.lx.test به web رسید (صفحه‌ای که در تمرین قبل عوضش کردیم).
  • api.lx.test به api (مسیر /).
  • other.lx.test به server default_server افتاد و return 444: Nginx اتصال را بدون هیچ پاسخی بست؛ برای همین curl کد 000 (هیچ پاسخی) گزارش داد. این روش خوبی برای رد کردن درخواست‌هایی است که با IP یا دامنه‌ی ناشناخته می‌آیند (ربات‌های اسکن).
  • curl --resolve HOST:PORT:IP به curl می‌گوید این اسم را بدون DNS به این IP وصل کن؛ هم Host درست فرستاده می‌شود و هم به /etc/hosts دست نمی‌زنیم.

آخر سر تنظیم قبلی را برگرداندیم.

✎ تمرینسخت

یک اسکریپت reload.sh بنویس که: ۱) تنظیمات را داخل کانتینر تست کند و اگر خراب بود، با پیام خطا و کد غیرصفر خارج شود و reload نکند؛ ۲) اگر سالم بود reload کند؛ ۳) دو ثانیه بعد، error log کانتینر (خروجی docker compose logs) را برای [emerg] تازه بگردد (یادت هست reload ناموفق بی‌صداست؟). با یک تنظیم سالم و یک تنظیم خراب آزمایشش کن.

دیدن جواب
reload.sh
#!/usr/bin/env bash
# safe nginx reload for a Compose project
set -euo pipefail
project=${1:?usage: reload.sh PROJECT}
dc() { docker compose -p "$project" "$@"; }
if ! out=$(dc exec -T nginx nginx -t 2>&1); then
echo "config test FAILED, not reloading:" >&2
echo "$out" | grep emerg >&2
exit 1
fi
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
dc exec -T nginx nginx -s reload 2> /dev/null
sleep 2
if dc logs --since "$since" nginx 2>&1 | grep -q '\[emerg\]'; then
echo "reload FAILED (old config still running):" >&2
dc logs --since "$since" nginx 2>&1 | grep '\[emerg\]' >&2
exit 1
fi
echo "reloaded OK"
Terminal window
chmod +x reload.sh
./reload.sh lx-nd; echo "exit: $?"
cp nginx/default.conf nginx/default.conf.good
sed -i 's|server api:8080 resolve;|server api:8080 resolve weight=x;|' nginx/default.conf
./reload.sh lx-nd; echo "exit: $?"
cp nginx/default.conf.good nginx/default.conf
./reload.sh lx-nd; echo "exit: $?"
خروجی
reloaded OK
exit: 0
config test FAILED, not reloading:
2026/10/04 14:24:16 [emerg] 159#159: invalid parameter "weight=x" in /etc/nginx/conf.d/default.conf:6
nginx: [emerg] invalid parameter "weight=x" in /etc/nginx/conf.d/default.conf:6
exit: 1
reloaded OK
exit: 0

سه اجرا:

  1. تنظیم سالم: تست، reload، و بعد از دو ثانیه هیچ [emerg] تازه‌ای در لاگ نبود: reloaded OK با کد ۰.
  2. تنظیم خراب (weight=x): nginx -t شکست خورد، اسکریپت فقط خط emerg را نشان داد و reload نکرد (کد ۱).
  3. تنظیم درست برگشت: دوباره OK.

نکته‌های اسکریپت: exec -T (بدون ترمینال؛ برای اسکریپت و CI لازم است)، logs --since (فقط لاگ‌های بعد از reload، تا emerg های قدیمی اشتباه گزارش نشوند)، و set -euo pipefail از درس مدیریت خطا در Bash. این اسکریپت را می‌شود آخرین مرحله‌ی دیپلوی گذاشت.

⚡ بررسی سریع

در کانتینر nginx نوشته‌ای proxy_pass http://localhost:3000 و اپ در سرویس app است. چه می‌شود؟

؟ آزمونک
  1. docker logs روی کانتینر nginx چرا لاگ دسترسی را نشان می‌دهد؟

  2. چرا mount پوشه‌ی conf.d بهتر از mount یک فایل default.conf است؟

  3. API بعد از دیپلوی IP تازه گرفته و Nginx ۵۰۲ می‌دهد تا reload. چرا؟

  4. کانتینر nginx با پیام host not found in upstream "api:8080" خارج می‌شود. علت؟

  5. nginx -s reload با تنظیم خراب چه می‌کند؟

  6. فایل‌های *.template در /etc/nginx/templates/ چه می‌شوند؟

  • ایمیج رسمی: nginx.conf همه‌ی conf.d/*.conf را include می‌کند؛ پوشه‌ی conf.d را (نه یک فایل) و ریشه‌ی سایت را با :ro mount کن. لاگ‌ها به stdout/stderr، پس docker compose logs nginx.
  • Compose: فقط nginx پورت منتشر می‌کند (127.0.0.1:... برای آزمایش)؛ بقیه‌ی سرویس‌ها با اسمشان (proxy_pass http://api:8080) و DNS داخلی داکر (127.0.0.11).
  • reload بدون ریستارت: docker compose exec nginx nginx -t و بعد docker compose exec nginx nginx -s reload. reload خراب بی‌صداست؛ لاگ را نگاه کن.
  • IP های متغیر: Nginx بدون resolve اسم‌ها را یک بار resolve می‌کند (۵۰۲ بعد از تغییر IP، و host not found in upstream اگر سرویس موقع شروع نباشد). راه‌حل: resolver 127.0.0.11 valid=5s; + upstream { zone ...; server api:8080 resolve; }.
  • localhost داخل کانتینر یعنی خود کانتینر.
  • قالب‌ها: /etc/nginx/templates/*.template + متغیر محیطی، با envsubst موقع شروع.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
docker run -d -p 127.0.0.1:8099:80 -v ./conf:/etc/nginx/conf.d:ro nginx:alpineNginx با تنظیمات خودت
docker exec CONTAINER nginx -tتست تنظیمات
docker compose exec nginx nginx -s reloadreload بدون ریستارت
docker compose exec nginx nginx -Tکل تنظیمات نهایی
docker compose logs nginxلاگ دسترسی و خطا
proxy_pass http://api:8080;proxy به سرویس با اسم
resolver 127.0.0.11 valid=5s;DNS داخلی داکر
server api:8080 resolve;resolve دوباره‌ی اسم (در upstream با zone)
docker compose exec nginx getent hosts apiIP فعلی سرویس
-v ./templates:/etc/nginx/templates:ro -e NAME=...قالب با envsubst