خطای runAsNonRoot در Kubernetes؛ وقتی ایمیج با root بالا میآید
بعد از اعمال Pod Security Standards روی restricted، پادها در وضعیت CreateContainerConfigError گیر میکنند و kubelet میگوید image با 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.30enforce-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-requirementskube-linter lint manifests/ --config .kube-linter.yamlDockerfile را 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 داخلی
اگر مرحله اسکن Trivy در پایپلاین با خطای 429 Too Many Requests شکست میخورد، مشکل از کد یا شبکه شما نیست؛ GHCR نرخ pull ناشناس را محدود میکند. در این مقاله پایگاهداده Trivy را در رجیستری داخلی mirror میکنیم و Trivy را به آن وصل میکنیم.

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

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