امنیت: non-root، read-only، محدودیت منابع
Container security: non-root, read-only filesystems, and resource limits
برنامه درست کار میکند. حالا سؤال عوض میشود: اگر روزی خود برنامه یا یکی از وابستگیهایش از کنترل خارج شد، چقدر دسترسی دارد و تا کجا میتواند اثر بگذارد؟
The application works. Now the question changes: if the application or one of its dependencies is compromised, what can it access, and how far can the damage spread?
برنامه سالم است؛ اما اگر یک روز کد داخلش دست مهاجم افتاد چه؟The app is healthy—but what if it isn't tomorrow?
تا اینجا بیشتر سؤالهایمان این بود که «آیا برنامه اجرا میشود؟». حالا فرض کن همان برنامه سالم بالا آمده، healthcheck هم سبز است، اما یک کتابخانهٔ آسیبپذیر اجازه میدهد کسی داخل فرایند برنامه کد اجرا کند. از این لحظه سؤال مهم عوض میشود: این فرایند واقعاً تا کجا اجازهٔ اثرگذاری دارد؟
Yesterday our web service started, answered requests, and passed its healthcheck. Today a vulnerable library let an attacker execute code inside the application process. The question is no longer “does the container run?” It is: “What access does this application actually need?”
هدف hardening این نیست که با چند گزینه container را «امن» اعلام کنیم. میخواهیم اگر چیزی خراب شد، دامنهٔ اثرش کوچکتر باشد. پس مرحلهبهمرحله دسترسی را کم میکنیم: کاربر کماختیارتر، فایلسیستم محدودتر و منابع کنترلشدهتر. بعد از هر تغییر هم ثابت میکنیم برنامه هنوز کار مورد انتظارش را انجام میدهد.
Chapters 3 and 12 taught us to separate process liveness, application health, and failure causes. The next step is to keep the application's authority and resource reach small if something goes wrong. This does not replace patching vulnerabilities or secure design; it limits the possible blast radius.
یک container همارز ماشین مجازی یا مرز امنیتی کامل نیست. containerها از سازوکارهای جداسازی هستهٔ میزبان استفاده میکنند. پیکربندی مناسب میتواند کمک بزرگی باشد، ولی هیچ گزینهای بهتنهایی container را «امن» نمیکند. پس بهجای وعدهٔ امنیت مطلق، چند لایهٔ قابلاندازهگیری میسازیم.
A container is not equivalent to a virtual machine or a complete security boundary. Containers use isolation mechanisms provided by the host kernel. Good configuration helps, but no single option makes a container “secure.” Instead of promising absolute security, we will add several measurable layers.
فرمانهای ترمینال چندخطی در این فصل از ادامهخط Bash (`\`) استفاده میکنند؛ در PowerShell آنها را یکخطی اجرا کن یا `\` پایانی را با backtickِ PowerShell جایگزین کن. ادامهخطهای داخل Dockerfile syntax خود Dockerfile هستند و نباید تغییر کنند.
Multiline terminal commands in this chapter use Bash line continuation (`\`). In PowerShell, run them on one line or replace the trailing `\` with PowerShell's backtick. Continuations inside a Dockerfile are Dockerfile syntax and should not be changed.
این برنامه واقعاً به چه دسترسیای نیاز دارد؟ چند پوشه باید بتواند تغییر دهد؟ چهقدر CPU و حافظه لازم دارد؟ چه اختیارهایی یا سطحی از دسترسی میزبان اصلاً لازم نیست به آن بدهیم؟ پاسخها را از رفتار واقعی برنامه بگیر، نه از حدس.
What access does this application actually need? Which directories must it change? How much CPU and memory does it need? Which privileges or host access does it not need? Base the answers on observed application behavior, not guesses.
گام اول: اگر root لازم نیست، به برنامه root ندهLayer one: the app does not need to own everything
اول وضعیت پایه را ببین. داخل image یک shell کوتاه اجرا کن و با id یا whoami بپرس فرایند با چه هویتی بالا میآید. خیلی از imageها اگر خودت چیزی مشخص نکنی با root شروع میشوند. سؤال بعدی ساده است: «برنامهٔ ما برای پاسخدادن به HTTP واقعاً به root نیاز دارد؟»
To see the starting point, run a short shell from an image and ask who it is. Many general-purpose images produce output like this:
docker run --rm python:3.12-alpine sh -c 'whoami; id' root uid=0(root) gid=0(root) groups=0(root)
در Dockerfile یک کاربر مشخص میسازیم و با USER اجرای نهایی را به همان کاربر میسپاریم. اگر برنامه باید جایی بنویسد، مالکیت همان مسیر را هم آگاهانه تنظیم میکنیم. COPY --chown و permission درست بهتر از این است که آخر کار با chmod 777 همهچیز را برای همه باز کنیم.
This example shows that the chosen image defaults to UID zero; not every image does, so inspect the identity instead of assuming. A UID is a numeric user identifier and a GID is a numeric group identifier. Filesystem permissions are evaluated mainly against these numbers, not a readable name such as app.
حالا سؤال تکراریمان: «این برنامه واقعاً به چه دسترسیای نیاز دارد؟» یک سرویس وب معمولی معمولاً فایل اجرایی و تنظیماتش را میخواند و روی یک پورت گوش میدهد؛ نوشتن روی تمام فایلسیستم یا تغییر مالکیت فایلها جزو نیازهای عادیاش نیست. Dockerfile میتواند کاربر پیشفرض runtime را با USER تعیین کند.
Now our recurring question: “What access does this application actually need?” A typical web service reads its executable and configuration and listens on a port; writing across the filesystem or changing file ownership is not normally part of its job. A Dockerfile can set the default runtime identity with USER.
FROM python:3.12-alpine RUN addgroup -S -g 10001 app \ && adduser -S -D -H -u 10001 -G app app \ && mkdir -p /app /var/lib/app \ && chown root:root /app /var/lib/app WORKDIR /app COPY server.py /app/server.py ENV STATE_DIR=/var/lib/app EXPOSE 8080 USER app:app CMD ["python", "/app/server.py"]
این image یک کاربر واقعی میسازد؛ USER app:app کاربر و گروه پیشفرض را برای فرمان زمان اجرا عوض میکند. مالکیت /var/lib/app را عمداً root گذاشتیم تا خطای نزدیک را ببینیم. فایل کپیشده هم بدون --chown معمولاً مالک root میشود؛ اگر برنامه فقط باید آن را بخواند اشکالی ندارد، اما پوشهٔ داده باید متناسب با کاربر runtime تنظیم شود.
This image creates a real user; USER app:app changes the default user and group for runtime commands. We deliberately leave /var/lib/app owned by root so we can see a realistic failure. A copied file is normally root-owned without --chown; that is fine if the app only reads it, but a data directory must be prepared for the runtime user.
RUN addgroup -S -g 10001 app \ && adduser -S -D -H -u 10001 -G app app \ && mkdir -p /app /var/lib/app \ && chown app:app /var/lib/app \ && chown root:root /app WORKDIR /app COPY --chown=root:root server.py /app/server.py USER app:app
فایل برنامه برای کاربر قابلخواندن است ولی قابلتغییر نیست؛ پوشهٔ داده را فقط به همان کاربر میدهیم. WORKDIR اگر مسیرش وجود نداشته باشد آن را میسازد، اما نباید فرض کنی مالکیتش خودبهخود با کاربر runtime جور است. مالک و مجوز پوشه را پیش از USER آگاهانه تنظیم کن. در buildهای مرحلهای بهتر است USER فقط در مرحلهٔ نهایی runtime بیاید تا نصب بستهها به مشکل نخورد.
The app file is readable but not mutable by the runtime user; only the data directory is writable by that user. WORKDIR creates a missing path, but do not assume its ownership will automatically match the runtime identity. Set directory ownership and permissions deliberately before USER. In multi-stage builds, place the runtime USER in the final stage so package installation is not accidentally run without required privileges.
$ docker run --rm -p 8080:8080 my-web $ curl -i http://localhost:8080/ HTTP/1.0 500 Internal Server Error PermissionError: [Errno 13] Permission denied: '/var/lib/app/visits.txt'
فرایند بالا آمده و با non-root اجرا میشود؛ خطا فقط هنگام نوشتن رخ میدهد. بهجای بالا بردن اختیار برنامه یا زدن chmod 777، مالک واقعی و نیاز نوشتن را پیدا کن. مجوز ۷۷۷ به هر کاربری اجازهٔ تغییر میدهد و مشکل مالکیت را پنهان میکند؛ راه درست، دادن مجوز لازم به کاربر مشخص است.
The process started as non-root; the failure occurs only when it tries to write. Rather than restoring broad privilege or using chmod 777, identify the owner and the actual write requirement. Mode 777 lets every user change the path and hides the ownership mistake; the better fix grants the required access to the intended user.
docker exec my-web sh -c 'whoami; id; ls -ld /var/lib/app' app uid=10001(app) gid=10001(app) groups=10001(app) drwxr-xr-x 2 root root 4096 ... /var/lib/app
شواهد دقیقاند: UID برنامه ۱۰۰۰۱ است، ولی پوشه متعلق به UID صفر و برای گروه هم قابلنوشتن نیست. اصلاح را در image ثبت میکنیم؛ مثلاً RUN chown app:app /var/lib/app یا پوشه را با همان مالکیت میسازیم. بعد image را rebuild میکنیم و همان درخواست را دوباره میفرستیم. permission را به 777 تبدیل نمیکنیم.
The evidence is specific: the app runs as UID 10001, but the directory belongs to UID zero and is not group-writable. Record the fix in the image—for example, RUN chown app:app /var/lib/app or create the directory with that ownership. Rebuild, then repeat the same request. Do not widen the mode to 777.
| مقایسه / Comparison | root runtime | non-root runtime | پرسش بررسی / Check |
|---|---|---|---|
| شناسه / Identity | معمولاً UID صفر در namespace کانتینر / Often UID 0 in the container namespace | UID/GID مشخص برنامه / Explicit app UID/GID | id واقعاً چه میگوید؟ / What does id report? |
| فایلها / Files | خطای مالکیت ممکن است پنهان شود / Ownership bugs may be masked | فقط مسیرهای آمادهشده کار میکنند / Only prepared paths work | کدام مسیر باید writable باشد؟ / Which path must be writable? |
| اگر برنامه رخنه کند / If compromised | اختیار بیشتر درون namespace / More authority inside its namespace | اختیار فرایند محدودتر / Less process authority | چه مرزهایی همچنان وجود دارند؟ / Which boundaries still apply? |
گفتن «root داخل container همان root میزبان است» نادرست است: namespaceها و قابلیتهای محدودشده معمولاً تفاوت مهمی میسازند. اما «root داخل container بیخطر است» هم نادرست است؛ اختیار بیشتر، mount میزبان، آسیبپذیری هسته یا دسترسی به Docker API میتواند خطر را بالا ببرد. حساب non-root یکی از لایههاست، نه مرز جادویی.
“Container root is the same as host root” is inaccurate: namespaces and restricted capabilities usually create important differences. But “container root is harmless” is also wrong; greater authority, host mounts, kernel vulnerabilities, or Docker API access can increase risk. A non-root account is one layer, not a magical boundary.
گام دوم: برنامه لازم نیست فایلسیستم خودش را هرجا خواست تغییر بدهدLayer two: what should change on the image filesystem?
وقتی برنامه بستهبندی شده، بخش بزرگی از filesystem فقط برای خواندن است: کد، کتابخانهها و فایلهای ثابت. اگر سرویس برای کار عادی لازم نیست آنها را تغییر دهد، --read-only یک مرز مفید میسازد. بعد مسیرهایی را که واقعاً نوشتن لازم دارند جدا و صریح تعریف میکنیم.
The application is packaged, but it may write caches, temporary files, or debug output to arbitrary locations. Ask again: “What access does this application actually need?” If normal operation does not require changing the container's main filesystem, make that filesystem read-only and mount only the specific paths that need writes.
docker run --rm --read-only --user 10001:10001 my-web PermissionError: [Errno 30] Read-only file system: '/var/lib/app/visits.txt'
برای فایل موقت میتوانیم tmpfs بدهیم؛ دادهٔ ماندگار میتواند روی volume بنشیند. نکته همین «استثنای صریح» است. read-only شدن root filesystem بهمعنی read-only شدن mountهای جدا نیست. اگر برنامه روی /data volume نوشتنی دارد، همان مسیر همچنان قابل نوشتن است.
Separate two failure types: a path may reject writes because ownership or permissions are wrong, or because the root filesystem itself is read-only. “Read-only file system” and “Permission denied” are not the same. --read-only locks the container's root filesystem; a separately mounted volume or path that is writable remains writable.
برای دادهٔ موقت که پس از توقف container لازم نیست، tmpfs انتخاب مناسبی است. دادهاش روی فایلسیستم حافظهای است و با حذف container باقی نمیماند؛ ضمن اینکه مصرفش جزو حافظهٔ container حساب میشود. دادهای که باید پس از تعویض container بماند، متعلق به volume یا ذخیرهسازی بیرونی است. volume را فقط وقتی اضافه کن که واقعاً به ماندگاری نیاز داری و مالکیت داخل mount را هم بررسی کن.
For temporary data that need not survive container removal, tmpfs is a suitable choice. It lives in memory-backed filesystem and does not persist after container removal; its use also counts toward container memory. Data that must survive replacement belongs in a volume or external storage. Add a volume only when persistence is genuinely required, and check ownership inside the mount.
docker run --rm --read-only --user 10001:10001 \ --tmpfs /tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001 \ -e STATE_DIR=/tmp my-web
این فرمان به برنامه اجازهٔ نوشتن روی root filesystem نمیدهد؛ فقط /tmp را با سقف کوچک و مالکیت UID/GID برنامه writable میکند. noexec اجرای مستقیم فایل از آن mount را منع میکند و nosuid اثر بیتهای set-user-ID را میبندد؛ این گزینهها جایگزین بررسی رفتار برنامه نیستند. اگر برنامه در مسیر دیگری بنویسد، خطا سرنخِ نیاز واقعی یا تنظیم نادرست است، نه دلیلی برای بازکردن کل rootfs.
This command blocks writes to the root filesystem and makes only /tmp writable, with a small cap and the app's UID/GID as owner. noexec prevents direct execution from that mount; nosuid neutralizes set-user-ID bits there. These options do not replace checking application behavior. If the app writes elsewhere, the failure is evidence of a real requirement or bad configuration—not a reason to reopen the whole root filesystem.
| مسیر / Path | مدل / Model | ماندگاری / Persistence | پرسش / Question |
|---|---|---|---|
| فایلهای برنامه / App files | root filesystem با --read-only / root filesystem with --read-only | داخل image / In image | آیا runtime باید اینها را عوض کند؟ / Must runtime change these? |
/tmp | tmpfs محدود / bounded tmpfs | نه؛ با توقف container از بین میرود / No; lost on container stop/removal | آیا داده موقت است و سقف دارد؟ / Is it temporary and bounded? |
/data | volume یا ذخیرهسازی بیرونی / volume or external storage | بله، طبق چرخهٔ عمر داده / Yes, according to data lifecycle | چه کسی مالک داده و backup است؟ / Who owns and backs it up? |
گام سوم: حتی یک فرایند سالم هم نباید بینهایت منبع بگیردLayer three: constrain the resource blast radius
فصل قبل دیدیم وقتی حافظه تمام میشود چه نشانههایی میبینیم. اینجا دیگر فقط آزمایش OOM نمیکنیم؛ میخواهیم برای یک سرویس واقعی مرز معقول بگذاریم. اگر یک bug حافظه را میبلعد، CPU را میچرخاند یا فرایندهای زیادی میسازد، بهتر است دامنهٔ فشار از اول محدود باشد.
Chapter 12 used a small memory cap to reproduce OOM in a controlled way. Here we move from the experiment to what each boundary means for a running service. With no ceiling, a memory leak, compute loop, or excessive process creation can pressure other services on the same host. A limit does not repair bad behavior; it constrains its resource footprint.
| مرز / Limit | نمونه / Example | چه میکند / What it does | چه نمیکند / What it does not do |
|---|---|---|---|
| Memory | --memory=128m | مصرف حافظهٔ cgroup را سقف میگذارد؛ عبور میتواند allocation failure یا OOM termination بسازد / Caps cgroup memory; exceeding it may cause allocation failures or OOM termination | حافظهٔ درستِ برنامه را پیدا یا نشت را تعمیر نمیکند / Does not find a correct app budget or fix a leak |
| CPU | --cpus=0.50 | زمان پردازنده را محدود میکند؛ برنامه ممکن است throttle و کند شود / Limits CPU time; the app may be throttled and slow down | نیمهستهٔ اختصاصی یا زمان پاسخ تضمینشده نیست / Does not reserve half a core or guarantee latency |
| Processes / PIDs | --pids-limit=64 | تعداد taskهای تحت محدودیت را سقف میگذارد / Caps tasks under the limit | نشتی thread/process را پیدا نمیکند؛ سقف کم میتواند کار معتبر را هم بشکند / Does not diagnose leaks; a low cap can break valid work |
--memory سقف حافظه را محدود میکند، --cpus سهم CPU را و --pids-limit تعداد فرایندها را. اینها تضمین نمیکنند برنامه درست رفتار کند؛ فقط میگویند یک برنامهٔ بدرفتار تا کجا میتواند منابع میزبان را مصرف کند. بعد از اعمال limit باید هم خود تنظیمات را inspect کنیم و هم رفتار واقعی سرویس را.
With direct Docker execution, these options make the boundaries explicit:
docker run -d --name web \ --memory=128m --cpus=0.50 --pids-limit=64 \ my-web
خروجی موفق docker run بهتنهایی ثابت نمیکند Engine محدودیت دلخواه را واقعاً اعمال کرده. پیکربندی ثبتشدهٔ container را inspect کن:
A successful docker run alone does not prove that the Engine applied the intended limits. Inspect the container's recorded configuration:
docker inspect web --format 'memory={{.HostConfig.Memory}} nanoCPUs={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}}'
memory=134217728 nanoCPUs=500000000 pids=64اینجا memory بر حسب بایت و CPU بر حسب nanoCPU ثبت شدهاند؛ عددها را بهجای متن نمایشی با واحد قابلفهم ترجمه کن. اینها شواهد پیکربندیاند، نه مصرف لحظهای. برای مصرف جاری docker stats web را کنارشان ببین. روی Docker Desktop، Engine داخل محیط مدیریتشده اجرا میشود؛ منابع ماشین مجازی آن هم سقف بیرونی دارد.
Memory is recorded in bytes and CPU in nanoCPUs; translate the numbers into readable units instead of treating them as labels. These are configuration evidence, not live usage. Pair them with docker stats web for current consumption. On Docker Desktop the Engine runs in a managed environment, which has its own outer resource ceiling.
محدودیت memory میتواند باعث شود allocation شکست بخورد یا فرایند با OOM متوقف شود؛ سقف CPU کار را کند میکند؛ سقف PIDs میتواند ساخت thread یا فرایند تازه را رد کند. هر سه به انتخاب عدد، اندازهگیری بار و رفتار برنامه نیاز دارند. مقدار کوچکِ آموزشی توصیهٔ production نیست. فصل ۱۲ روش تشخیص OOM را دارد؛ اینجا از همان تشخیص برای انتخاب مرز مناسب استفاده میکنیم.
A memory limit can cause allocations to fail or a process to be stopped by OOM; a CPU cap slows work; a PID ceiling can reject new threads or processes. All three require sizing from measured workload and application behavior. A small teaching value is not a production recommendation. Chapter 12 explains OOM diagnosis; here we use that evidence to choose an appropriate boundary.
همان مرزها باید داخل تعریف Compose هم دیده شوندThe same boundaries in Compose
اگر سرویس را با Compose بالا میآوریم، hardening نباید به چند flag فراموششدنی در history ترمینال محدود شود. تنظیماتی مثل user، read-only بودن، tmpfs و limitهای منابع را کنار همان service مینویسیم تا نفر بعد هم همان مرزها را بگیرد.
When you launch a service with Compose, keep its boundaries in the service definition so they remain reproducible. The current Compose service reference defines direct keys for user, read_only, tmpfs, mem_limit, cpus, and pids_limit. This example targets ordinary local Compose and does not depend on a scheduler or Swarm.
services:
web:
build: .
ports:
- "8080:8080"
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001
environment:
STATE_DIR: /tmp
BREAK_HEALTH: "0"
mem_limit: 128m
cpus: 0.50
pids_limit: 64
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=1)"]
interval: 10s
timeout: 2s
retries: 3
start_period: 5sاین فصل فقط syntax مناسب اجرای معمول Compose را استفاده میکند. هر چیزی که مخصوص orchestrator یا Swarm است را بیدلیل وارد نمونه نمیکنیم. هدف این است که فایل Compose همان چیزی را توصیف کند که واقعاً روی محیط فعلی اجرا میشود.
Before starting, inspect docker compose config to see whether Compose parses and resolves the final settings; this does not prove application behavior or daemon support for every feature. Inspect the created container too. In the current reference, mem_limit, cpus, and pids_limit are direct service settings; if you also define matching deploy.resources.limits, the values must be consistent and deploy support is platform-dependent. For this local example we keep the direct keys rather than mislabeling scheduler-oriented syntax as a universal rule.
docker compose config
docker compose up -d --build
docker compose ps
docker inspect "$(docker compose ps -q web)" --format '{{.Config.User}} {{.HostConfig.ReadonlyRootfs}} {{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}'در PowerShell میتوانی بهجای substitution مخصوص Bash، شناسه را جدا بگیری: $cid = docker compose ps -q web و بعد docker inspect $cid --format .... در هر shell دستور متناسب خودش را بهکار ببر؛ syntax دو محیط را در یک فرمان مخلوط نکن.
In PowerShell, get the ID separately instead of using Bash substitution: $cid = docker compose ps -q web, then run docker inspect $cid --format .... Use syntax appropriate to your shell; do not mix shell conventions in one command.
مرزهای اضافه را فقط وقتی لازماند اضافه کنOptional layers: trim privileges too
بعد از non-root، read-only و limitهای منابع، هنوز میشود بعضی اختیارهای Linux را هم محدودتر کرد. cap-drop یا no-new-privileges میتوانند مفید باشند، اما اینجا قرار نیست capabilityهای Linux را حفظ کنیم. روش درست همان قبلی است: از نیاز واقعی برنامه شروع کن، یک مرز را کم کن و بعد رفتار را دوباره آزمایش کن.
Docker restricts a set of Linux capabilities by default. If a service does not need a particular capability, you can remove it with --cap-drop; blindly dropping everything can still break legitimate behavior. Start from actual requirements and test the change. no-new-privileges prevents a process from gaining additional privileges through mechanisms such as setuid; it complements other controls rather than compensating for root or host access.
docker run --security-opt=no-new-privileges \ --cap-drop=NET_RAW \ my-web
دو میانبر را هم بهعنوان علامت خطر نگه دار: --privileged و mount کردن /var/run/docker.sock. هر دو میتوانند اختیار بسیار زیادی به container بدهند. اگر برنامهٔ معمولی وب به آنها «نیاز» پیدا کرده، قبل از قبولکردن این نیاز باید معماری را دوباره بررسی کنی.
If you think you need more permission, first identify the exact operation and whether there is a narrower alternative. --privileged is not a harmless shortcut: it grants broad authority, host-device access, and changes default restrictions. Mounting /var/run/docker.sock is not ordinary file access; control of the Docker daemon can often create powerful containers and mounts. Chapter 2 explained why that socket is a critical boundary. Do not give either to an ordinary web app.
| لایه / Control | کاهش میدهد / Reduces | حل نمیکند / Does not solve |
|---|---|---|
| non-root user | اختیارهای فرایند و اشتباههای مالکیت / Process authority and ownership mistakes | کد آسیبپذیر، secret بدمدیریتشده یا mount پرقدرت / Vulnerable code, poor secret handling, or powerful mounts |
| read-only rootfs | تغییر فایلهای داخل لایهٔ اصلی / Changes to the root filesystem layer | نوشتن در volume writable یا سوءاستفاده از API / Writes to writable volumes or API abuse |
| memory/CPU/PID limits | دامنهٔ مصرف این منابع / Resource footprint in these dimensions | نشت، پاسخ کند، خطای منطقی یا مصرف دیسک / Leaks, latency, logic bugs, or disk usage |
| no-new-privileges / cap-drop | راههای کسب اختیار اضافه یا capabilityهای مشخص / Privilege gain or selected capabilities | نیازسنجی، وصلهکردن و همهٔ مرزهای میزبان / Threat modeling, patching, or every host boundary |
| privileged / Docker socket | هیچکدام؛ اینها اختیار را بالا میبرند / Nothing; they increase authority | امنیت برنامه؛ برعکس، دامنهٔ اثر را بزرگ میکنند / App security; they expand the blast radius |
لایههای بعدی مثل SELinux، AppArmor، seccomp و rootless Docker ارزش دارند، اما هرکدام مدل و محدودیت خودش را دارد و این فصل وارد جزئیاتشان نمیشود. آنها را مرحلهٔ بعدی ببین؛ نه چیزی که با اضافهکردن یک خط، بقیهٔ کنترلها را بینیاز کند.
Further layers such as SELinux, AppArmor, seccomp, and rootless Docker are valuable, but each has its own model and tradeoffs and is outside this chapter's depth. Treat them as another step—not a line that makes every other control unnecessary.
یک سرویس را یکباره «هاردن» نکن؛ مرزها را یکییکی ببندOne service, from broad access to only what it needs
از نسخهای شروع میکنیم که کار میکند و بعد فقط یک چیز را تغییر میدهیم: اول user، بعد مسیرهای نوشتنی، بعد read-only و در آخر limitها. بعد از هر گام healthcheck و یک درخواست واقعی را اجرا میکنیم. این ترتیب باعث میشود اگر چیزی شکست، بدانیم کدام تغییر مسئول بوده است.
Instead of dropping five options into Compose and hoping for the best, we change one thing at a time and check observable behavior after each step. The recurring question stays the same: “What access does this application actually need?”
- ۱ · baseline
پرسش ثابت کل مسیر همین است: «این برنامه واقعاً به چه دسترسیای نیاز دارد؟» اگر جواب «نوشتن فقط روی /data» است، لازم نیست کل root filesystem نوشتنی بماند. اگر جواب «یک فرایند وب» است، لازم نیست root یا اختیارهای میزبان را به آن بدهیم.
Version A runs with its default user, writable root filesystem, and no resource caps. Record the `/` response and `whoami; id`; this is our baseline, not the recommended pattern.
- ۲ · non-root
کاربر UID/GID ۱۰۰۰۱ را اجرا میکنیم. دوباره هویت را میخوانیم؛ سپس درخواست نوشتن را عمداً میفرستیم و ممکن است ۵۰۰ بگیریم. این شکست مفید است: معلوم شد سرویس به مسیر داده نیاز دارد.
Run as UID/GID 10001. Verify identity again, then deliberately make the write request; it may return 500. This useful failure reveals that the service needs a data path.
- ۳ · ownership
مالکیت پوشهٔ لازم را در Dockerfile به همان UID میدهیم. همان درخواست باید موفق شود، بدون اینکه فایل برنامه writable شده باشد یا مجوز ۷۷۷ بدهیم.
Give the required directory to that UID in the Dockerfile. The same request should now succeed without making application code writable or using mode 777.
- ۴ · read-only + explicit writes
rootfs را read-only میکنیم؛ نوشتن به `/var/lib/app` دوباره شکست میخورد، چون تغییر image filesystem قرار نیست راهحل باشد. دادهٔ این سرویس آزمایشی موقت است، پس آن را به tmpfs محدود `/tmp` هدایت میکنیم. volume را فقط اگر واقعاً ماندگاری خواستیم اضافه میکنیم.
Make the root filesystem read-only; writing to `/var/lib/app` fails again because mutating the image filesystem is not the intended design. This sample's data is temporary, so redirect it to a bounded `/tmp` tmpfs. Add a volume only if persistence is actually required.
- ۵ · resource boundaries + health
memory، CPU و PID را محدود میکنیم؛ container را inspect میکنیم، درخواست واقعی میفرستیم و healthcheck را میسنجیم. محدودیتها فقط با همان پاسخ موفق و شواهد تنظیمات معنی دارند.
Set memory, CPU, and PID limits; inspect the container, send a real request, and check its health status. Limits matter only alongside a successful request and evidence that the configuration was applied.
اگر hardening برنامه را شکست، مرز را دقیق پیدا کن؛ همهچیز را باز نکنWhen hardening breaks the service
PermissionError: [Errno 13] Permission denied- نشانه
- فرایند non-root است، مسیر متعلق به root است.
- Meaning
- The process is non-root and the target is root-owned.
- قدم بعد
- با
idوls -ldمالکیت و نیاز واقعی نوشتن را ببین؛ owner/group را هدفمند درست کن. - Next
- Check identity and directory ownership; grant only the required write access.
Read-only file system- نشانه
- mount یا rootfs فقطخواندنی است.
- Meaning
- The root filesystem or a mount is read-only.
- قدم بعد
- مسیر نوشتن را پیدا کن؛ آن را به tmpfs موقت یا volume لازم هدایت کن.
- Next
- Find the write path and direct it to required tmpfs or volume.
Healthcheck failed after adding read_only- نشانه
- healthcheck شاید در مسیر ناخواسته بنویسد یا binary لازم در image نیست.
- Meaning
- The check may write to an unexpected path or its binary may be absent.
- قدم بعد
- خروجی healthcheck را inspect کن و check را cheap، واقعی و بدون تغییر داده نگه دار.
- Next
- Inspect health output and keep the test real, cheap, and read-only.
OOMKilled / slow response / fork failed- نشانه
- ممکن است memory، CPU یا PIDs بیش از نیاز واقعی محدود شده باشد.
- Meaning
- Memory, CPU, or PID limits may be below measured needs.
- قدم بعد
- به فصل ۱۲ برگرد، state و شواهد مصرف را بخوان، بعد یک مقدار را با بار واقعی تغییر بده.
- Next
- Return to Chapter 12, inspect state and usage evidence, and adjust one value against real workload.
بعد از تغییر user ممکن است permission denied ببینی؛ بعد از read-only ممکن است Read-only file system بگیری. این دو یک مشکل نیستند و راهحل یکسان ندارند. هویت فرایند، مالکیت مسیر و نوع mount را جدا ببین.
A failure after hardening is not a reason to immediately restore root, a writable filesystem, and unlimited resources. Distinguish permission denial from a read-only filesystem, inspect identity and mounts, and make the smallest necessary adjustment. Changing several boundaries at once makes diagnosis harder.
۱۸ تمرین؛ هر بار بپرس «کمترین دسترسی لازم چیست؟»18 exercises: from file permissions to host boundaries
در هر تمرین اول مشخص کن برنامه چه کاری باید انجام دهد و کدام دسترسی واقعاً برای همان کار لازم است. بعد پاسخ را باز کن. هدف حفظکردن flagهای امنیتی نیست؛ باید بتوانی توضیح بدهی هر مرز چه چیزی را کم میکند و چه چیزی را اصلاً حل نمیکند.
Before opening a solution, say which evidence you want and what is not yet proven. Several questions ask you to distinguish “it started” from “it did the right work.”
۱. UID صفر در خروجی چه میگوید؟1. What does UID zero tell you?
در container فرمان id خروجی uid=0(root) میدهد. چه چیزی میدانی و چه چیزی را نباید از همین خط نتیجه بگیری؟
Inside a container, id reports uid=0(root). What do you know, and what should you not infer from that line alone?
شناسه را با مرز میزبان یکی نکن · Don't equate identity with the host boundary
میدانی فرایند در namespace کانتینر UID صفر دارد. هنوز نمیدانی namespaceها، capabilityها، mountها و تنظیم daemon چه محدودیتی میسازند؛ پس نه «قطعاً root میزبان است» درست است و نه «بیخطر است». برای برنامهٔ وب همچنان UID غیرصفر را انتخاب کن.
You know the process has UID zero in the container namespace. You do not yet know the combined effect of namespaces, capabilities, mounts, and daemon configuration; neither “definitely host root” nor “harmless” follows. For a web app, choose a nonzero UID.
۲. after USER: چرا نوشتن شکست خورد؟2. Why did writing fail after USER?
id نشان میدهد UID ۱۰۰۰۱ هستی؛ ls -ld /data مالک root root و مجوز 755 را نشان میدهد. برنامه برای ساخت فایل باید چه تغییری کند؟
id shows UID 10001; ls -ld /data shows owner root root and mode 755. What should change so the app can create a file?
بهجای تغییر هویت، مالکیت مسیر لازم را درست کن · Fix the required path's ownership
کاربر دیگری نمیتواند در پوشهٔ root-owned با ۷۵۵ فایل بسازد. فقط اگر نوشتن در `/data` واقعاً لازم است، آن را پیش از USER به کاربر app بده یا mount را با مالکیت/مجوز مناسب آماده کن. بقیهٔ فایلها را writable نکن و اول مطمئن شو این مسیر دادهٔ درست است.
A different user cannot create a file in a root-owned 755 directory. Only if `/data` genuinely needs writes, assign it to the app before USER or prepare the mount with correct ownership and permissions. Do not make other files writable, and verify that this is the right data path.
۳. چرا chmod 777 اصلاح خوبی نیست؟3. Why isn't chmod 777 a good fix?
تیم برای خلاصشدن از permission error، کل پوشهٔ داده را ۷۷۷ میکند. چه ایرادی دارد و چه بررسیای جایگزینش میکنی؟
The team sets the whole data directory to 777 to get past a permission error. What is wrong with that, and what checks should replace it?
دسترسی را به نیاز و هویت وصل کن · Tie access to identity and need
هر user داخل container میتواند در آن مسیر بنویسد و شاید فایلهای حساس را جایگزین کند؛ علاوه بر آن، عیب مالکیت پنهان میماند. با id هویت فرایند، با stat/ls -ld owner و mode را بخوان؛ مالکیت را به UID/GID برنامه بده و فقط مجوز حداقلی لازم را باز کن.
Every container user can write there and may replace sensitive files; the ownership bug also remains hidden. Check process identity and path owner/mode, assign the directory to the app UID/GID, and grant only the required permission.
۴. COPY فایل را به چه کسی میدهد؟4. Who owns a copied file by default?
Dockerfile یک اسکریپت را کپی میکند و بعد USER app میگذارد. اسکریپت لازم نیست writable باشد؛ چه شکلی روشن میکند؟
A Dockerfile copies a script and then sets USER app. The script need not be writable; which form makes that clear?
خواندنی برای app، مالکیت ثابت برای image · Readable to app, fixed in the image
COPY --chown=root:root server.py /app/server.py مالکیت را صریح میکند. اطمینان پیدا کن mode خواندن/اجرا کافی است؛ اگر مسیر داده جداست، فقط همان را با RUN chown app:app /var/lib/app آماده کن. اگر --chown را حذف کنی، فایلهای COPY معمولاً مالک root میشوند.
Use COPY --chown=root:root server.py /app/server.py to make ownership explicit. Ensure its mode allows reading/execution; prepare only the separate data path with RUN chown app:app /var/lib/app. Without --chown, copied files are normally root-owned.
۵. WORKDIR ساخت، پس مالک هم درست است؟5. Does WORKDIR set the right ownership?
Dockerfile میگوید WORKDIR /app و بعد USER app. برنامه میخواهد فایل source را در `/app` بازنویسی کند؛ چه فرضی باید بررسی کنی؟
The Dockerfile says WORKDIR /app and then USER app. The app wants to rewrite source files in `/app`; what assumption should you check?
وجود پوشه به معنی مالکیت writable نیست · Existence does not imply write access
WORKDIR پوشهٔ غایب را میسازد، اما بهتنهایی تضمین نمیکند مالکیت آن با app سازگار باشد. با stat/ls -ld بررسی کن؛ با توجه به نیاز، یا آنچه در `/app` مینویسی را به مسیر داده منتقل کن یا مالکیت فقط همان مسیر لازم را قبل از USER تنظیم کن.
WORKDIR creates a missing directory, but does not by itself guarantee app-compatible ownership. Inspect it. Depending on the requirement, move runtime data out of `/app` or set ownership only on the path that must be writable before USER.
۶. root داخل container؛ دو گزارهٔ غلط6. Container root: two false claims
کسی میگوید «container root دقیقاً root میزبان است»؛ دیگری میگوید «چون container است، root خطری ندارد». با کدام مدل پاسخ میدهی؟
One person says “container root is exactly host root”; another says “root is harmless because it is in a container.” What model answers both?
namespace مهم است؛ اختیار اضافه هم مهم است · Namespaces matter; added authority matters too
UID صفر در namespace کانتینر بهطور خودکار معادل root میزبان نیست؛ اما فرایند همچنان اختیارهای بیشتری داخل محیطش دارد. میزبان mounts، Docker socket، privileged و آسیبپذیری هسته میتوانند مرزها را ضعیف کنند. پس دسترسی را کم کن و محیط واقعی را بررسی کن، نه اینکه یکی از دو شعار را بپذیری.
UID zero in a container namespace is not automatically host root, but the process still has more authority inside its environment. Host mounts, the Docker socket, privileged mode, and kernel vulnerabilities can weaken boundaries. Reduce access and inspect the actual setup rather than accepting either slogan.
۷. read-only و مسیر نوشتن7. Read-only and the write path
برنامه پس از افزودن --read-only هنگام نوشتن cache در `/tmp` خطا میدهد. cache لازم نیست باقی بماند. چه تغییری با نیازش جور است؟
After adding --read-only, the app fails while writing a cache under `/tmp`. The cache need not persist. What change fits the requirement?
فقط همان مسیر موقت را باز کن · Open only that temporary path
`/tmp` را به tmpfs محدود mount کن و مالکیت UID/GID برنامه را درست بده. root filesystem را writable نکن. بعد ثابت کن cache واقعاً بعد از توقف لازم نیست و سقف tmpfs با حافظهٔ سرویس سازگار است.
Mount only `/tmp` as a bounded tmpfs and give it the app UID/GID. Do not make the root filesystem writable. Verify the cache need not survive shutdown and that tmpfs sizing fits the service memory budget.
۸. کدام data باید volume شود؟8. Which data belongs in a volume?
یک برنامه هم thumbnail موقت میسازد و هم فایلهای آپلودشدهٔ کاربر را نگه میدارد. برای هرکدام چه نوع mountی محتملتر است؟
An app creates temporary thumbnails and stores user uploads. Which mount type is more appropriate for each?
چرخهٔ عمر داده انتخاب را تعیین میکند · Data lifecycle decides
thumbnail قابلبازسازی و موقت میتواند در tmpfs محدود باشد، اگر اندازهاش کنترل شود. upload کاربر باید در volume یا ذخیرهسازی بیرونیِ پشتیبانگیریشده بماند. حجم را صرفاً چون برنامه نوشتن میکند انتخاب نکن؛ مالک داده، پشتیبان و نیاز ماندگاری را روشن کن.
Recreatable temporary thumbnails may use a bounded tmpfs if their size is controlled. User uploads belong in a backed-up volume or external storage. Do not choose a volume merely because the app writes; establish data ownership, backups, and persistence requirements.
۹. writable volume، با rootfs فقطخواندنی9. Writable volume with a read-only root
container با --read-only است ولی برنامه هنوز در volume متصلشده مینویسد. تناقض است؟
The container uses --read-only, yet the app can still write to a mounted volume. Is that contradictory?
نه؛ هر mount مرز مستقلی دارد · No; each mount has its own setting
نه. --read-only فایلسیستم اصلی را فقطخواندنی میکند؛ volume جداگانه با حالت writable استثناست. inspect کن mount کدام مسیر را پوشانده و آیا واقعاً لازم است؛ در صورت نیاز volume را جداگانه read-only تعریف کن.
No. --read-only makes the root filesystem read-only; a separately mounted volume can remain writable. Inspect which path is mounted and whether writes are required; make the volume itself read-only if they are not.
۱۰. --cpus=0.5 تضمین میکند چه چیزی؟10. What does --cpus=0.5 guarantee?
سرویس از ۵۰٪ CPU استفاده میکند و کند میشود. آیا یک نیمهستهٔ رزروشده دارد؟
The service is limited to 50% CPU and gets slower. Does it have half a core reserved?
سقف مصرف با رزرو فرق دارد · A cap is not a reservation
نه؛ این محدودیت سقف زمان پردازنده است، نه رزرو ثابت یا تضمین latency. فشار را با docker stats ببین و اثرش را روی latency و صف اندازه بگیر. CPU را برای پاسخگویی سرویس بهتنهایی درست نکن.
No. It caps CPU time; it does not reserve a core or guarantee latency. Check usage with docker stats and measure latency and queueing. A CPU cap alone does not make the service responsive.
۱۱. PIDs limit خطای ساخت worker میدهد11. A PID limit blocks worker creation
بعد از تنظیم --pids-limit=16 برنامه نمیتواند همهٔ workerهایش را بسازد. کدام تشخیص از «Docker bug است» بهتر است؟
After setting --pids-limit=16, the app cannot create all its workers. What diagnosis is better than “Docker is broken”?
سقف را با تعداد واقعی taskها مقایسه کن · Compare the cap with real task needs
عدد ۱۶ ممکن است کمتر از workerها، child processها و taskهای لازم باشد. state و log را بخوان، تعداد taskها و نیاز اوج را در بار نماینده بسنج، سپس سقف را بهاندازهٔ نیاز بهاضافهٔ حاشیهٔ حسابشده تغییر بده. به unlimited برنگرد مگر دلیل روشن داشته باشی.
Sixteen may be below the required workers, child processes, and tasks. Read state and logs, measure task count and peak needs under representative load, then size the cap with an explicit margin. Do not revert to unlimited without evidence.
۱۲. memory cap از ۱۲۸ مگابایت گذشت12. A memory cap of 128 MB was reached
در فصل ۱۲ دیدی OOMKilled=true پس از allocation رخ داد. چه دو چیزی را قبل از بالا بردن limit ثبت میکنی؟
In Chapter 12 you saw OOMKilled=true after an allocation. What two things should you record before raising the limit?
شواهد مصرف و اندازهٔ کار واقعی · Usage evidence and real workload size
حد مصرف جاری/اوج، `docker stats` و limit ثبتشده در inspect را کنار زمان و درخواست شکستخورده بگذار؛ سپس بررسی کن آیا رشد حافظه نشت است یا اندازهٔ دادهٔ معتبر. allocation را با همان بار تکرار و نتیجه را بسنج. صرفاً از روی ۱۳۷ یا یک بار OOM عدد production انتخاب نکن.
Correlate current/peak usage, docker stats, and the inspected limit with timing and the failed request; then determine whether growth is a leak or valid workload size. Repeat with representative input and verify. Do not choose a production value from 137 or one OOM alone.
۱۳. پس از افزودن cap، چه شد؟13. What changed after adding a cap?
docker inspect مقدار HostConfig.NanoCpus=500000000 و Memory=134217728 میدهد. این دو عدد را به زبان ساده بخوان.
docker inspect reports HostConfig.NanoCpus=500000000 and Memory=134217728. Interpret the two values.
پیکربندی با واحد خام ذخیره شده · Configuration is recorded in raw units
NanoCPU یکمیلیاردم CPU است؛ ۵۰۰ میلیون یعنی نیم CPU سقف. حافظه بر حسب بایت است و ۱۳۴۲۱۷۷۲۸ بایت برابر ۱۲۸ MiB است. اینها پیکربندیاند نه مصرف زنده؛ برای مصرف جاری `docker stats` را ببین.
NanoCPU is one-billionth of a CPU, so 500 million is a half-CPU cap. Memory is in bytes; 134,217,728 bytes is 128 MiB. These values describe configuration, not live usage; use docker stats for current consumption.
۱۴. چرا Docker socket خطر را عوض میکند؟14. Why does the Docker socket change the risk?
یک web service به /var/run/docker.sock bind mount شده و کاربرش non-root است. آیا non-root بودن خطر این mount را از بین میبرد؟
A web service has /var/run/docker.sock bind-mounted and runs as non-root. Does non-root remove the risk of that mount?
daemon اختیار جداگانه و بزرگی است · The daemon is a separate, powerful authority
خیر؛ کاربر دارای دسترسی به API socket میتواند اختیار daemon را بهکار بگیرد، از جمله ساخت container با mountهای میزبان، بسته به daemon و مجوز سوکت. UID پایین داخل برنامه دسترسی API را خنثی نمیکند. socket را بردار و اگر automation لازم است یک واسط بسیار محدود و بررسیشده طراحی کن.
No. A process able to use the API socket can exercise daemon authority, including creating containers with host mounts depending on daemon and socket access. A low UID does not neutralize API access. Remove the socket; if automation is required, design a narrowly scoped, reviewed intermediary.
۱۵. «برای حلش privileged کنیم»15. “Let's use privileged to fix it”
برنامه روی پورت ۸۰ bind نمیشود. همکارت --privileged پیشنهاد میدهد. قبل از تغییر چه میگویی؟
The app cannot bind port 80. A teammate suggests --privileged. What do you say before changing anything?
نیاز دقیق را جدا کن؛ اختیار گسترده جواب پیشفرض نیست · Isolate the need; broad authority is not the default
بپرس آیا میتوان برنامه را روی پورت بالاتر مثل ۸۰۸۰ اجرا و از میزبان port نگاشت کرد. privileged اختیارها و deviceهای زیادی را میگشاید و ارتباطی با یک bind port خاص ندارد. اگر نیاز واقعی به capabilityی هست، فقط همان اختیار مشخص را ارزیابی کن و آزمون را تکرار کن.
Ask whether the app can listen on a higher port such as 8080 and use host port mapping. Privileged opens broad capabilities and devices; it is not a targeted fix for one port. If a capability is genuinely required, evaluate only that one and repeat the test.
۱۶. کدام resource key در Compose محلی؟16. Which resource keys for local Compose?
فایل فقط قرار است با docker compose up روی یک Docker Engine محلی اجرا شود. برای سقف معمول CPU، حافظه و PID از چه کلیدهایی شروع میکنی و چه چیزی را بررسی میکنی؟
The file will run only with docker compose up against a local Docker Engine. Which keys start the CPU, memory, and PID caps, and what do you verify?
کلیدهای سرویس فعلی و بعد inspect · Current service keys, then inspect
از cpus، mem_limit و pids_limit در service شروع کن؛ deploy.resources را بدون فهم پلتفرم universal فرض نکن. ابتدا docker compose config و بعد HostConfig همان container را inspect کن تا parse و مقدار اعمالشده را از هم جدا کنی.
Start with service-level cpus, mem_limit, and pids_limit; do not assume deploy.resources is universally implemented without checking the platform. Run docker compose config, then inspect the container's HostConfig to distinguish parsing from the applied values.
۱۷. سرویس چند writes دارد؛ harden را طرح کن17. Design hardening for a service with several write paths
سرویس فایل برنامه را میخواند، session موقت میسازد، فایل گزارش کاربر را نگه میدارد و health مسیر سرویس دارد. مسیرها را چهطور دستهبندی میکنی؟
The service reads app files, creates temporary sessions, retains user report files, and has a health endpoint. How do you classify its paths?
read-only کجا، tmpfs کجا، volume کجا؟ · Rootfs, tmpfs, or volume?
فایل برنامه فقطخواندنی باشد؛ session موقت در tmpfs محدود با owner درست؛ گزارش کاربر در volume یا ذخیرهسازی بیرونی با پشتیبان و چرخهٔ عمر مشخص. healthcheck باید مسیر سرویس واقعی را بدون نوشتن داده بسنجد. بعد از هر mount، مالکیت و writable بودن همان مسیر را با درخواست واقعی تأیید کن.
Keep app files read-only; place temporary sessions in a bounded tmpfs with correct ownership; store user reports in a backed-up volume or external store with a defined lifecycle. The healthcheck should probe the real endpoint without writing data. Verify ownership and write behavior at each mount with a real request.
۱۸. پنج تغییر با هم و healthcheck قرمز18. Five changes at once and a failing healthcheck
non-root، read-only، tmpfs، memory cap و PID cap را یکباره اضافه کردی. حالا سرویس unhealthy است. چهطور از حدسزدن فاصله میگیری؟
You added non-root, read-only, tmpfs, a memory cap, and a PID cap all at once. Now the service is unhealthy. How do you stop guessing?
شواهد را بخوان و تغییر را مرحلهای برگردان · Read evidence and isolate one change
وضعیت health و خروجی check، log برنامه، identity و owner مسیرها، mountهای واقعی و limitهای inspect را جمع کن. اگر stdout/health log میگوید permission denied، owner یا mount را بررسی کن؛ اگر OOMKilled است memory مدرک را دنبال کن. یک تغییر را در هر نوبت اصلاح کن و پس از هر قدم همان مسیر سرویس را دوباره بسنج.
Collect health status and check output, app logs, identity and path ownership, actual mounts, and inspected limits. If output says permission denied, inspect ownership or mounts; if OOMKilled is true, follow memory evidence. Change one thing at a time and retest the same endpoint after every step.
آزمایشگاه: یک سرویس سالم را محدود کن، بدون اینکه کورکورانه خرابش کنیLab: harden one web service, one step at a time
یک web service کوچک داریم که healthcheck دارد و روی مسیر داده یک شمارنده مینویسد. اول وضعیت پایه را ثبت میکنیم، بعد non-root، ownership درست، read-only، مسیر نوشتنی و limitهای منابع را یکییکی اضافه میکنیم. دو خطا را هم عمداً میسازیم تا permission و read-only را با هم اشتباه نگیریم.
We will build a small HTTP server that answers `/health` and writes a counter into a data directory on `/` requests. That behavior lets us distinguish permission errors from read-only errors. The app uses no real secrets, and we will not drive memory or CPU with unbounded load.
در هر مرحله دو شاهد لازم داریم: تنظیم واقعاً اعمال شده و برنامه هنوز کار اصلیاش را انجام میدهد. فقط سبزشدن healthcheck کافی نیست اگر درخواست واقعی که باید داده بنویسد شکست بخورد.
This lab targets Linux containers; Docker Desktop on Windows runs them in a managed Linux environment. Before starting, check docker ps -a for the names `sec13-base`, `sec13-user`, `sec13-ro`, and `sec13-hardened`. If any name already exists, choose a fresh name consistently throughout the lab; do not delete an existing container just to free a name. In PowerShell, use curl.exe so the command is not confused with a possible curl alias.
در این فصل fork bomb، فشار حافظهٔ میزبان یا حلقهٔ CPU نامحدود نمیسازیم. هر بار که بار آزمایشی لازم شد، آن را فقط داخل container با سقف صریح اجرا کن و راه توقفش را از قبل بدان. تمرین فعلی هم عمداً بار سنگین تولید نمیکند.
We will not create fork bombs, host-wide memory pressure, or unbounded CPU loops. If a workload experiment is needed, constrain it inside the container and know how to stop it beforehand. This lab deliberately generates no heavy load.
۱. ساخت پوشه و برنامه1. Create the project directory and app
در پوشهای تازه، فایل server.py زیر را بساز. server رویدادها را روی stdout مینویسد؛ فایل شمارنده فقط برای تمرین مسیر نوشتن است. مسیر state از متغیر محیطی میآید تا بتوانیم آن را بین rootfs و tmpfs جابهجا کنیم.
In a fresh directory, create server.py below. The server writes operational events to stdout; the counter file exists only to exercise a write path. The state path comes from an environment variable so we can move it between rootfs and tmpfs.
from http.server import BaseHTTPRequestHandler, HTTPServer
import os
from pathlib import Path
STATE_DIR = Path(os.environ.get("STATE_DIR", "/var/lib/app"))
BREAK_HEALTH = os.environ.get("BREAK_HEALTH", "0") == "1"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
code = 500 if BREAK_HEALTH else 200
body = b"not ready\n" if BREAK_HEALTH else b"healthy\n"
self.send_response(code)
self.end_headers()
self.wfile.write(body)
print(f"health status={code}", flush=True)
return
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
visits = STATE_DIR / "visits.txt"
count = int(visits.read_text()) if visits.exists() else 0
visits.write_text(str(count + 1))
self.send_response(200)
body = f"visit {count + 1}\n".encode()
except OSError as exc:
self.send_response(500)
body = f"write failed: {exc}\n".encode()
print(f"write_error path={STATE_DIR} error={exc}", flush=True)
self.end_headers()
self.wfile.write(body)
print(f"request path={self.path} method={self.command}", flush=True)
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()پیام آخر عمداً `self.دستور` را نشان میدهد، نه status HTTP؛ در مرور log این تفاوت را ببین. status واقعی در پاسخ و خط health یا write_error هست. حالا Dockerfile اولیه را با پوشهٔ دادهٔ root-owned بساز:
The last log line deliberately shows `self.command`, not the HTTP status; notice that distinction when reading logs. The actual status is in the response and the health or write_error message. Now create the initial Dockerfile with a root-owned data directory:
FROM python:3.12-alpine RUN addgroup -S -g 10001 app \ && adduser -S -D -H -u 10001 -G app app \ && mkdir -p /app /var/lib/app \ && chown root:root /app /var/lib/app WORKDIR /app COPY server.py /app/server.py ENV STATE_DIR=/var/lib/app EXPOSE 8080 USER app:app CMD ["python", "/app/server.py"]
۲. Version A: وضعیت پایه قابلمشاهده2. Version A: observable baseline
میسازیم و عمداً کاربر را به root برمیگردانیم تا وضعیت پایه با درخواست موفق داشته باشیم. این override فقط برای مقایسهٔ آزمایشگاهی است و الگوی استقرار نیست.
Build and deliberately override the user to root so we have a successful baseline request. This override is for the lab comparison only; it is not a deployment pattern.
docker build -t sec13-web .
docker run -d --name sec13-base --user 0:0 -p 8088:8080 sec13-web
docker exec sec13-base sh -c 'whoami; id'
curl -i http://localhost:8088/
docker inspect sec13-base --format '{{.Config.User}} readOnly={{.HostConfig.ReadonlyRootfs}} memory={{.HostConfig.Memory}} pids={{.HostConfig.PidsLimit}}'در image، USER app وجود دارد ولی اجرای وضعیت پایه با --user 0:0 آن را override میکند. انتظار داریم root، پاسخ ۲۰۰ و rootfs نوشتنی ببینیم؛ مقدارهای صفر برای memory/PID در inspect یعنی این فرمان سقف مشخصی نداده است، نه اینکه سرویس هیچ منبعی مصرف نمیکند.
The image defines USER app, but the baseline overrides it with --user 0:0. Expect root, an HTTP 200, and a writable root filesystem. Zero memory/PID values in this inspection mean this command set no explicit cap—not that the service consumes no resources.
۳. non-root را فعال کن؛ شکست مجوز را بخوان3. Enable non-root and read the permission failure
container وضعیت پایه را فقط پس از ثبت خروجی متوقف و حذف کن. حالا همان image را با کاربر پیشفرض اجرا کن؛ فایلهای برنامه دستنخوردهاند، اما مسیر داده root-owned مانده است.
After recording the baseline, stop and remove only that lab container. Now run the same image as its default user: app files are unchanged, but the data directory is still root-owned.
docker rm -f sec13-base docker run -d --name sec13-user --user 10001:10001 -p 8088:8080 sec13-web docker exec sec13-user sh -c 'whoami; id; ls -ld /var/lib/app' curl -i http://localhost:8088/ docker logs sec13-user
درخواست health ممکن است سالم باشد، ولی `/` برای نوشتن ۵۰۰ میدهد؛ فرایند زنده است و اشکال دقیقاً در مجوز مسیر داده است. پوشه را میتوانیم در build به user بدهیم. برای پاکسازی همین نمونه، container را حذف کردهایم؛ هیچ دادهٔ مهمی نگه ندار.
The health request may succeed while `/` returns 500 on its write; the process is alive and the data-path permission is the specific failure. We can assign the directory to the user at build time. This sample is disposable, so no important data should be stored here.
RUN addgroup -S -g 10001 app \ && adduser -S -D -H -u 10001 -G app app \ && mkdir -p /app /var/lib/app \ && chown app:app /var/lib/app \ && chown root:root /app WORKDIR /app COPY --chown=root:root server.py /app/server.py ENV STATE_DIR=/var/lib/app USER app:app
docker rm -f sec13-user docker build -t sec13-web . docker run -d --name sec13-user --user 10001:10001 -p 8088:8080 sec13-web curl -i http://localhost:8088/
بعد از ثبت خطای مالکیت، container قبلی را حذف و این نسخه را rebuild میکنیم. پاسخ همان درخواست باید ۲۰۰ شود. مالکیت app فقط روی مسیر داده است؛ فایل برنامه root-owned و خواندنی مانده. حالا non-root را با مدرک هویت و رفتار نوشتن سنجیدهایم.
After rebuilding, repeat the same user and request and expect HTTP 200. Only the data path belongs to app; the application file stays root-owned and readable. We have verified non-root using identity and actual write behavior.
۴. rootfs را ببند؛ خطای دوم را جدا تشخیص بده4. Lock rootfs and diagnose the second failure separately
حالا `--read-only` را اضافه کن ولی `STATE_DIR` را تغییر نده. درخواست `/` دوباره شکست میخورد؛ این بار پوشه owner درست است، اما لایهٔ اصلی فقطخواندنی است. این همان شکستی نیست که در قدم قبل داشتیم.
Now add `--read-only` but leave `STATE_DIR` unchanged. `/` fails again; this time directory ownership is correct, but the root layer is read-only. This is not the same failure as the previous step.
docker rm -f sec13-user
docker run -d --name sec13-ro --read-only \
--user 10001:10001 -p 8088:8080 sec13-web
curl -i http://localhost:8088/
docker logs sec13-ro
docker inspect sec13-ro --format 'readonly={{.HostConfig.ReadonlyRootfs}}'لاگ یا response باید «Read-only file system» را نشان دهد و inspect مقدار true را. تغییر مالکیت دوباره راهحل نیست؛ دادهٔ این نمونه لازم نیست پایدار بماند. پس `STATE_DIR=/tmp` میگذاریم و فقط `/tmp` را tmpfs میکنیم.
The log or response should show “Read-only file system,” and inspect should report true. Changing ownership again is not the fix; this sample's data need not persist. Set STATE_DIR=/tmp and mount only `/tmp` as tmpfs.
۵. فایلسیستم نوشتنیِ کوچک و سقف منابع5. Add one small write mount and resource ceilings
قبل از اجرای نهایی، container شکستخورده را پس از ثبت شواهد حذف کن. این بار یک rootfs فقطخواندنی، tmpfs شانزده مگابایتی با مالکیت درست، سقف ۱۲۸ مگابایت memory، نیم CPU و ۶۴ PID میدهیم. این عددها برای آزمایشاند، نه نسخهٔ آمادهٔ production.
After recording evidence, remove the failed container. This time use a read-only rootfs, a 16 MB tmpfs with correct ownership, a 128 MB memory ceiling, half a CPU, and 64 PIDs. These are lab values, not production recommendations.
docker rm -f sec13-ro docker run -d --name sec13-hardened -p 8088:8080 \ --user 10001:10001 --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001 \ --memory=128m --cpus=0.50 --pids-limit=64 \ -e STATE_DIR=/tmp sec13-web
هیچ volumeای اضافه نکردیم، چون شمارندهٔ آموزشی موقتی است. اگر محصول واقعی باید آن داده را پس از جایگزینی container نگه دارد، پیش از افزودن volume مالکیت mount، پشتیبان و چرخهٔ حذفش را تعیین کن. فقط کلمهٔ «داده» بهتنهایی دلیل ماندگاری نیست.
We did not add a volume because this teaching counter is temporary. If a real product must retain that data after container replacement, define mount ownership, backups, and deletion lifecycle before adding a volume. Calling something “data” alone does not make it persistent.
۶. درخواست، healthcheck و حدود واقعاً اعمالشده6. Verify the request, healthcheck, and applied limits
Healthcheck را بیرون از برنامه نگه میداریم تا آزمایش همچنان از shell/python موجود در image استفاده کند. `docker run` سلامت را با --health-cmd هم میتواند بگیرد؛ برای این تمرین بهتر است Compose فایل نهایی را ثبت کند. معادل Compose زیر از همان تنظیمها استفاده میکند:
Keep the healthcheck outside the app so it uses the shell/Python already present in the image. Docker can also set it through --health-cmd, but for this exercise Compose records the final definition. The Compose equivalent uses the same controls:
services:
web:
build: .
ports: ["8088:8080"]
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001
environment:
STATE_DIR: /tmp
BREAK_HEALTH: "0"
mem_limit: 128m
cpus: 0.50
pids_limit: 64
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=1)"]
interval: 10s
timeout: 2s
retries: 3
start_period: 5sفایل را با نام compose.yaml کنار Dockerfile بگذار. برای اجرای نسخهٔ Compose، container اجرای مستقیم را پس از ثبت نتیجه حذف کن تا پورت آزاد شود. بعد فرمانهای زیر را اجرا کن. در PowerShell برای curl از curl.exe استفاده کن؛ curl ممکن است alias متفاوتی باشد. در PowerShell شناسه را جدا در متغیر بگذار، چون نمونهٔ گرفتن container ID پایین Bash است.
Save the file as compose.yaml beside the Dockerfile. After recording the direct-run result, remove that lab container to free the port, then run the commands below. In PowerShell use curl.exe; curl may be a different alias. Store the container ID separately in PowerShell because the ID command below uses Bash syntax.
docker rm -f sec13-hardened docker compose config docker compose up -d --build docker compose ps curl -i http://localhost:8088/
در پایان، در Compose مقدار BREAK_HEALTH را از "0" به "1" تغییر بده و با docker compose up -d --force-recreate web سرویس را بازسازی کن. انتظار: برنامه همچنان پاسخ `/` را میدهد ولی healthcheck پس از چند نوبت unhealthy میشود. این آزمایش ثابت میکند health، فرایند state و فایلسیستم read-only مفاهیم جدا هستند. بعد مقدار را به "0" برگردان، دوباره recreate کن و بازگشت health به healthy را تأیید کن.
Finally, change BREAK_HEALTH in Compose from "0" to "1" and recreate the service with docker compose up -d --force-recreate web. The app should still answer `/` while the healthcheck becomes unhealthy after retries. This demonstrates that health, process state, and a read-only filesystem are distinct concepts. Restore "0", recreate, and verify health returns to healthy.
cid=$(docker compose ps -q web)
docker inspect "$cid" --format 'user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} mem={{.HostConfig.Memory}} nanoCPU={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}} health={{.State.Health.Status}}'
docker inspect "$cid" --format '{{json .Mounts}}'
curl -i http://localhost:8088/
docker compose logs --tail 20 web
docker stats --no-stream "$cid"برای مقایسه وضعیت پایه و hardened، `whoami/id`، پاسخ همان `/`، نتیجهٔ health، تنظیمات inspect، مصرف `docker stats` و خطاهایی که در دو خطا دیدی کنار هم بگذار. healthcheck ممکن است به پورت میزبان نیاز نداشته باشد چون از داخل container به loopback آن میزند. هیچ secret واقعی در آزمایش وارد نکن.
Compare baseline and hardened using identity, the same `/` response, health result, inspected configuration, `docker stats`, and the two deliberate failure messages. The healthcheck does not need a published host port because it calls the container's loopback. Do not use real secrets in this lab.
پس از ذخیرهٔ شواهد، فقط containerهای مخصوص این تمرین را متوقف و حذف کن. اگر از Compose استفاده کردی، volumeای تعریف نشده؛ docker compose down برای همان پروژه کافی است. اگر اجرای مستقیم داشتی، فقط نامهای lab خودت را حذف کن. فرمان کلی prune لازم نیست و چیزی از سیستم پاک نمیکنیم.
After saving evidence, stop and remove only containers created for this lab. If you used Compose, no volume was defined; docker compose down for this project is sufficient. For direct runs, remove only your lab containers by name. No global prune is needed and we will not delete unrelated data.
امنیت یک کلید روشن/خاموش نیست؛ چند مرز کوچک و قابلسنجش استSecurity is not one option; it is a set of deliberate boundaries
از root به non-root رفتیم، نوشتن را به مسیرهای لازم محدود کردیم و مصرف منابع را سقف گذاشتیم. هیچکدام بهتنهایی «امنیت کامل» نیستند؛ کنار هم فقط اختیار و دامنهٔ اثر یک خطا را کمتر میکنند.
We started with a baseline and answered one question at a time: which UID runs the app? Where must it write? How much memory, CPU, and how many PIDs does it need? Each answer created a boundary, and the healthcheck plus real request told us whether the app still worked afterward. When something failed, we diagnosed it instead of disabling every control at once.
این فصل را با همان پرسش تمام کن که از اول داشتیم: «اگر این فرایند فردا خراب شد، چه چیزهایی هنوز اجازه دارد تغییر بدهد؟» هرچه پاسخ دقیقتر و محدودتر باشد، معماری قابلاعتمادتر است.
Remember: non-root reduces process authority; read-only reduces writable paths; resource caps constrain resource pressure. None fixes app bugs and none is a complete sandbox alone. The recurring final question remains: “What access does this application actually need?”
در کار واقعی، این لایهها را کنار بهروزرسانی image، بررسی وابستگیها، مدیریت secret و سیاستهای میزبان میگذاریم. SELinux، AppArmor، seccomp و rootless Docker مرحلههای بعدیاند. برای امروز، کافی است اختیار، مسیر نوشتن و مصرف را با شواهد تعریف کرده باشی.
In real deployments, combine these layers with image updates, dependency review, secret management, and host policy. SELinux, AppArmor, seccomp, and rootless Docker are later steps. For now, define authority, writable paths, and resource use from evidence.
مرجع سریع: درخواست در کنار شواهدQuick reference: pair a request with evidence
whoami; idهویت واقعی فرایند را بپرسCheck the process identityUSER app:appکاربر پیشفرض runtime در imageSet the image's default runtime userCOPY --chown=user:groupمالکیت فایل کپیشده را صریح کنSet copied-file ownership explicitly--read-onlyroot filesystem را ببند؛ mountها را جدا بررسی کنLock rootfs; inspect mounts separately--tmpfs /tmp:...,size=16mنوشتن موقت و محدودBounded temporary writes--memory --cpus --pids-limitحد مصرف را بده و inspect کنSet and inspect resource ceilingsdocker stats --no-streamمصرف جاری را با limit اشتباه نگیرCompare current use with configured limitsno-new-privileges / cap-dropلایهٔ اختیاری؛ پس از سنجش سازگاریOptional layers; test compatibility--privileged / docker.sockاختیار گسترده؛ راهحل پیشفرض نیستBroad authority; never a default fixبرای syntax فعلی، مستند رسمی Docker را دربارهٔ USER و COPY --chown، docker run، read-only و resource flags، tmpfs و مدل امنیت Engine ببین. مرجع جاری Compose service attributes تنظیم محلی و deploy specification رفتار وابسته به پلتفرم را توضیح میدهند. برای privileged و no-new-privileges، امنیت اجرای container را بخوان.
For current syntax, see the official Docker references for USER and COPY --chown, docker run, read-only, and resource flags, tmpfs, and the Engine security model. The current Compose service attributes reference describes local service settings; the deploy specification describes platform-dependent behavior. For privileged and no-new-privileges, consult container security configuration.