Konteyner teknolojisi hayatımızı kolaylaştırdı, bunu kimse inkar edemez. Ama “docker run” yazıp geçtiğimizde aslında ne kadar büyük bir saldırı yüzeyi açtığımızı çoğumuz fark etmiyoruz. Geçen ay bir müşterimde yaptığım güvenlik denetiminde, production ortamında root olarak çalışan, tüm host portlarına erişimi olan ve üç yıldır güncellenmemiş base image kullanan konteynerler buldum. Bu yazıda o tür felaketleri yaşamamanız için Docker konteynerlerinde saldırı yüzeyini nasıl minimize edeceğinizi, gerçek dünya senaryolarıyla anlatacağım.
Neden Konteyner Güvenliği Bu Kadar Önemli?
Docker’ın popülerleşmesiyle birlikte “konteynerlar izole değil mi zaten?” düşüncesi yaygınlaştı. Evet, kısmen izole. Ama bu izolasyon sizin düşündüğünüz kadar sağlam bir duvar değil. Konteyner, host kernel’ini paylaşıyor. Yani çekirdek seviyesinde bir açık, tüm konteynerleri ve host sistemi tehlikeye atabilir.
2019’daki runc açığını hatırlarsınız, CVE-2019-5736. Kötü niyetli bir konteyner, host üzerindeki runc binary’sini üzerine yazabiliyordu. Bu tek bir açık mıydı? Evet. Ama milyonlarca production sistemi etkiledi. Demek ki “konteyner içindeyiz, izoliz” diye rahatlamak doğru değil.
Saldırı yüzeyini azaltmak ise birkaç temel prensibe dayanıyor:
- Çalışan her servis potansiyel bir giriş noktasıdır
- Root yetkisi her zaman tehlikelidir
- Gereksiz araçlar ve kütüphaneler ek risk demektir
- Ağ erişimi ne kadar geniş olursa saldırı yüzeyi o kadar büyür
Minimal Base Image Kullanımı
En yaygın hatalardan biri ubuntu:latest ya da debian:latest gibi şişirilmiş base image’larla başlamak. Bu image’lar içinde curl, wget, bash, netcat ve onlarca başka araç geliyor. Bunların büyük çoğunluğuna uygulamanızın çalışması için ihtiyaç yok. Ama bir saldırgan konteynere girdiğinde bu araçların hepsini kullanabilir.
Distroless Image’lar
Google’ın distroless projesini kesinlikle incelemenizi öneririm. Bu image’larda shell bile yok. Sadece uygulamanızın çalışması için gereken minimum bileşenler mevcut.
# Kötü örnek: Gereksiz yere büyük image
FROM ubuntu:22.04
RUN apt-get install -y python3 python3-pip
COPY app.py .
CMD ["python3", "app.py"]
# İyi örnek: Distroless kullanımı
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
FROM gcr.io/distroless/python3-debian11
WORKDIR /app
COPY --from=builder /app /app
CMD ["app.py"]
Distroless ile image boyutunuz dramatik biçimde küçülür ve içeride shell olmadığı için bir saldırgan konteynere girse bile komut çalıştırmak için ciddi zorlukla karşılaşır.
Alpine Linux Tercih Edin
Eğer distroless uygulamanız için çok kısıtlayıcıysa, Alpine Linux iyi bir ara seçenek. Tam Ubuntu’nun yanında devasa küçük, musl libc kullanıyor ve minimal paket sayısıyla geliyor.
# Node.js uygulaması için Alpine tabanlı multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:18-alpine
# Gereksiz paketleri kaldır
RUN apk del --no-cache curl wget
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
# Root olmayan kullanıcı oluştur
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
Root Olmayan Kullanıcıyla Çalıştırma
Bu muhtemelen en kritik nokta. Varsayılan olarak Docker konteynerleri root kullanıcısıyla çalışır. Konteyner escape açığı durumunda, root olarak çalışan bir konteyner host üzerinde de root yetkisi kazandırabilir.
# Dockerfile içinde non-root kullanıcı oluşturma
FROM python:3.11-slim
WORKDIR /app
# Önce bağımlılıkları kur (root gerekebilir)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Uygulama dosyalarını kopyala
COPY . .
# Dedicated kullanıcı oluştur
RUN groupadd --gid 1001 appgroup &&
useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser
# Dosya sahipliğini ver
RUN chown -R appuser:appgroup /app
# Kullanıcıyı değiştir
USER appuser
CMD ["python", "app.py"]
Mevcut konteynerinizin hangi kullanıcıyla çalıştığını kontrol etmek için:
# Çalışan konteynerde kullanıcıyı kontrol et
docker exec konteyner_adi id
# Tüm çalışan konteynerlerin kullanıcı bilgisini gör
docker ps -q | xargs -I{} docker inspect {} --format='{{.Name}}: {{.Config.User}}'
Boş çıktı alırsanız ve User alanı boşsa, o konteyner root olarak çalışıyor demektir. Acil müdahale edin.
Linux Capabilities Kısıtlaması
Linux capabilities sistemi, root yetkilerini parçalara böler. Normalde root kullanıcısı onlarca farklı ayrıcalığa sahiptir. Ağ arayüzü değiştirme, sistem saatini ayarlama, kernel modülü yükleme… Konteynerlere bunların tamamını vermek yerine sadece ihtiyaç duydukları yetenekleri verin.
# Tüm capabilities'leri kaldır, sadece gerekenleri ekle
docker run
--cap-drop=ALL
--cap-add=NET_BIND_SERVICE
--name web-server
nginx:alpine
# Docker Compose ile capabilities yönetimi
# docker-compose.yml
version: "3.8"
services:
web:
image: nginx:alpine
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
Hangi capabilities’lere ihtiyacınız olduğunu anlamak için önce konteyneri normal şekilde çalıştırıp sonra docker inspect ile analiz edebilirsiniz. Ya da amicontained aracını kullanabilirsiniz.
# amicontained ile mevcut capabilities'leri görüntüle
docker run --rm -it r.j3ss.co/amicontained
# Belirli bir konteynerdeki capabilities'leri listele
docker inspect konteyner_adi | grep -A 20 "CapAdd"
Read-Only Filesystem
Bir saldırgan konteynere girdiğinde genellikle persistence sağlamak ister. Bunun en yaygın yolu dosya sistemine bir şeyler yazmaktır. Read-only filesystem ile bunu engelliyoruz.
# Konteyneri read-only başlat
docker run --read-only
--tmpfs /tmp
--tmpfs /var/run
--name myapp
myapp:latest
# Kubernetes pod spec'inde read-only
# securityContext:
# readOnlyRootFilesystem: true
Tabi bazı uygulamalar geçici dosya yazmak zorunda. Bunun için tmpfs mount kullanıyoruz. tmpfs RAM’e yazıyor, disk’e değil. Ve konteyner durduğunda otomatik temizleniyor.
Bir PostgreSQL konteyneri için pratik örnek:
docker run -d
--name postgres-secure
--read-only
--tmpfs /tmp
--tmpfs /var/run/postgresql
-v postgres-data:/var/lib/postgresql/data
-e POSTGRES_PASSWORD=gizlisifre
--cap-drop=ALL
--cap-add=SETUID
--cap-add=SETGID
--cap-add=DAC_READ_SEARCH
--security-opt=no-new-privileges:true
postgres:15-alpine
Seccomp ve AppArmor Profilleri
Capabilities kısıtlamasının ötesine geçmek istiyorsanız seccomp profilleri kullanın. Seccomp, konteynerin yapabileceği sistem çağrılarını (syscall) kısıtlar. Bir web uygulamasının mount() veya reboot() sistem çağrısına neden ihtiyacı olsun ki?
# Özel seccomp profili oluştur
cat > /etc/docker/seccomp/myapp-profile.json << 'EOF'
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"read", "write", "open", "close", "stat", "fstat",
"poll", "lseek", "mmap", "mprotect", "munmap", "brk",
"rt_sigaction", "rt_sigprocmask", "ioctl", "access",
"pipe", "select", "sched_yield", "mremap", "msync",
"mincore", "madvise", "dup", "dup2", "nanosleep",
"getitimer", "alarm", "setitimer", "getpid", "sendfile",
"socket", "connect", "accept", "sendto", "recvfrom",
"sendmsg", "recvmsg", "shutdown", "bind", "listen",
"getsockname", "getpeername", "socketpair", "setsockopt",
"getsockopt", "clone", "fork", "vfork", "execve",
"exit", "wait4", "kill", "uname", "fcntl", "flock",
"fsync", "fdatasync", "truncate", "ftruncate", "getcwd",
"chdir", "rename", "mkdir", "rmdir", "creat", "link",
"unlink", "readlink", "chmod", "fchmod", "chown",
"fchown", "lchown", "umask", "gettimeofday", "getrlimit",
"getrusage", "sysinfo", "times", "getuid", "syslog",
"getgid", "geteuid", "getegid", "setpgid", "getppid",
"getpgrp", "setsid", "getgroups", "getresuid",
"getresgid", "getpgid", "getsid", "capget", "capset",
"rt_sigpending", "rt_sigtimedwait", "rt_sigsuspend",
"sigaltstack", "arch_prctl", "setrlimit", "sync",
"gettid", "futex", "set_thread_area", "epoll_create",
"epoll_ctl", "epoll_wait", "set_tid_address", "restart_syscall",
"clock_gettime", "clock_getres", "clock_nanosleep",
"exit_group", "epoll_pwait", "openat", "newfstatat",
"getdents64", "set_robust_list", "get_robust_list",
"splice", "tee", "sync_file_range", "vmsplice",
"move_pages", "accept4", "epoll_create1", "dup3",
"pipe2", "recvmmsg", "preadv", "pwritev", "sendmmsg",
"getrandom", "memfd_create", "pkey_mprotect", "statx"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
EOF
# Seccomp profilini kullanarak konteyneri başlat
docker run -d
--security-opt seccomp=/etc/docker/seccomp/myapp-profile.json
--name secure-app
myapp:latest
AppArmor profili ise dosya sistemi erişimini ve ağ işlemlerini kontrol eder:
# AppArmor profili kontrolü
aa-status | grep docker
# Docker'ın varsayılan AppArmor profilini görmek için
cat /etc/apparmor.d/docker-default
# Konteyneri özel AppArmor profiliyle başlat
docker run --security-opt apparmor=docker-default nginx:alpine
Ağ Güvenliği ve İzolasyon
Varsayılan Docker kurulumunda tüm konteynerler aynı bridge network üzerinde birbirleriyle konuşabilir. Bu büyük bir güvenlik açığı. Bir konteynere giren saldırgan, tüm diğer konteynerlere de ulaşabilir.
# Servisler arası izolasyon için ayrı network'ler oluştur
docker network create --driver bridge frontend-net
docker network create --driver bridge backend-net
docker network create --driver bridge db-net
# Frontend sadece frontend-net'te
docker run -d --name nginx --network frontend-net nginx:alpine
# Backend hem frontend-net hem backend-net'te (API bridge noktası)
docker run -d --name api --network backend-net myapi:latest
docker network connect frontend-net api
# Database sadece backend-net'te, dışarıya kapalı
docker run -d --name postgres --network db-net postgres:15-alpine
docker network connect backend-net postgres
# Internal network (dış dünyaya erişimi yok)
docker network create --internal --driver bridge isolated-net
docker run -d --name internal-service --network isolated-net myservice:latest
Docker Compose ile bu yapıyı daha temiz yönetebilirsiniz:
version: "3.8"
services:
nginx:
image: nginx:alpine
networks:
- frontend
ports:
- "443:443"
api:
image: myapi:latest
networks:
- frontend
- backend
# Port expose etme, sadece network üzerinden erişim
postgres:
image: postgres:15-alpine
networks:
- backend
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # Dış dünyaya erişim yok
secrets:
db_password:
file: ./secrets/db_password.txt
no-new-privileges ve Diğer Güvenlik Seçenekleri
no-new-privileges seçeneği, bir konteynerdeki process’lerin setuid/setgid bitleri veya dosya capabilities’leri aracılığıyla yeni yetkiler kazanmasını engeller. Bu özellikle privilege escalation saldırılarına karşı önemli.
# no-new-privileges ile konteyner başlatma
docker run -d
--security-opt no-new-privileges:true
--name secure-nginx
nginx:alpine
# Kullanıcı namespace remapping - host root ile konteyner root'u ayır
# /etc/docker/daemon.json dosyasına ekle:
cat >> /etc/docker/daemon.json << 'EOF'
{
"userns-remap": "default"
}
EOF
# Docker daemon'ı yeniden başlat
systemctl restart docker
# Konteyner içindeki root, host üzerinde çok düşük yetkili kullanıcıya map edilir
docker run --rm -it alpine id
# uid=0(root) gid=0(root) groups=0(root)
# ama host üzerinde bu aslında uid=100000 gibi bir kullanıcı
Secret Yönetimi
Dockerfile içinde ya da environment variable olarak şifre ve API key geçmek çok yaygın ve çok tehlikeli bir pratik. Image katmanları bu bilgileri kalıcı olarak saklar.
# Kötü: Dockerfile içinde secret
# FROM alpine
# ENV DB_PASSWORD=supersecret123 <- YAPMAYIN
# Kötü: docker run'da environment variable
# docker run -e DB_PASSWORD=supersecret123 myapp <- YAPMAYIN
# İyi: Docker secrets kullanımı (Swarm mode)
echo "supersecret123" | docker secret create db_password -
docker service create
--name myapp
--secret db_password
myapp:latest
# Konteyner içinde /run/secrets/db_password dosyasından okuyun
# İyi: Harici secret manager ile entegrasyon
# HashiCorp Vault'tan secret çekme örneği
docker run -d
--name myapp
-e VAULT_ADDR=https://vault.internal:8200
-e VAULT_TOKEN_FILE=/run/secrets/vault_token
--secret vault_token
myapp:latest
Image Güvenlik Taraması
Güvenli bir konteyner sadece runtime ayarlarıyla değil, temiz bir image ile başlar. CI/CD pipeline’ınıza mutlaka image taraması ekleyin.
# Trivy ile image taraması (ücretsiz, açık kaynak)
# Kurulum
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Image tarama
trivy image nginx:latest
# Sadece HIGH ve CRITICAL açıkları göster
trivy image --severity HIGH,CRITICAL nginx:latest
# CI/CD için çıktıyı JSON formatında al
trivy image --format json --output results.json myapp:latest
# Dockerfile taraması
trivy config Dockerfile
# Dockerfile best practices için hadolint
docker run --rm -i hadolint/hadolint < Dockerfile
Docker Daemon Güvenliği
Konteynerleri güvene almak yeterli değil, daemon’ın kendisini de sertleştirmeniz gerekiyor.
# /etc/docker/daemon.json için güvenli konfigürasyon
cat > /etc/docker/daemon.json << 'EOF'
{
"icc": false,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"no-new-privileges": true,
"live-restore": true,
"userland-proxy": false,
"seccomp-profile": "/etc/docker/seccomp/default.json",
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 64000,
"Soft": 32000
}
}
}
EOF
# ICC (Inter-Container Communication) kapalıyken
# spesifik iletişime izin vermek için network kullanın
# icc: false konteynerlerin bridge üzerinden direkt konuşmasını engeller
systemctl restart docker
Docker socket’ına erişimi de kısıtlayın. Docker socket’ı root yetkisi demektir.
# Docker socket'ına erişimi sadece docker grubuna ver
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker
# Bir uygulamanın docker socket'ına ihtiyacı varsa (CI/CD agent gibi)
# Docker socket proxy kullan
docker run -d
--name docker-socket-proxy
-v /var/run/docker.sock:/var/run/docker.sock
-e CONTAINERS=1
-e IMAGES=1
-e NETWORKS=0
-e VOLUMES=0
-e POST=0
tecnativa/docker-socket-proxy
CIS Benchmark ile Uyumluluk Kontrolü
Tüm bu ayarları yaptıktan sonra Docker CIS Benchmark’ı çalıştırarak eksiklerinizi tespit edebilirsiniz.
# Docker Bench Security aracı
docker run -it --net host --pid host --userns host --cap-add audit_control
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST
-v /etc:/etc:ro
-v /lib/systemd/system:/lib/systemd/system:ro
-v /usr/bin/containerd:/usr/bin/containerd:ro
-v /usr/bin/runc:/usr/bin/runc:ro
-v /usr/lib/systemd:/usr/lib/systemd:ro
-v /var/lib:/var/lib:ro
-v /var/run/docker.sock:/var/run/docker.sock:ro
--label docker_bench_security
docker/docker-bench-security
Çıktıda [PASS], [WARN] ve [INFO] etiketleri göreceksiniz. WARN olanları öncelikli olarak ele alın.
Gerçek Dünya Senaryosu: Production Web Uygulaması
Tüm bu bilgileri bir araya getirip production ortamına hazır bir kurulum yapalım:
# Güvenli bir Node.js web uygulaması için tam Dockerfile
cat > Dockerfile << 'EOF'
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM node:18-alpine
LABEL maintainer="[email protected]"
# Gereksiz paketleri kaldır
RUN apk del --no-cache apk-tools &&
rm -rf /var/cache/apk/*
WORKDIR /app
# Non-root kullanıcı
RUN addgroup -g 1001 -S nodejs &&
adduser -S nodeuser -u 1001 -G nodejs
# Dosyaları kopyala
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --chown=nodeuser:nodejs src/ ./src/
COPY --chown=nodeuser:nodejs package.json ./
USER nodeuser
# Port tanımla ama bind etme
EXPOSE 3000
# Health check ekle
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "src/server.js"]
EOF
# Bu image'ı güvenli şekilde çalıştır
docker run -d
--name production-app
--read-only
--tmpfs /tmp:rw,noexec,nosuid,size=100m
--cap-drop=ALL
--cap-add=NET_BIND_SERVICE
--security-opt no-new-privileges:true
--security-opt seccomp=/etc/docker/seccomp/nodejs-profile.json
--network app-network
--memory=512m
--cpus=1
--pids-limit=100
--restart unless-stopped
production-app:latest
Sonuç
Konteyner güvenliği tek bir önlemle sağlanmıyor. Katmanlı bir yaklaşım gerekiyor. En azından şu beş temel adımı atlamazsanız zaten büyük çoğunluğu kapatmış olursunuz:
- Non-root kullanıcı: Konteynerlerinizi asla root olarak çalıştırmayın
- Minimal image: Distroless veya Alpine tabanlı ince image’lar kullanın
- Read-only filesystem: Mümkün olduğunda dosya sistemi yazılabilirliğini kaldırın
- Capability kısıtlama:
--cap-drop=ALLile başlayıp sadece gerekli olanları ekleyin - Ağ segmentasyonu: Konteynerleri işlevlerine göre ayrı networklere koyun
Bunları CI/CD sürecinize entegre edin, Trivy gibi araçlarla image taramasını otomatik hale getirin ve Docker Bench’i periyodik çalıştırın. Güvenlik bir kez yapıp unutulan bir şey değil, süregelen bir pratik. Base image’larınızı her ay güncelleyin, eski ve kullanılmayan konteynerleri temizleyin, logları düzenli inceleyin.
Başlangıç için bunaltıcı gelebilir, ama küçük adımlarla başlayın. Önce non-root kullanıcıya geçin. Sonra capability kısıtlaması yapın. Her adımda sistemlerinizin çok daha güvenli hale geldiğini göreceksiniz.