~/yazilar/iyi-bir-dockerfile-yazmak
Küçük ve güvenli bir image için Dockerfile yazmak
içindekiler
$ docker images uygulama-web
REPOSITORY TAG IMAGE ID CREATED SIZE
uygulama-web 1.0.0 ... 3 minutes ago 1.1GBBirkaç yüz kilobaytlık bir Python uygulaması için bir gigabaytın üstünde bir image. En düz haliyle yazılmış bir Dockerfile'dan çıkan sonuç tipik olarak buna benziyor:
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD gunicorn -b 0.0.0.0:8000 app:appBeş satır ve çalışıyor. Ama içinde beş ayrı sorun var. Base image derleyiciler ve geliştirme başlıklarıyla dolu tam bir Debian. Kodda tek satır değişse bile bütün bağımlılıklar baştan kuruluyor. Build context'te ne varsa (.git, .env, test verisi) image'a giriyor. Uygulama root olarak çalışıyor. CMD kabuk biçiminde yazıldığı için docker stop sinyalinin uygulamaya ulaşması da garanti değil.
Bunları sırayla düzeltiyorum.
Base image seçimi
Resmi Python image'ının üç yaygın çeşidi var. Etiketsiz olan (python:3.12) tam bir Debian ve içinde gcc, başlık dosyaları, bir sürü araç geliyor. -slim aynı Debian'ın kırpılmış hali. -alpine ise musl libc kullanan Alpine tabanlı ve en küçüğü.
Python için ben -slim kullanıyorum. Alpine küçük ama musl yüzünden bazı paketlerin hazır wheel'i olmayabiliyor ve pip onları kaynaktan derlemeye kalkıyor. Build süresi uzuyor, image'a derleme araçları girince de boyut avantajı kayboluyor. Go gibi statik binary üreten dillerde durum farklı, orada son aşamada distroless ya da hiç işletim sistemi içermeyen scratch bile kullanılabiliyor. Python'da gcr.io/distroless/python3 da bir seçenek, ama içinde kabuk olmadığı için hata ayıklamak zorlaşıyor ve Python sürümünü image belirliyor.
Etiketi ne kadar belirgin yazdığın da önemli. python:3.12-slim Python'un yama sürümlerini ve altındaki Debian'ı zamanla değiştirebiliyor. Daha sabit bir şey istiyorsan Debian'ın kod adını da etikete ekle (python:3.12-slim-<kod-adı> biçiminde). Tamamen tekrarlanabilir build için image'ı digest ile sabitlemek de mümkün: python:3.12-slim@sha256:.... Digest ile sabitlersen base image'daki güvenlik yamalarını kendiliğinden almazsın, digest'i düzenli güncellemen gerekir. Ben etiketle sabitleyip düzenli aralıklarla --pull ile yeniden build alıyorum.
Katman cache'i
Dockerfile'daki her talimat bir katman üretiyor ve Docker bir talimatın girdisi değişmediyse o katmanı yeniden üretmek yerine cache'ten alıyor. COPY için girdi kopyalanan dosyaların içeriği. Bir katman değişince ondan sonraki bütün katmanlar da yeniden üretiliyor.
İlk Dockerfile'da COPY . . bağımlılık kurulumundan önce geliyor. Kodda tek bir dosya değişince COPY katmanı değişiyor ve arkasından gelen pip install her seferinde baştan çalışıyor. Çözüm, seyrek değişen şeyleri üste, sık değişenleri alta koymak:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .Artık sadece requirements.txt değişince bağımlılıklar yeniden kuruluyor. Kod değiştiğinde build çıktısında bağımlılık adımının yanında CACHED yazıyor:
=> CACHED [builder 5/5] RUN --mount=type=cache,target=/root/.cache/pip pip install -r /tmp/requirements.txt
=> [stage-1 6/6] COPY . .requirements.txt içinde sürümleri sabitle. flask yerine flask== ve tam sürüm yaz (pip freeze ya da pip-compile çıktısı). Sabitlenmemiş bir dosyadan bugün aldığın build ile yarın aldığın build farklı paketler içerebiliyor ve cache bunu görmüyor, çünkü dosyanın içeriği değişmedi.
.dockerignore
docker build başlarken build context'teki bütün dosyaları BuildKit'e gönderiyor ve COPY . . bunların hepsini image'a alıyor. .git dizini, yerel sanal ortam, test verisi, bazen de .env. Proje kökünden build alınıyorsa ve orada .env varsa, image'ı çeken herkes parolaları şu komutla okuyabilir:
docker run --rm uygulama-web:1.0.0 cat /app/.envapp/.dockerignore:
.git
.gitignore
.env
.env.*
!.env.example
__pycache__/
*.pyc
.venv/
.pytest_cache/
tests/
Dockerfile
.dockerignore
*.mdBuild context küçüldüğü için build de hızlanıyor. .git dizini büyük bir projede yüzlerce megabayt olabiliyor ve her build'de baştan gönderiliyor. Bir de cache tarafı var: .git içindeki dosyalar her commit'te değiştiği için, dışarıda bırakılmazsa COPY . . katmanı kodda değişiklik olmasa bile yeniden üretiliyor.
Multi-stage build
Bazı Python paketleri kurulurken derleme istiyor. Örneğin psycopg'nin C uzantılı sürümü (psycopg[c]) için gcc ve libpq-dev gerekiyor. Bunları image'a kurarsam derleme araçları üretim image'ında kalıyor. Hem boyut büyüyor hem de saldırgana hazır bir derleyici bırakmış oluyorum.
Multi-stage build bu ikilemi çözüyor. İlk aşamada derleme araçlarıyla bağımlılıkları bir sanal ortama kuruyorum, ikinci aşamada sadece o sanal ortamı ve çalışma zamanında gereken kütüphaneyi (libpq5) alıyorum. İlk aşamadaki her şey son image'a girmiyor.
requirements.txt şuna benziyor (sürümleri kendi projene göre sabitle):
flask
gunicorn
psycopg[c]
redisDockerfile'ın son hali:
# syntax=docker/dockerfile:1
ARG PYTHON_IMAGE=python:3.12-slim
# 1. aşama: bağımlılıkları derle
FROM ${PYTHON_IMAGE} AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt /tmp/requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r /tmp/requirements.txt
# 2. aşama: sadece çalışması için gerekenler
FROM ${PYTHON_IMAGE}
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY . .
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]ARG PYTHON_IMAGE iki aşamanın da aynı base image'dan başlamasını garanti ediyor. Sanal ortamı bir aşamadan diğerine kopyalamanın şartı bu: venv içindeki script'lerin ilk satırında Python yorumlayıcısının tam yolu yazıyor ve iki aşamada bu yol aynı olmalı. Aynı sebeple venv'i iki aşamada da /opt/venv gibi aynı yolda tutuyorum.
apt-get update ile apt-get install aynı RUN içinde. Ayrı olsalar update katmanı cache'ten gelir, paket listesi haftalar öncesinden kalır ve install artık depoda olmayan bir sürümü aramaya kalkabilir. Sondaki rm -rf /var/lib/apt/lists/* paket listelerini aynı katmanda siliyor. Ayrı bir RUN ile silseydim dosyalar bir önceki katmanda durmaya devam ederdi, image küçülmezdi.
--mount=type=cache pip'in indirdiği paketleri build'ler arasında saklıyor ama image'a koymuyor. requirements.txt değişince bütün paketler yeniden indirilmiyor, sadece değişenler iniyor. Bu özellik BuildKit gerektiriyor. Güncel Docker'da BuildKit varsayılan olarak açık, dosyanın ilk satırındaki # syntax= de güncel Dockerfile sözdizimini kullanmasını sağlıyor.
CMD dizi (exec) biçiminde. Kabuk biçiminde yazınca (CMD gunicorn ...) Docker komutu /bin/sh -c ile çalıştırıyor. Bazı kabuklar tek komutluk -c çağrısında kendini o komutla değiştiriyor, bazıları değiştirmiyor. Değiştirmezse PID 1 kabuk oluyor ve docker stop'un gönderdiği SIGTERM gunicorn'a ulaşmıyor. Docker on saniye bekleyip SIGKILL gönderiyor, her deploy'da on saniye kayıp ve yarıda kesilen istekler. Kabuğun davranışına güvenmek yerine dizi biçimini kullanıyorum, sinyal doğrudan gunicorn'a gidiyor.
HEALTHCHECK uygulamanın /health diye basit bir uç sunduğunu varsayıyor. Seninkinde yoksa 200 dönen herhangi bir yolu kullanabilirsin. Bunun için curl kurmadım. Slim image'da curl yok, sadece healthcheck için kurmak da gereksiz bir paket demek. Python zaten orada.
Root olmayan kullanıcı
Varsayılan olarak container içindeki süreç root olarak çalışıyor. Container'ın root'u host'un root'uyla aynı UID. Namespace'ler ve Docker'ın kısıtlamaları arada duruyor. Ama bir kernel açığı ya da yanlış bir mount (ör. host'tan bağlanmış bir dizin) devreye girdiğinde bu ayrım önem kazanıyor. Uygulamanın root olmasına gerek yoksa root olmasın.
UID'yi 10001 gibi yüksek ve sabit bir değer seçtim. Sabit olması, host'tan bağlanan dizinlerde ve Kubernetes'te runAsUser gibi ayarlarda işi kolaylaştırıyor. Yüksek olması da host'taki gerçek bir kullanıcıyla çakışma ihtimalini azaltıyor.
Bir ayrıntı daha: COPY . . dosyaları root sahipliğiyle kopyalıyor ve USER app bu satırdan sonra geliyor. Yani uygulama kendi kodunu okuyabiliyor ama değiştiremiyor. Birçok örnekte COPY --chown=app:app görürsün. Uygulamanın kendi dosyalarına yazması gerekmiyorsa ben bunu tercih etmiyorum, çünkü uygulamadaki bir açık kodun üzerine yazmak için kullanılabiliyor.
Uygulamanın yazması gereken bir dizin varsa (yüklenen dosyalar gibi) volume'la birlikte bir sorun çıkıyor:
PermissionError: [Errno 13] Permission denied: '/app/uploads/rapor.pdf'Named volume ilk oluşturulduğunda Docker, image'daki aynı yoldaki dizinin içeriğini ve sahipliğini volume'a kopyalıyor. Image'da o dizin yoksa volume root sahipliğiyle oluşuyor ve app kullanıcısı yazamıyor. Çözüm, dizini image'da doğru sahiple oluşturmak. USER app satırından önce:
RUN mkdir -p /app/uploads && chown app:app /app/uploadsVolume daha önce root sahipliğiyle oluşturulduysa bu satır yetmiyor, volume'u bir kere silip yeniden oluşturman ya da sahipliğini elle düzeltmen gerekiyor.
Build ve kontrol
docker build --pull -t uygulama-web:1.0.0 ./app
docker images uygulama-web
docker run --rm uygulama-web:1.0.0 idREPOSITORY TAG IMAGE ID CREATED SIZE
uygulama-web 1.0.0 ... 12 seconds ago ~170MB
uid=10001(app) gid=10001(app) groups=10001(app)--pull, base image'ın yerelde olan kopyası yerine registry'deki güncel halini çekiyor. Bunu vermezsen aylar önce çektiğin, yamaları eksik bir base image üzerine build almaya devam edebilirsin.
Hangi katmanın ne kadar yer tuttuğunu docker history gösteriyor:
docker history --format '{{.Size}}\t{{.CreatedBy}}' uygulama-web:1.0.0 | headBeklenmedik büyüklükte bir katman görürsen genelde sebep ya gereksiz bir paket ya da sonradan ayrı katmanda silinmeye çalışılan dosyalar oluyor.
Bir aşamada sorun varsa sadece o aşamaya kadar build alabilirsin. Builder aşamasında hangi paketlerin derlendiğine bakmak için işe yarıyor:
docker build --target builder -t uygulama-web:debug ./app
docker run --rm -it uygulama-web:debug bashDockerfile'daki yaygın hataları yakalamak için Docker'ın yerleşik kontrolleri de var:
docker build --check ./appBu kontrol, kabuk biçimindeki CMD gibi şeyleri uyarı olarak gösteriyor. Daha kapsamlı bir linter istersen hadolint da yaygın kullanılıyor.
Compose dosyasında hiçbir şey değişmiyor, docker compose up -d --build yeni Dockerfile ile image'ı yeniden üretiyor. Image artık küçük ve root değil, ama Docker'ın kendisinin ne kadar yetkiyle çalıştığı, container'a hangi yetkilerin verildiği ve image'daki paketlerde bilinen açık olup olmadığı hâlâ açık sorular.
Sıradaki yazı: Docker'da güvenliği sıkılaştırmak