امنیت و DevSecOpsمهندسانسطح متوسط

خطای runAsNonRoot در Kubernetes؛ وقتی ایمیج با root بالا می‌آید

بعد از اعمال Pod Security Standards روی restricted، پادها در وضعیت CreateContainerConfigError گیر می‌کنند و kubelet می‌گوید image با root اجرا می‌شود. علت ریشه‌ای و راه‌حل کامل را بررسی می‌کنیم.

تحریریه کلودیکپ ۷ دقیقه مطالعه
خطای runAsNonRoot در Kubernetes؛ وقتی ایمیج با root بالا می‌آید

شرح مسئله: پاد در CreateContainerConfigError گیر می‌کند

سناریوی همیشگی: تیم امنیت تصمیم می‌گیرد namespace سرویس‌ها را از baseline به restricted ببرد. لیبل را اضافه می‌کنید، چند دقیقه بعد هیچ دیپلوی جدیدی بالا نمی‌آید. نه خطای اپلیکیشن، نه مشکل ایمیج‌پول؛ فقط پادهایی که در فاز کانتینرسازی متوقف شده‌اند:

kubectl -n shop get pods
# NAME                        READY   STATUS                       RESTARTS   AGE
# checkout-7d9f8c5b6-x2klm     0/1     CreateContainerConfigError   0          2m
# checkout-7d9f8c5b6-q8w4t     0/1     CreateContainerConfigError   0          2m

اولین جای درست برای نگاه کردن، ایونت‌های پاد است نه لاگ کانتینر (کانتینر هرگز استارت نشده که لاگی داشته باشد):

kubectl -n shop describe pod checkout-7d9f8c5b6-x2klm | tail -n 8
# Events:
#   Type     Reason  Age   From     Message
#   ----     ------  ----  ----     -------
#   Warning  Failed  14s   kubelet  Error: container has runAsNonRoot and image will run as root

یک نسخه دیگر از همین خطا هم وجود دارد که بیشتر تیم‌هایی که از USER node یا USER app استفاده می‌کنند به آن می‌خورند:

# Warning  Failed  9s  kubelet  Error: container has runAsNonRoot and image has non-numeric user (node), cannot verify user is non-root

و یک حالت سوم که به‌ظاهر خطای دیگری است ولی همان ریشه را دارد: پاد با موفقیت اجرا می‌شود اما چند ثانیه بعد CrashLoopBackOff می‌خورد و در لاگ، EACCES: permission denied, mkdir '/app/.cache' یا permission denied روی /tmp دیده می‌شود؛ چون پروسه حالا با UID غیرصفر اجرا می‌شود و مالکیت فایل‌های ایمیج با آن UID یکی نیست.

علت ریشه‌ای: kubelet نمی‌تواند non-root بودن را تأیید کند

نکته‌ای که اکثر پست‌ها جا می‌اندازند این است که پیام بالا از admission نمی‌آید، از kubelet می‌آید. یعنی پاد از دروازه‌ی Pod Security Admission رد شده و مانع‌اش ادمیشن نبوده؛ در لحظه ساخت کانتینر، kubelet تابع verifyRunAsNonRoot را اجرا می‌کند و شکست می‌خورد. همین موضوع سرنخ اصلی است: باید فرق بین «پاد مجاز شد» و «کانتینر مجاز شد» را جدا کنیم.

منطق kubelet ساده است:

  • اگر در securityContext مقدار runAsUser مشخص شده باشد، kubelet همان را می‌بیند؛ اگر غیر از صفر باشد، عبور می‌کند.

  • اگر runAsUser مشخص نشده باشد، kubelet داخل کانفیگ ایمیج نگاه می‌کند (همان فیلد User در Dockerfile). اگر خالی باشد یعنی root → پیام image will run as root.

  • اگر خالی نباشد اما عددی نباشد (مثل node)، kubelet عمداً وارد /etc/passwd ایمیج نمی‌شود و نمی‌تواند حدس بزند که این نام به یک UID غیرصفر نگاشت می‌شود → پیام cannot verify user is non-root.

پس ریشه‌ی مشکل همیشه یکی از این دو است: ایمیج غیرعددی یا بدون USER ساخته شده، یا منیفست به کانتینر runAsUser نداده و انتظار دارد ایمیج این کار را کرده باشد.

یک تصور غلط رایج هم که وقت زیادی از تیم‌ها می‌گیرد: پروفایل restricted الزام می‌کند runAsNonRoot: true، allowPrivilegeEscalation: false، capabilities.drop: ["ALL"]، seccompProfile.type: RuntimeDefault و محدود بودن نوع volume، اما readOnlyRootFilesystem جزو الزامات این پروفایل نیست. اگر پاد فقط به خاطر همین خطا رد می‌شود، لازم نیست کل اپلیکیشن را read-only کنید؛ ولی چون در عمل خوب است، در ادامه هر دو را با هم می‌آوریم.

راه‌حل گام‌به‌گام

گام صفر: اول warn و audit، بعد enforce

قبل از اینکه یک namespace را قفل کنید، همان قوانین را در حالت هشدار بگذارید تا فهرست دقیق تخلف‌ها را ببینید:

kubectl label --overwrite ns shop \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=latest \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/audit-version=latest

از این به بعد هر kubectl apply، خط‌های هشدار را روی stderr چاپ می‌کند و در audit log هم ثبت می‌شود:

kubectl -n shop apply -f deploy.yaml
# Warning: would violate PodSecurity "restricted:latest":
#   allowPrivilegeEscalation != false
#   unrestricted capabilities: container "checkout" must set securityContext.capabilities.drop=["ALL"]
#   runAsNonRoot != true: container "checkout" must not be privileged,
#     must not set allowPrivilegeEscalation=true, and must not set runAsUser=0

گام یک: ایمیج را با UID عددی بسازید

مطمئن‌ترین راه این است که ایمیج، کاربر خودش را با UID ثابت داشته باشد. برای ایمیج‌های Debian-based:

FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app

# UID/GID ثابت و عددی؛ کلید عبور از بررسی kubelet
RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin app

COPY --from=build --chown=10001:10001 /app/dist ./dist
COPY --from=build --chown=10001:10001 /app/node_modules ./node_modules

# حتماً به شکل عددی بنویسید، نه USER app
USER 10001:10001
EXPOSE 8080
CMD ["node", "dist/main.js"]

اگر پایه ایمیج‌تان Alpine است، معادل BusyBox آن این است: RUN addgroup -g 10001 -S app && adduser -u 10001 -S app -G app. نکته مهم --chown است؛ بدون آن پروسه‌ای که حالا با UID 10001 اجرا می‌شود نمی‌تواند فایل‌ها را بخواند و خطای permission denied می‌گیرید.

گام دو: securityContext را کامل کنید

حتی با ایمیج درست، بدون تعریف صریح این بلوک نمی‌توانید روی الزامات restricted حساب کنید. یک منیفست عبورکرده از هر پنج کنترل:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
  namespace: shop
spec:
  replicas: 2
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: checkout
          image: registry.example.com/shop/checkout:1.4.2
          ports:
            - containerPort: 8080
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /app/.cache
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}

دو نکته که معمولاً از چشم می‌افتد: fsGroup: 10001 باعث می‌شود volumeهای emptyDir با گروه درست mount شوند و نوشتن در /tmp شکست نخورد. و emptyDir در فهرست volumeهای مجاز restricted هست، پس با این تغییر قانون را نمی‌شکنید.

گام سه: ایمیج‌های third-party را بررسی کنید

همیشه نمی‌توانید ایمیج را rebuild کنید. قبل از هر چیز ببینید ایمیج چه می‌گوید:

docker image inspect registry.example.com/thirdparty/agent:2.1 \
  --format '{{.Config.User}}'
# خروجی خالی یعنی root؛ خروجی "agent" یعنی غیرعددی

در هر دو حالت می‌توانید با ست کردن صریح runAsUser در منیفست از بررسی ایمیج عبور کنید، چون kubelet وقتی UID عددی می‌بیند دیگر داخل ایمیج نگاه نمی‌کند:

          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            runAsGroup: 10001

اما این ترفند فقط زمانی جواب می‌دهد که اپلیکیشن واقعاً به root نیاز نداشته باشد. ایمیجی که روی پورت زیر ۱۰۲۴ bind می‌کند، در /var/lib می‌نویسد یا فایل‌هایش مالک root با مود 600 است، با این روش در فاز اجرا می‌شکند. برای چنین workloadهایی، مسیر درست ساختن یک namespace جداگانه با پروفایل baseline و مستندسازی استثناست، نه شل کردن کل خوشه.

گام چهار: enforce با نسخه‌ی پین‌شده

kubectl label --overwrite ns shop \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=v1.30

enforce-version را پین کنید. اگر latest بگذارید، با هر ارتقای خوشه پروفایل ممکن است سخت‌تر شود و بی‌خبر، دیپلوی تیم دیگری بشکند.

چطور مطمئن شویم مشکل واقعاً حل شده است

اینکه پاد Running شد کافی نیست؛ باید تأیید کنید که فرآیند واقعاً با UID غیرصفر اجرا می‌شود و ایمیج هم بدون ترفند قبول شده است:

kubectl -n shop rollout status deploy/checkout
kubectl -n shop exec deploy/checkout -- id
# uid=10001(app) gid=10001(app) groups=10001(app)

kubectl -n shop exec deploy/checkout -- grep CapEff /proc/1/status
# CapEff: 0000000000000000   ← هیچ capability باقی نمانده

kubectl -n shop exec deploy/checkout -- sh -c 'touch /tmp/health && echo writable'
# writable

سپس بررسی کنید که دیگر هشدار ادمیشن صادر نمی‌شود؛ یعنی منیفست از نظر خود API Server کاملاً منطبق است:

kubectl -n shop apply --dry-run=server -f deploy.yaml
# اگر هیچ خط Warning چاپ نشد، منیفست سازگار است

و در نهایت، ایونت پاد نباید هیچ Failed مربوط به kubelet داشته باشد:

kubectl -n shop get events --field-selector reason=Failed --sort-by=.lastTimestamp

پیشگیری و بهترین روش‌ها

  • سیاست را به پایپلاین ببرید، نه به خوشه. ابزاری مثل kube-linter همان قوانین را قبل از merge می‌گیرد: run-as-non-root، privilege-escalation-container، seccomp-profile و no-read-only-root-fs جزو چک‌های آماده‌ی آن هستند.

# .kube-linter.yaml
checks:
  addAllBuiltIn: true
  exclude:
    - unset-cpu-requirements
    - unset-memory-requirements
kube-linter lint manifests/ --config .kube-linter.yaml
  • Dockerfile را lint کنید. قاعده‌ی DL3002 در hadolint دقیقاً همین را می‌گیرد: آخرین USER نباید root باشد. یک خط در CI کافی است تا کسی سهواً USER را حذف نکند.

  • UID و GID را استاندارد کنید. یک بازه ثابت (مثلاً ۱۰۰۰۰ تا ۱۰۹۹۹) برای همه سرویس‌ها تعیین کنید تا نه با کاربران سیستمی ایمیج تصادم کند و نه موقع نوشتن روی volume اشتراکی مشکل پیش بیاید.

  • همیشه runAsUser را صریح بنویسید. حتی وقتی ایمیج USER 10001 دارد. این کار بررسی kubelet را مستقل از ایمیج می‌کند و مانع خطای cannot verify user is non-root در آینده می‌شود.

  • مسیر warn → audit → enforce را در محیط dev حفظ کنید. اگر از Kyverno یا Gatekeeper استفاده می‌کنید، در فاز اول با حالت Audit و mutate برای تزریق پیش‌فرض‌های securityContext شروع کنید تا توسعه‌دهنده مجبور نباشد همه‌چیز را از حفظ بنویسد.

  • readOnlyRootFilesystem را عادت کنید. هرچند در پروفایل restricted الزامی نیست، اما اگر از روز اول اپلیکیشن را مجبور کنید فقط در /tmp و مسیرهای مشخص بنویسد، مهاجرت به سخت‌گیری‌های بعدی بدون درد خواهد بود.

اشتراک‌گذاری:

نویسنده

تحریریه کلودیکپ

تیم محتوای کلودیکپ

تیم فنی و محتوای کلودیکپ؛ مهندسانی که هر روز با DevOps، Kubernetes و زیرساخت ابری کار می‌کنند و تجربه‌هایشان را اینجا می‌نویسند.

سوالات متداول

سوالات متداول این مقاله

نه. پروفایل restricted در Pod Security Standards مواردی مثل runAsNonRoot، allowPrivilegeEscalation=false، drop همه‌ی capabilityها، seccompProfile از نوع RuntimeDefault و محدودیت نوع volume را الزام می‌کند، اما readOnlyRootFilesystem جزو آن نیست. با این حال فعال کردنش توصیه می‌شود و فقط باید مسیرهای نوشتنی مثل /tmp را با emptyDir mount کنید.

اول بررسی کنید که آیا واقعاً به root نیاز دارد یا فقط چون USER در Dockerfile ست نشده این‌طور به نظر می‌رسد. اکثر اپلیکیشن‌ها با runAsUser عددی و اصلاح مالکیت فایل‌ها کار می‌کنند. اگر ایمیج واقعاً به root نیاز دارد (مثلاً bind روی پورت زیر ۱۰۲۴ یا نوشتن در /var/lib)، آن workload را در namespace جداگانه‌ای با پروفایل baseline نگه دارید و استثنا را مستند کنید؛ هرگز کل خوشه را به خاطر یک سرویس شل نکنید.

بله. PodSecurityPolicy در Kubernetes 1.25 حذف شد و PodSecurity Admission که از 1.23 به‌صورت بتا و از 1.25 به‌صورت پایدار در دسترس است جای آن را گرفته. تفاوت مهم‌اش این است که PSA فقط admit یا reject می‌کند و مقادیر securityContext را به‌صورت خودکار پر نمی‌کند؛ اگر به تزریق خودکار پیش‌فرض‌ها نیاز دارید باید از Kyverno یا Gatekeeper استفاده کنید.

ادامه مطالعه

مقالات مرتبط

رفع خطای 429 در Trivy DB با راه‌اندازی mirror داخلی
امنیت و DevSecOpsمتوسط

رفع خطای 429 در Trivy DB با راه‌اندازی mirror داخلی

اگر مرحله اسکن Trivy در پایپلاین با خطای 429 Too Many Requests شکست می‌خورد، مشکل از کد یا شبکه شما نیست؛ GHCR نرخ pull ناشناس را محدود می‌کند. در این مقاله پایگاه‌داده Trivy را در رجیستری داخلی mirror می‌کنیم و Trivy را به آن وصل می‌کنیم.

تحریریه کلودیکپ ۸ دقیقه مطالعه
پر شدن فضای etcd؛ خطای mvcc: database space exceeded
Kubernetes و کانتینرپیشرفته

پر شدن فضای etcd؛ خطای mvcc: database space exceeded

وقتی فایل backend در etcd از فضای تعیین‌شده عبور می‌کند، کل کلاستر Kubernetes به حالت فقط‌خواندنی می‌رود. در این مقاله علت fragmentation را بررسی می‌کنیم و با defrag، disarm و تنظیم quota کلاستر را برمی‌گردانیم.

تحریریه کلودیکپ ۷ دقیقه مطالعه
خطای fsnotify watcher در Kubernetes؛ سقف inotify نود
Kubernetes و کانتینرمتوسط

خطای fsnotify watcher در Kubernetes؛ سقف inotify نود

اگر پادهای Grafana، Prometheus یا هر اپلیکیشنی که از inotify استفاده می‌کند با خطای «failed to create fsnotify watcher: too many open files» کرش می‌کنند، مقصر سقف پیش‌فرض هسته لینوکس روی نود است.

تحریریه کلودیکپ ۷ دقیقه مطالعه

در پیاده‌سازی به کمک نیاز دارید؟

تیم کلودیکپ همین کار را هر روز برای تیم‌های دیگر انجام می‌دهد. اگر جایی گیر کرده‌اید، با ما صحبت کنید.