قانونی که شما نوشتید، و قانونی که Docker نوشت
دو برنامه دارند یک فایروال یکسان را با دو مفروضهٔ متفاوت ویرایش میکنند، و فقط یکی از آنها به شما خبر میدهد. ufw قوانین خودش را در زنجیرهٔ INPUT از جدول filter مینویسد — مسیری که یک بسته طی میکند وقتی مقصدش خودِ هاست است. Docker در جدول nat و در FORWARD مینویسد — مسیری که یک بسته طی میکند وقتی مقصدش جای دیگری است.
یک بسته را دنبال کنید تا شکاف کاملاً روشن شود. یک نفر در کشوری دیگر به آدرس شما روی پورت 5432 وصل میشود. بسته میرسد، و اولین چیزی که با آن روبهرو میشود nat PREROUTING است، که آن را به زنجیرهٔ DOCKER در Docker میفرستد. آنجا یک قانون DNAT مقصد را به 172.17.0.2:5432 بازمینویسد — یعنی خودِ کانتینر. بسته دیگر به آدرس سرور شما نیست، پس کرنل آن را مسیریابی میکند بهجای تحویل محلی: از FORWARD رد میشود، جایی که Docker از قبل یک ACCEPT برای ترافیکِ روبهسوی یک پورتِ منتشرشده نصب کرده است. در هیچ نقطهای از این مسیر، بسته از INPUT رد نمیشود — همان تنها زنجیرهای که ufw فیلترش میکند. قانون شما هیچوقت اشتباه نبود. فقط هیچوقت از آن پرسیده نشد.
به همین دلیل هم این علامت اینقدر گیجکننده است. ufw status verbose همچنان گزارش میدهد Default: deny (incoming)، با هیچ چیز مجاز جز 80، 443 و پورت SSH شما، درحالیکه یک اسکنر در قارهای دیگر یک نشست باز به پایگاهدادهٔ شما دارد. هر دو جمله درستاند. فایروال دقیقاً همان کاری را میکند که پیکربندی کردهاید، روی ترافیکی که به آن داده شده، و ترافیکی که اهمیت دارد اصلاً به آن داده نمیشود.
هیچکدام اینها باگ نیست، و هیچکدام هم بیدقتیِ Docker نیست. یک container engine مجبور است قوانین NAT و forwarding را برنامهریزی کند وگرنه کانتینرها اصلاً نمیتوانند به شبکه برسند، و نمیتواند با اطمینان حدس بزند کدامیک از قوانین فایروال هاست شما قرار بوده رویشان اعمال شود. پس کار صادقانه را انجام میدهد: زنجیرههای خودش را مدیریت میکند، و یک زنجیرهٔ اختصاصی در اختیار شما میگذارد — DOCKER-USER، که پیش از هر چیز دیگری در FORWARD ارزیابی میشود — و قول میدهد هرگز آن را بازنویسی نکند. شکاف، وجودِ خودِ این مکانیزم نیست. شکاف این است که -p 5432:5432 شبیه "این را در دسترس بگذار" خوانده میشود اما معنایش این است: "این را به هر آدرسی که این ماشین به آن پاسخ میدهد منتشر کن، آنسوترِ همان فایروالی که یک ساعت وقت گذاشتی بنویسی".
یک پورت در معرض، از بیرون چه شکلی است
اینترنت سریعتر از آن چیزی که فکر میکنید متوجه میشود. بازههای هاستینگ بهطور پیوسته و کامل اسکن میشوند — نه از سوی کسی که به شما علاقهمند شده، بلکه توسط کراولرهای تجاری، پروژههای پژوهشی و باتنتهای فرصتطلبی که هر آدرس قابلمسیریابی را روی هر پورت جالبی جارو میکنند و نتیجه را منتشر یا میفروشند. فاصلهٔ میان docker compose up -d و اولین اتصال ناخواستهٔ به یک پورتِ تازهمنتشرشدهٔ پایگاهداده، با ساعت اندازهگیری میشود. هیچکس لازم نبود hostname شما را حدس بزند. هیچکس لازم نبود اسمتان را بداند. آدرس در همان بازه بود.
چیزی که پیدا میکنند کاملاً به آنچه منتشر کردهاید بستگی دارد، و حالتهای رایج دلگیرند. یک کانتینر PostgreSQL یا MySQL که از قطعهکد quick-start یک ایمیج راه افتاده، با همان رمز عبورِ بیاهمیتِ همان قطعهکد که هنوز سرجایش مانده. یک نمونهٔ Elasticsearch یا MongoDB که هرگز برای خواستنِ احراز هویت پیکربندی نشده، چون قرار بوده فقط از کانتینرِ اپلیکیشن در دسترس باشد. یک memcached که به هر کسی پاسخ میدهد — که نهفقط یک نشتِ داده، بلکه تقویتکنندهای است که میتوان آن را بهسمتِ یک هدفِ دیگر نشانه رفت و سرور شما را به یکی از شرکتکنندگانِ همان نوع سیلی تبدیل کرد که در راهنمای اولین ساعتِ حملهٔ DDoS ما شرح داده شده. یک پنلِ مدیریتی یا داشبوردِ صفِ پیام که فرض را بر یک شبکهٔ خصوصی گذاشته بود. یک endpoint متریک که بیسروصدا هر سرویسِ داخلی، hostname و نسخهای را که اجرا میکنید فهرست میکند.
ارزش دارد آسیبِ درجهدوم را هم نام ببریم، چون آسیبِ درجهیک تمامِ صورتحساب نیست. یک پایگاهداده که از اینترنت قابلدسترسی است فقط قابلخواندن نیست — در بیشترِ موتورها قابلنوشتن هم هست، که یعنی نفوذگر برای نصبِ یک راهِ ماندگار به اکسپلویتِ بیشتری نیاز ندارد، و چند موتور را میشود وادار کرد از یک نشستِ دارای امتیاز، روی هاست فایل بنویسند یا دستور اجرا کنند. روی باکسی که دقیقاً به این دلیل انتخابش کردید که هیچ هویتی حمل نمیکند، یک نقطهٔ ورودِ بدوناحرازهویت هم پلی است به هر چیزِ دیگری که آن باکس با آن سروکار دارد: مقصدِ بکآپی که میتواند به آن برسد، کلیدهای داخلِ متغیرهای محیطیاش، و کانتینرهای دیگر روی همان bridge.
بخشِ ناراحتکننده این است که هیچکدام از اینها خودش را اعلام نمیکند. هیچ سطرِ لاگی نیست که بگوید "فایروال شما دور زده شد". سرویس کار میکند، اپلیکیشن وصل میشود، سایت بالاست، و تنها سیگنالِ بیرونی یک شمارندهٔ اتصال است که کسی تماشایش نمیکند. این افشا یا توسطِ خودتان، عمداً، در همان ده دقیقهٔ بعدی کشف میشود — یا توسطِ یک نفرِ دیگر، طبقِ برنامهٔ خودش.
SP·03بخوانید واقعاً چه چیزی را منتشر میکنید
از آنجا شروع کنید که خودِ engine فکر میکند دارد چهکار میکند. docker ps یک ستونِ PORTS چاپ میکند، و تمایزِ داخل آن، کلِ موضوعِ این راهنماست: 0.0.0.0:5432->5432/tcp یعنی هر آدرسی روی ماشین، 127.0.0.1:5432->5432/tcp یعنی فقط loopback، و یک 5432/tcp سادهٔ بدونِ فلش یعنی پورت به کانتینرهای دیگر expose شده و هیچجا منتشر نشده. پیش از آنکه چیزی را تغییر دهید، هر سطرِ آن ستون را روی هر کانتینر بخوانید.
بعد با ss -tulpen نگاهی به سوکتها بیندازید. روی یک نصبِ پیشفرض میبینید docker-proxy پورتهای منتشرشده را نگه داشته، چون Docker هنوز به ازای هر پورتِ منتشرشده یک پردازهٔ کوچک در فضای کاربر راه میاندازد. اینجا همان تلهای است که یک بعدازظهرِ آدم را میگیرد: اگر userland-proxy روی دیمنِ شما غیرفعال باشد — که یک تغییرِ تنظیمِ رایج است، و بعضی توزیعها همینطور عرضهاش میکنند — اصلاً هیچ سوکتِ listenکنندهای روی هاست وجود ندارد. ss هیچی نشان نمیدهد، lsof هیچی نشان نمیدهد، و پورت هنوز کاملاً باز است، چون قانونِ DNATِ کرنل دارد کار را انجام میدهد بدونِ آنکه هیچ پردازهای لازم باشد آدرس را نگه دارد. خروجیِ ساکتِ ss مدرکی برای بستهبودنِ یک پورت نیست.
پس خودِ قوانین را بخوانید. iptables -t nat -S DOCKER به ازای هر پورتِ منتشرشده یک سطرِ DNAT فهرست میکند، و هر سطر همان جوابی را که میخواهید حمل میکند: قانونی با -d 127.0.0.1/32 یعنی انتشار روی loopback، و قانونی بدونِ محدودیتِ مقصد روی هر آدرسی که ماشین دارد اعمال میشود. همین کار را با ip6tables هم انجام دهید، چون این دو خانواده مستقل از هم پیکربندی میشوند و یک باکس میتواند روی یکی سفت و روی دیگری باز باشد.
و در آخر — و این تنها مرحلهای است که واقعاً چیزی را ثابت میکند — از جایی دیگر به ماشین نگاه کنید. هر دستوری که تا اینجا اجرا کردید روی خودِ هاست اجرا میشود و دیدِ خودِ هاست از شبکهاش را به ارث میبرد. ترافیکِ loopback از زنجیرههای مهم رد نمیشود، پس موفقبودنِ curl 127.0.0.1:5432 هیچ چیزی دربارهٔ اینکه آیا یک غریبه میتواند همین کار را بکند به شما نمیگوید، و شکستخوردنش حتی کمتر میگوید. تستِ معتبر یک اسکن از یک ماشینِ دیگر روی یک شبکهٔ دیگر است، روی هر دو خانوادهٔ IP. هر چیزی پیش از آن فقط یک فرضیه است.
بایند به loopback، و تفاوت میان ports و expose
کوچکترین اصلاحِ مفید، یازده کاراکتر است. -p 127.0.0.1:5432:5432 به Docker میگوید قانونِ DNAT خودش را با یک محدودیتِ مقصد بنویسد، پس بازنویسی فقط روی ترافیکی اعمال میشود که از قبل محلی بوده. یک بستهٔ راهدور که بهسمتِ آدرسِ عمومیِ شما نشانه رفته دیگر با این قانون جور درنمیآید، به کانتینر فوروارد نمیشود، و بالاخره همانجایی میرسد که همیشه فکر میکردید میرسد: INPUT، جایی که ufw آن را رد میکند. در یک فایلِ Compose همین کار میشود ports: ["127.0.0.1:5432:5432"]، و گیومهها اهمیت دارند — یک مقدارِ بدونِ گیومه با دونقطه داخلش، تصادفِ parsingای است که منتظرِ رخدادن است.
اما سؤالِ بهتر این است که اصلاً چرا این پورت منتشر شده. کانتینرهایی که به یک شبکهٔ user-defined یکسان وصلاند، مستقیماً به هم میرسند، با نامِ سرویس، روی همان پورتِ خودِ کانتینر، بدونِ هیچگونه انتشاری. اپلیکیشنِ شما به 127.0.0.1:5432 وصل نمیشود؛ به postgres:5432 وصل میشود، که DNS داخلیِ Docker آن را به آدرسی روی bridge خصوصی resolve میکند. یک پایگاهداده در چنین چینشی هیچ خطِ ports:ای لازم ندارد — نه از نوعِ loopback، نه هیچ نوعِ دیگر. امنترین پورتِ منتشرشده، همان پورتی است که حذفش کردهاید. ports: را برای همان یکیدو سرویسی نگه دارید که واقعاً با عموم روبهرو هستند، و بگذارید بقیه روی شبکهٔ خصوصی حرف بزنند.
اینجا هم همان جایی است که expose: بد فهمیده میشود. نه چیزی منتشر میکند و نه چیزی باز میکند؛ صرفاً مستندسازی است که ثبت میکند یک سرویس روی چه پورتی listen میکند، و در هیچ جهتی روی فایروال اثری ندارد. آدمها آن را اضافه میکنند به این امید که نسخهٔ امنِ ports: باشد، که همینطور هم هست — دقیقاً به همان معنایی که یک کامنت نسخهٔ امنِ کد است. اگر میخواهید سرویسی فقط برای همسایههایش قابلدسترسی باشد، به expose: نیازی ندارید؛ به نبودِ ports: نیاز دارید.
دو محدودیتِ صادقانه برای بایند به loopback وجود دارد. اول، این کار مرزِ هاست را محافظت میکند، نه همسایگی را: کانتینرهای روی یک bridge network یکسان همچنان آزادانه به هم میرسند، پس یک کانتینرِ front-end که به خطر افتاده، مسیری باز به یک پایگاهداده دارد که هیچچیز منتشر نکرده. سرویسها را روی شبکههای جدا از هم پخش کنید و شبکهٔ بکاند را وقتی شعاعِ انفجار اهمیت دارد internal: true علامت بزنید. دوم، 127.0.0.1 یک آدرسِ IPv4 است و فقط IPv4 را محدود میکند؛ اگر هاست یک /64 قابلمسیریابی دارد — که هر پلن اینجا یکی میدهد — دربارهٔ v6 جداگانه فکر کنید، و جداگانه هم تستش کنید.
DOCKER-USER: زنجیرهای که دقیقاً برای همین وجود دارد
بایند به loopback همان کانتینرهایی را درست میکند که به یادشان هستید. DOCKER-USER راهی است که با آن دیگر فقط یک -p با حادثهٔ بعدی فاصله نداشته باشید. Docker آن را بهعنوانِ اولین jump در FORWARD نصب میکند، پیش از قوانینِ accept خودش، و — برخلافِ هر چیزِ دیگری در زنجیرههایش — محتوایش را در طولِ ریاستارتها، ارتقاها و کانتینرهای تازه دستنخورده رها میکند. اینجا همان جای پشتیبانیشده برای سیاستی است که engine نمیتواند حدس بزند: اینکه اصلاً کدام منبعها اجازه دارند به کانتینرهای این هاست برسند.
الگو سه قانون روی اینترفیسِ عمومی است، و ترتیب همهچیز است. اول، ترافیکِ established و related را برگردانید، تا پاسخِ اتصالاتی که کانتینرهای شما باز کردهاند همچنان جاری بماند. دوم، منبعهایی را که واقعاً میخواهید اجازهٔ ورود بدهید برگردانید — یک آدرسِ دفتر، یک هاستِ مانیتورینگ، یک سرورِ همتا. سوم، هر چیزِ دیگری را که از اینترنت میرسد drop کنید. اشتباه گرفتنِ قانونِ conntrack، همان شکستِ کلاسیکِ اینجاست: اگر DROP را اول بگذارید، هر اتصالِ خروجیِ هر کانتینر روی بستهٔ برگشتی میمیرد، که به شکلِ "Docker شبکهٔ DNS و نصبِ پکیجها را خراب کرده" ظاهر میشود و آدمها را کاملاً به سمتِ جای اشتباه میفرستد تا دنبالش بگردند.
دو نکتهٔ عملیاتی تعیین میکند که آیا این در تماس با واقعیت دوام میآورد یا نه. باید با نام به اینترفیسِ عمومی محدود شود — -i eth0، یا هر چیزی که ip route get 1.1.1.1 روی باکسِ شما گزارش میدهد — وگرنه ترافیکِ میانِ bridgeهای خودتان را هم drop میکنید. و باید بعد از هر ریاستارت دوباره اعمال شود، بعد از آنکه دیمنِ Docker زنجیره را ساخته. یک واحدِ systemd از نوعِ oneshot که با After=docker.service ترتیب داده شده شکلِ قابلاعتمادِ این کار است؛ ذخیرهکردن با netfilter-persistent هم کار میکند، تا وقتی بپذیرید که بازیابیِ یک ruleset کامل درحالیکه Docker همزمان دارد آن را از نو میسازد، یک race است که باید تأییدش کنید، نه فرضش بگیرید.
بعد نیمهٔ IPv6. اگر ip6tables -S DOCKER-USER یک زنجیره چاپ کند، هر قانون را عیناً در آن هم بسازید. اگر خطا بدهد، دیمنِ شما اصلاً قوانینِ v6 را مدیریت نمیکند — که هیچ چیزی دربارهٔ اینکه آیا ترافیکِ v6 به کانتینرهای شما میرسد یا نه نمیگوید، فقط میگوید Docker آن را فیلتر نمیکند. آنجا با استدلال به جواب نرسید؛ مسیرها بسته به نسخه، تنظیماتِ دیمن و توزیع فرق میکنند، و یک نتیجهگیریِ غلطِ پرادعا از هیچ نتیجهگیریای بدتر است. خودتان را با nmap -6 اسکن کنید و به نتیجهاش اعتماد کنید.
یک کاری که نباید بکنید: دست بردن بهسمتِ "iptables": false در /etc/docker/daemon.json. این کار جلوی دستزدنِ Docker به فایروال را میگیرد، و همزمان جلوی این را هم میگیرد که NAT کانتینر، masquerading خروجی و ایزولهسازیِ بینشبکهای توسطِ هر چیزی پیکربندی شوند. شما مشکل را حذف نکردهاید، کارش را به ارث بردهاید — با دست، برای هر کانتینر، تا ابد. روی یک VPS تکی، انتخابِ صادقانه این است که بگذارید Docker زنجیرههای خودش را مدیریت کند و شما DOCKER-USER را صاحب شوید.
دروازهٔ دیگر: سوکت، و چیزی که با روت اجرا میشود
همهٔ آنچه تا اینجا گفته شد دربارهٔ بستههایی بود که میرسند. این بخش دربارهٔ چیزی است که آن بستهها پیدا میکنند، و یک مورد آنقدر بدتر از بقیه است که سزاوار است جداگانه گفته شود: مانتکردنِ /var/run/docker.sock داخلِ یک کانتینر، معادلِ دادنِ دسترسیِ root روی هاست به همان کانتینر است. نه "نزدیک به". معادل. هر چیزی که بتواند با آن سوکت حرف بزند میتواند یک کانتینرِ privileged تازه راه بیندازد که فایلسیستمِ هاست داخلش مانت شده، و از آنجا هر کلیدی را بخواند، هر فایلی را بنویسد و هر چیزی را که بخواهد نصب کند. خیلی از ایمیجهای بهظاهر راحت این را میخواهند — داشبوردها، auto-updaterها، reverse proxyهایی با service discovery. با آن درخواست طوری رفتار کنید که انگار تصمیم گرفتهاید به آن ایمیج به همان اندازهای اعتماد کنید که به root shellِ خودتان اعتماد دارید، و همان دقت را هم برای Docker API روی TCP داشته باشید: یک دیمنِ بدوناحرازهویت روی پورتِ 2375 همان دروازه است، اینبار رو به کلِ اینترنت باز.
بعد از آن، container runtime چهار کاهشِ کمهزینه به شما میدهد، و هیچکدام نیاز به بازطراحیِ چیزی ندارد. با user: "1000:1000" بهعنوانِ یک کاربرِ non-root اجرا کنید، چون پیشفرض، root داخلِ namespace است و همین نقطهٔ شروعِ هر فرار از کانتینر است. security_opt: ["no-new-privileges:true"] را تنظیم کنید، که جلوی یک باینریِ setuid داخلِ ایمیج را میگیرد از اینکه هیچوقت بیشتر از همان پردازهای که با آن شروع شده امتیاز بگیرد. همهٔ قابلیتها را با cap_drop: [ALL] بیندازید و فقط همان چیزی را برگردانید که سرویس واقعاً لازم دارد — بیشترِ اپلیکیشنهای وب هیچکدام را لازم ندارند. فایلسیستمِ روت را read_only: true مانت کنید و یک tmpfs کوچک برایش برای فضای موقت بگذارید، که "انداختنِ یک وبشل در دایرکتوریِ اپ" را از یک قدم به یک بنبست تبدیل میکند.
همین منطق دربارهٔ چیزی هم که به کانتینر داده میشود صدق میکند. رازهایی که بهشکلِ متغیرِ محیطی پاس داده میشوند برای هر چیزی که بتواند محیطِ پردازه را بخواند قابلدیدناند و با وفاداریِ کامل در خروجیِ docker inspect و در هر لاگ یا گزارشِ کرشی که پیکربندی را dump میکند کپی میشوند؛ یک فایل که فقط-خواندنی در یک مسیرِ مشخص مانت شده کمتر راحت است اما بهطرزِ قابلتوجهی کمتر نشت میکند. و یک کانتینر به بیش از شبکهای که کارش نیاز دارد نیازی ندارد: یک worker که فقط با پایگاهداده حرف میزند اصلاً نباید بتواند به اینترنت اتصال باز کند.
اگر همهٔ اینها حسِ جنگیدن با پیشفرض را میدهد، این برداشتِ منصفانهای است، و همین استدلالِ رفتن بهسمتِ rootless Docker یا Podman است — جایی که دیمن و کانتینرها بهعنوانِ یک کاربرِ بدونامتیاز اجرا میشوند، یک فرار از کانتینر شما را همان کاربر میرساند نه root، و پورتهای منتشرشده توسطِ یک پردازهٔ عادیِ سطحکاربر روی هاست نگه داشته میشوند، که یعنی قوانینِ ufw شما به شکلِ معمول رویشان اعمال میشود. معامله واقعی است: بعضی قابلیتها، بعضی storage driverها و بعضی ترفندهای شبکهای متفاوت رفتار میکنند یا اصلاً کار نمیکنند. ارزش دارد بدانید این انتخاب وجود دارد و چه چیزی میخرد، تا اینکه بعد از یک حادثه کشف کنید که پیشفرض هم خودش یک انتخاب بوده.
SP·07ایمیجهایی که خودتان ننوشتهاید، روی باکسی که نمیتوانید از دستش بدهید
یک ایمیجِ کانتینر، فایلسیستمِ یک نفرِ دیگر است که روی ماشینِ شما اجرا میشود، از لایههایی سوارشده که نخواندهاید. این استدلالی علیهِ استفاده از آنها نیست — استدلالی است برای دانستنِ اینکه کدامها را اجرا میکنید و چقدر قدیمیاند. دو شکستی که واقعاً روی زیرساختهای کوچک اتفاق میافتد هر دو پیشپاافتادهاند: یا ایمیج هیچوقت قابلاعتماد نبوده، یا در ماهِ مارس قابلاعتماد بوده و از آن موقع کسی دوباره نساختهاش.
شکستِ اول را بیشتر با نظم دربارهٔ اینکه ایمیجها از کجا میآیند حل میکنید. مخزنهای رسمی یا منتشرشده توسطِ vendor را به یک فورکِ راحت با سه ستاره ترجیح دهید، و خصوصاً به ایمیجهایی که جذابیتشان این است که شش سرویس را در یک خطِ YAML بستهبندی میکنند مشکوک باشید. جایی که اهمیت دارد، بهجایِ tag، با digest pin کنید: postgres:17 یک هدفِ متحرک است که میتواند بینِ دو اجرای docker compose pull زیرِ پایتان عوض شود، درحالیکه postgres@sha256:… همان فایلسیستمِ دقیقی است که تست کردهاید. Pinکردن، رفعِ خودکارِ مشکلات را با reproducibility معامله میکند، که وقتی عادت به rebuildکردن دارید معاملهٔ درستی است و وقتی ندارید معاملهٔ غلط.
دومی همان چیزی است که بیسروصدا نیش میزند. بهروزرسانیهای unattended به کانتینرهای شما دست نمیزنند. همان بهروزرسانیهای امنیتیِ خودکاری که در اولین ساعت پیکربندی کردید، پکیجهای هاست را patch میکنند و هیچ دیدی به فضای کاربرِ داخلِ یک ایمیج ندارند — پس باکسی که خودش را کاملاً patchشده گزارش میدهد میتواند وبسروری را از یک ایمیجِ پایه با یک سال آسیبپذیریِ رفعنشده اجرا کند. کانتینرها بهروزرسانی نمیشوند، جایگزین میشوند: pull کنید، دوباره بسازید، و هر چیزی را که دیگر ارجاعی به آن نیست حذف کنید، با ریتمی که واقعاً رعایتش میکنید. یکبار در ماه، یادداشتشده، از یک نیتِ کامل بهتر است.
همین باعث میشود فایلِ Compose باارزشترین شیء روی سرور باشد. تنها توصیفِ کاملِ اینکه آن ماشین چیست همین است، و بازسازی از رویش باید یک کارِ روتین باشد نه یک پروژهٔ باستانشناسی — آن را در version control نگه دارید، فایلهای environment را کنارش نگه دارید، و هر دو را در همان بکآپ رمزنگاریشده و خارج از سروری نگه دارید که دادههای شما را دارد. تستِ یک هاستِ کانتینر این نیست که آیا در حال اجراست. این است که آیا میتوانید آن را دوباره، عیناً، روی یک VPS تازه در حدود 15 min سرپا کنید.
SP·08لاگها، volumeها، و وضعیتی که از کانتینر بیشتر عمر میکند
کانتینرها بهعنوانِ چیزی یکبارمصرف تبلیغ میشوند، که این دربارهٔ خودِ پردازه درست است و دربارهٔ هر چیزی که پشتِ سر میگذارد غلط. دو نوع وضعیت روی یک هاستِ Docker انباشته میشود، و هر دو آدمها را در بدترین لحظه غافلگیر میکنند.
اولی لاگهاست. درایورِ پیشفرضِ json-file هر خطی را که کانتینرهای شما به stdout و stderr مینویسند ضبط میکند، و — مگر اینکه خلافش را بگویید — هیچوقت آنها را rotate نمیکند. یک اپلیکیشنِ پرحرف میتواند طیِ چند هفته یک دیسک را همینطوری پر کند، و یک دیسکِ پر روی یک هاستِ پایگاهداده خودش یک حادثه است. یکبار یک سقف تعیین کنید، در /etc/docker/daemon.json، تا روی هر چیزی که بعداً راه میاندازید اعمال شود.
یک خوانشِ حریمِ خصوصی هم از همین تنظیم وجود دارد، و روی سروری که برای حریمِ خصوصی انتخابش کردهاید احتمالاً همین خوانش مهمتر است. یک access log که rotate نشده، رکوردی دائمی و ایندکسنشده از آدرسِ IP، user agent و مسیرِ درخواستِ هر بازدیدکننده است، روی دیسکی که از نظرِ فیزیکی کنترلش نمیکنید. شما تصمیم نگرفتید آن را نگه دارید. پیشفرض بهجای شما تصمیم گرفت. تصمیمِ آگاهانه یعنی سقفگذاری روی مدتِ نگهداری، و — بهتر از آن — کمکردنِ چیزی که از همان اول نوشته میشود: یک فرمتِ لاگ روی پروکسی که آدرسِ کلاینت را حذف یا کوتاه میکند، ارزشِ عملیاتی را نگه میدارد و مسئولیت را میاندازد. دادهای که هرگز ننوشتهاید نه قابلتوقیف است، نه قابلاحضاریه، نه قابللورفتن — همان استدلالی که آدمها را از همان اول به دور نگهداشتنِ نامشان از یک سرور میرساند.
دومی volumeهاست، و لبهٔ تیز یک flag است. docker compose down کانتینرها را متوقف و حذف میکند و volumeهای نامدار را دستنخورده میگذارد؛ docker compose down -v آنها را برای همیشه حذف میکند، با همان اطمینان. پایگاهدادهها داخلِ همان volumeها زندگی میکنند. هر چیزِ دیگری هم که دلتان برایش تنگ میشود همینطور. دادههایتان را در volumeهای نامدار نگه دارید نه در volumeهای بینام، بدانید ساعتِ دو بامداد دارید کدام دستور را تایپ میکنید، و بهیاد داشته باشید که docker system prune برای پسگرفتنِ فضا وجود دارد و با کمالِ میل فضای شما را هم پس میگیرد.
میماند خودِ بکآپ، جایی که انتزاعِ کانتینر برای آخرین بار گمراهتان میکند. کپیکردنِ دایرکتوریِ volume یک پایگاهدادهٔ زنده، بکآپ نیست؛ کپیِ فایلهایی است که همان لحظهای که شما میخواندیدشان داشتند نوشته میشدند، و دقیقاً همان زمانی که بیشتر از همیشه لازمش دارید، بهشکلِ یک پایگاهدادهٔ خراب بازیابی میشود. از راهِ خودِ engine بکآپ بگیرید — pg_dump، mysqldump، یا API اسنپشاتِ خودِ engine — و بعد نتیجه را رمزنگاری کنید و به جایی بفرستید که سرورتان نتواند به آن برسد. همان بندِ آخر جایی است که وقتی نفوذگر از قبل داخلِ کانتینر است اهمیت پیدا میکند: بکآپی که هاستِ بهخطرافتادهٔ شما بتواند حذفش کند، اصلاً بکآپ نیست.
گامبهگام
-
01
فهرستبرداری از آنچه باکس همین الان منتشر میکند
پیش از تغییردادنِ هر چیزی، وضعیتِ فعلی را یادداشت کنید — بعداً برای diffگرفتن بهش نیاز خواهید داشت. ستونِ
PORTSرا برای هر کانتینر بخوانید، بعد قوانینِ NATای را که Docker واقعاً نصب کرده، روی هر دو خانوادهٔ IP، بخوانید. هر چیزی که بدونِ مقصدِ127.0.0.1چاپ شود از اینترنت قابلدسترسی است، هرچهufw statusادعا کند.docker ps --format 'table {{.Names}}\t{{.Ports}}' ss -tulpen | grep -E 'docker|LISTEN' sudo iptables -t nat -S DOCKER # -d 127.0.0.1/32 = loopback only sudo ip6tables -t nat -S DOCKER # may not exist; that is an answer too sudo ufw status verbose # what you believed was trueحالا که اینجایید، نامِ اینترفیسِ عمومی را هم یادداشت کنید — قوانینِ فایروال بعداً به آن نیاز دارند، و روی هر ایمیجی
eth0نیست.ip route get 1.1.1.1 | awk '{print $5; exit}' -
02
خودتان را از جایی دیگر اسکن کنید
هاست نمیتواند خودش را ممیزی کند: ترافیکِ loopback هرگز از زنجیرههایی که میخواهید تستشان کنید رد نمیشود. این کار را از یک ماشینِ دیگر روی یک شبکهٔ دیگر انجام دهید — یک VPS دوم از $8.00 در ماه در یک منطقهٔ دیگر، صادقانهترین محلِ تست است، و بعد از این هم به کارتان میآید. هر دو خانواده را اسکن کنید، چون جداگانه پیکربندی میشوند.
# from ANOTHER machine, against your server nmap -Pn -sS -p- --min-rate 1000 203.0.113.10 nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1 # no nmap? one port at a time is enough to settle an argument nc -zv 203.0.113.10 5432
هر پورتِ بازی که 80، 443 یا پورتِ SSH شما نباشد، یک یافته است. خروجی را نگه دارید؛ همان نیمهٔ "پیش از"ِ مدرکی است که آخرِ کار به آن نیاز خواهید داشت.
-
03
چیزی را که لازم نیست عمومی باشد، منتشر نکنید
حالا آن را از سرچشمه، در فایلِ Compose درست کنید. سرویسهای بکاند به یک شبکهٔ خصوصی منتقل میشوند و خطِ
ports:شان را کاملاً از دست میدهند — اپلیکیشن با نامِ سرویس به آنها میرسد. هر چیزی که باید از خودِ هاست قابلدسترسی باشد، بهجایِ یک پورتِ ساده، یک بایندِ صریح به loopback میگیرد. شبکهٔ بکاند راinternalعلامت بزنید تا هیچچیز رویش بدونِ اجازه با اینترنت حرف نزند.services: db: image: postgres:17 # ports: ["5432:5432"] # deleted: the app reaches it as db:5432 networks: [back] volumes: [dbdata:/var/lib/postgresql/data] app: image: myapp:1.4 environment: DATABASE_URL: postgres://app@db:5432/app ports: ["127.0.0.1:8080:8080"] # loopback only; proxy sits in front networks: [back, front] networks: front: {} back: internal: true volumes: dbdata: {}استک را دوباره بسازید و تأیید کنید شکلِ قوانینِ NAT عوض شده — خطِ DNAT برای اپلیکیشن حالا باید یک مقصدِ loopback داشته باشد، و پایگاهداده اصلاً نباید هیچ خطی داشته باشد.
docker compose up -d docker ps --format 'table {{.Names}}\t{{.Ports}}' sudo iptables -t nat -S DOCKER -
04
دقیقاً یک چیز را جلوی دیدِ عموم بگذارید
وقتی همهچیز روی loopback باشد، یک reverse proxy تنها سطحِ عمومی میشود — تنها جایی که TLS در آن terminate میشود، تنها جایی که آدرسِ کلاینت اصلاً دیده میشود، و تنها جایی که فرمتِ لاگ تصمیم گرفته میشود. آن را روی هاست یا در کانتینری اجرا کنید که فقط 80 و 443 را منتشر میکند و هیچ چیزِ دیگری را؛ اگر در یک کانتینر اجرا شود، به شبکهٔ
frontمیپیوندد و بهجایِ loopback به نامِ سرویسها proxy میکند.# in the http{} block of /etc/nginx/nginx.conf — no client address recorded log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent'; # /etc/nginx/conf.d/app.conf (proxy on the host) server { listen 443 ssl; listen [::]:443 ssl; server_name example.com; access_log /var/log/nginx/app.log privacy; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }اگر کلِ هدفِ این تمرین این است که هیچکس نفهمد ماشین کجاست، جایِ این proxy اصلاً روی یک باکسِ دیگر است — همان چینشی که در پنهان کردن IP مبدأ پشت یک reverse proxy آمده، و کاملاً با هر چیزی که اینجا گفتیم جفت میشود.
-
05
شکاف را با DOCKER-USER ببندید، روی هر دو خانواده
بایندهای loopback کانتینرهای امروز را درست میکنند؛ این یکی کانتینرهایی را درست میکند که هنوز ننوشتهاید. سه قانون روی اینترفیسِ عمومی، اول conntrack تا خروجیِ کانتینر کارکردنش را ادامه بدهد، بعد منبعهای مجازتان، بعد یک drop. بلافاصله بعد از اعمالکردن، اتصالِ خروجیِ یک کانتینر را تست کنید — همان قانونی که آدمها برعکس میگیرندش.
PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}') sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN # your admin address sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP sudo iptables -S DOCKER-USER docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'اگر دیمن قوانینِ v6 را مدیریت میکند، همین کار را برای IPv6 هم عیناً تکرار کنید، بعد با واحدی که بعد از آنکه Docker زنجیرههایش را از نو ساخت اجرا میشود، کاری کنید از یک ریاستارت هم جان سالم به در ببرد.
sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning' # /etc/systemd/system/docker-user-rules.service [Unit] After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/sbin/docker-user-rules.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target
-
06
امتیاز را داخل کانتینرها کم کنید، و سقفی برای لاگها بگذارید
کاری را که یک ایمیجِ بهخطرافتاده میتواند بکند کم کنید، و جلویِ این را بگیرید که هاست از رکوردی پر شود که کسی تصمیم نگرفته نگهش دارد. آن چهار تنظیمِ کانتینر روی یک سرویسِ وبِ معمولی هیچ هزینهای ندارند؛ سقفِ لاگ هم روی هر کانتینری که بعد از reloadشدنِ دیمن راه میافتد اعمال میشود.
services: app: image: myapp:1.4 user: "1000:1000" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: ["no-new-privileges:true"] # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]# /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }sudo systemctl reload docker docker inspect --format '{{.HostConfig.LogConfig}}' app -
07
از بیرون تأیید کنید، بعد runbook را بنویسید
عمداً ریاستارت کنید، درحالیکه هیچچیز به جوابش وابسته نیست، و ثابت کنید وضعیتی که ساختید همان وضعیتی است که برمیگردد. بعد اسکنِ بیرونیِ مرحلهٔ دو را تکرار کنید و آن را با خروجیای که نگه داشته بودید diff بگیرید — همان diff محصولِ نهایی است، نه دستورها.
sudo reboot # after it returns: sudo iptables -S DOCKER-USER # rules reapplied? docker ps --format 'table {{.Names}}\t{{.Ports}}' # from ANOTHER machine again: nmap -Pn -p- 203.0.113.10 nmap -Pn -6 -p- 2001:db8::1پنج خط را جایی بنویسید که پیدایشان کنید: قوانین به کدام اینترفیس بایند میشوند، واحد کجا زندگی میکند، کدام سرویسها عمداً عمومیاند، آخرین بار کِی ایمیجها را دوباره ساختید، و چطور volumeها را بازیابی کنید. یک هاستِ کانتینر فقط بهاندازهٔ توصیفی خوب است که بتواند بعد از بدترین روز دوباره بسازدش.


