Mert Özen Her Satırda Daha İleri
DevOps Docker — #16

Dockerfile'ın Az Bilinen Instruction ve Bayrakları: Heredoc, SHELL, ADD'nin Git/URL Kaynakları, COPY --exclude/--parents/--link ve Build Check'ler

Mert Özen 15 Kas 2026 17 dk 5 görüntülenme
Dockerfile'ın Az Bilinen Instruction ve Bayrakları: Heredoc, SHELL, ADD'nin Git/URL Kaynakları, COPY --exclude/--parents/--link ve Build Check'ler

Dockerfile'ın yeni bayrakları güvenli kullanılırsa build'i hem kısaltır hem sağlamlaştırır: heredoc'ta set -eu, ADD'de --checksum, COPY'de --parents ve --exclude, publish'te RUN --network=none. Frontend'in docker/dockerfile:1 tag'inin yazım günü 1.27.1'de kaldığını ve 1.29.0 ile davranışın nasıl değiştiğini gerçek çıktılarla gösteriyor, SHELL ile pipefail, ONBUILD, build check'ler ve hadolint'i Bookstore.Api'nin yeni Dockerfile'ında birleştiriyoruz.

Sürüm notu: Bu yazıdaki örnekler Docker Engine 29.9.3 (BuildKit 0.34.2), Dockerfile frontend 1.29.0 (karşılaştırma için 1.27.1), hadolint 2.15.1, .NET SDK 10.0.404 ve go-sqlcmd 1.10.0 ile denenmiştir.

Önce Tahmin Et

Aşağıdaki Dockerfile çok satırlı bir RUN'ı heredoc ile yazıyor. Üçüncü satırdaki cp olmayan bir dosyayı kopyalamaya çalışıyor. Build ne yapar: (a) cp satırında durur, (b) devam eder ve başarılı biter? İkinci soru: # syntax=docker/dockerfile:1 yazan bir Dockerfile bugün frontend'in hangi sürümüyle build edilir?

# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
RUN <<EOT
echo "1: creating /data"
mkdir /data
cp /missing/books.csv /data/
echo "3: done"
EOT

5.–7. yazılarda Dockerfile'ın temel instruction'larını, cache mount'u, secret'ları ve multi-stage build'i gördük. Bu yazı son yıllarda eklenen ama çoğu projede hâlâ görmediğin bayrakları ve az kullanılan birkaç eski instruction'ı ele alıyor. Sonunda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API Dockerfile'ını bunlarla yeniden yazıp build check'lerden ve hadolint'ten geçireceğiz. Lab dosyaları labs/16-dockerfile/ altında, hepsini çalıştıran script labs/16-dockerfile.sh.

syntax Satırı: Frontend Sürümünü Sabitlemek

İlk satırdaki # syntax= direktifi BuildKit'e Dockerfile'ı hangi frontend'in (Dockerfile'ı build adımlarına çeviren parser) yorumlayacağını söyler. Frontend Docker Hub'dan bir image olarak çekilir; satır yoksa Engine'e gömülü sürüm kullanılır. Dokümana göre docker/dockerfile:1 1.x serisinin en güncel kararlı sürümü demek. Tag'lerin bugün nereyi gösterdiğine bakalım:

for t in 1 1.27.1 1.28.0 1.29.0; do
    printf "dockerfile:%-7s " $t
    docker buildx imagetools inspect docker/dockerfile:$t --format '{{.Manifest.Digest}}' | cut -c1-19
done
dockerfile:1       sha256:4edf897a3ffa
dockerfile:1.27.1  sha256:4edf897a3ffa
dockerfile:1.28.0  sha256:bb22d9815c72
dockerfile:1.29.0  sha256:572cebbc2b15

Bu yazıyı hazırladığım gün (15 Kasım 2026) 1 tag'i 30 Eylül'de çıkan 1.27.1'i gösteriyordu; 1.28.0 (7 Ekim) ve 1.29.0 (11 Kasım) yayımlanmış olmasına rağmen. Yani :1 her zaman en yenisi değil, Docker'ın ne zaman taşıdığına bağlı hareketli bir tag. Aradaki fark da önemsiz değil. 1.29.0'ın release notlarına göre ARG ve STOPSIGNAL'daki bilinmeyen bayraklar artık sessizce yok sayılmıyor:

# syntax=docker/dockerfile:1
FROM alpine:3.24
ARG --required APP_VERSION=1.3.0
RUN echo "version: $APP_VERSION"
# syntax=docker/dockerfile:1 ile
#7 [2/2] RUN echo "version: 1.3.0"
#7 0.077 version: 1.3.0

# syntax=docker/dockerfile:1.29.0 ile
ERROR: failed to build: failed to solve: dockerfile parse error on line 3: unknown flag: --required

ARG'ın --required diye bir bayrağı yok. 1.27.1 bunu görmezden geliyor ve build yeşil; 1.29.0 parse hatası veriyor. :1 tag'i 1.29'a geçtiği gün bu Dockerfile kimse dokunmadan kırılacak. Tersi de var: 1.27.1 bir güvenlik düzeltmesiydi (16 MiB'tan büyük Dockerfile'ları reddediyor) ve tam sürüme sabitlenmiş bir Dockerfile bu tür düzeltmeleri kendiliğinden almaz.

Benim tercihim tam sürüme sabitlemek (docker/dockerfile:1.29.0) ve bu satırı base image'larla aynı süreçte, bilerek güncellemek. Aşağıda göreceğimiz check=error=true'yu kullanıyorsan Docker'ın dokümanı da sabitlemeyi öneriyor: yeni frontend yeni check'ler getirir ve sabitlenmemiş bir Dockerfile bir sabah yeni bir kural yüzünden kırılabilir. Daha sıkısı digest'le sabitlemek (docker/dockerfile:1.29.0@sha256:...), 4. yazıdaki image pin'lemenin aynısı.

Heredoc: Çok Satırlı RUN ve Image İçinde Dosya

Heredoc'lar frontend 1.4'ten (2022) beri kararlı. RUN <<EOT ile EOT arasındaki satırlar varsayılan shell'e script olarak verilir; satır sonlarına && \ yazmak gerekmez. Önce Tahmin Et'teki build'in çıktısı:

#7 [2/2] RUN <<EOT (echo "1: creating /data"...)
#7 0.083 1: creating /data
#7 0.083 cp: can't stat '/missing/books.csv': No such file or directory
#7 0.084 3: done
#7 DONE 0.1s

İlk sorunun cevabı (b): cp hata verdi ama build başarılı bitti. Heredoc bir shell script'i gibi çalışır ve shell script'i varsayılan olarak hatada durmaz; adımın exit code'u son komutun exit code'udur. && zincirinde bu sorun yoktu, çünkü her komut bir öncekinin başarısına bağlıydı. Çözüm, script'in ilk satırına set -eu yazmak (-e ilk hatada durur, -u tanımsız değişkeni hata sayar):

#7 [2/2] RUN <<EOT (set -eu...)
#7 0.084 1: creating /data
#7 0.084 cp: can't stat '/missing/books.csv': No such file or directory
#7 ERROR: process "/bin/sh -c set -eu\necho \"1: creating /data\"\nmkdir /data\ncp /missing/books.csv /data/\necho \"3: done\"\n" did not complete successfully: exit code: 1

COPY ile heredoc kullanınca build context'te olmayan bir dosyayı image içinde üretebilirsin: küçük bir entrypoint script'i ya da config dosyası. Burada bir kural önemli: heredoc'un sınırlayıcısı tırnaksızsa (<<EOF) içerideki değişkenler build sırasında açılır; tırnak içindeyse (<<'EOF') metin olduğu gibi kopyalanır ve değişkenler container çalışırken açılır. Script yazarken neredeyse her zaman ikincisini istersin. Tırnaksız yazınca:

# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
COPY <<EOF /usr/local/bin/apply-db.sh
for f in /db/*.sql; do
    echo "applying $f to $SQLCMDSERVER"
done
EOF
RUN cat /usr/local/bin/apply-db.sh
#9 [3/3] RUN cat /usr/local/bin/apply-db.sh
#9 0.080 for f in /db/*.sql; do
#9 0.080     echo "applying  to "
#9 0.080 done

Döngü değişkeni $f ve ortam değişkeni $SQLCMDSERVER build sırasında boş string'e döndü; script bozuk. İyi haber, docker build --check bunu yakalıyor:

Check complete, 2 warnings have been found!

WARNING: UndefinedVar - https://docs.docker.com/go/dockerfile/rule/undefined-var/
Usage of undefined variable '$SQLCMDSERVER'
Dockerfile.heredoc-unquoted:3

WARNING: UndefinedVar - https://docs.docker.com/go/dockerfile/rule/undefined-var/
Usage of undefined variable '$f'
Dockerfile.heredoc-unquoted:3

ADD: URL, Checksum ve Git Kaynağı

5. yazıda yerel dosyalar için COPY'yi önermiş, ADD'nin URL ve Git kaynağı gibi ek işleri olduğunu söylemiştik. Bu ek işler doğru kullanılınca değerli. Bookstore'un veritabanı script'lerini (db/*.sql, 11. yazı) bir job olarak çalıştıracak küçük bir image düşünelim; içinde Microsoft'un Go ile yazılmış sqlcmd'si olsun (labs/16-dockerfile/Dockerfile.dbinit):

# syntax=docker/dockerfile:1.29.0
ARG SQLCMD_VERSION=1.10.0

# her mimari için ayrı stage: her indirmenin kendi checksum'ı var
FROM scratch AS sqlcmd-amd64
ARG SQLCMD_VERSION
ADD --unpack=true --checksum=sha256:92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987 \
    https://github.com/microsoft/go-sqlcmd/releases/download/v${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2 /

FROM scratch AS sqlcmd-arm64
ARG SQLCMD_VERSION
ADD --unpack=true --checksum=sha256:9faaa981f9c374f319ac796dedb4678499b8596c87d5b6c512e9b0e7a3b74f8e \
    https://github.com/microsoft/go-sqlcmd/releases/download/v${SQLCMD_VERSION}/sqlcmd-linux-arm64.tar.bz2 /

# stage'i TARGETARCH seçer; BuildKit diğerini hiç çalıştırmaz
# hadolint ignore=DL3006
FROM sqlcmd-${TARGETARCH} AS sqlcmd

FROM alpine:3.24 AS final
COPY --from=sqlcmd /sqlcmd /usr/local/bin/sqlcmd
COPY db/ /db/
COPY --chmod=755 <<'EOF' /usr/local/bin/apply-db.sh
#!/bin/sh
set -eu
for f in /db/*.sql; do
    echo "applying $f to $SQLCMDSERVER"
    sqlcmd -C -b -i "$f"
done
EOF

USER 1000
ENTRYPOINT ["/usr/local/bin/apply-db.sh"]

Burada üç şey birlikte çalışıyor. --checksum, indirilen dosyanın içeriğinden hesaplanan SHA (Secure Hash Algorithm; dosyanın parmak izi gibi çalışan özet) değerini doğrular, desteklenen tek algoritma SHA-256; URL'deki dosya değişirse ya da araya biri girerse build durur. --unpack=true uzak bir arşivi açar; yerel tar'lar varsayılan olarak açılır, uzaktakiler açılmaz. Her mimarinin dosyası ve checksum'ı farklı olduğu için iki stage var; FROM sqlcmd-${TARGETARCH} doğru olanı seçiyor. x64 için build:

docker build --progress=plain -f labs/16-dockerfile/Dockerfile.dbinit -t dockerseries-bookstore-dbinit:1.3.0 .
#9 [sqlcmd-amd64 1/1] ADD --unpack=true --checksum=sha256:92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987     https://github.com/microsoft/go-sqlcmd/releases/download/v1.10.0/sqlcmd-linux-amd64.tar.bz2 /
#9 DONE 1.0s
#11 [final 2/4] COPY --from=sqlcmd /sqlcmd /usr/local/bin/sqlcmd
#12 [final 3/4] COPY db/ /db/
#13 [final 4/4] COPY --chmod=755 <<EOF /usr/local/bin/apply-db.sh

Image çalışıyor ve set -eu eksik ortam değişkenini daha veritabanına gitmeden yakalıyor (SQL Server'lı çalıştırma 11. ve 12. yazıdaki gibi):

docker run --rm --entrypoint sqlcmd dockerseries-bookstore-dbinit:1.3.0 --version
docker run --rm dockerseries-bookstore-dbinit:1.3.0
sqlcmd: Install/Create/Query SQL Server, Azure SQL, and Tools

Version: v1.10.0
...
/usr/local/bin/apply-db.sh: line 4: SQLCMDSERVER: parameter not set

Checksum'daki tek karakteri değiştirince (önce hesaplanan, sonra beklenen değer):

#9 ERROR: digest mismatch sha256:92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987: sha256:92516d98c63e99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987

Checksum'ı bir kere kendin hesaplarsın (sha256sum ya da PowerShell'de Get-FileHash) ya da proje yayımlıyorsa release sayfasından alırsın. Kimlik doğrulama isteyen URL'ler için Docker'ın build secret'larıyla verilen HTTP header'ları ya da 6. yazıdaki gibi secret mount'lu bir RUN curl gerekir.

Git kaynağında checksum commit SHA'sıdır. Tag başka bir commit'e taşınabilir, commit değişmez:

# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
# tag taşınabilir; commit checksum'ı değişmez
ADD --checksum=affbc17e1e0e97b0bb625ef8185afea3d746067f https://github.com/microsoft/go-sqlcmd.git#v1.10.0 /src/go-sqlcmd
RUN ls /src/go-sqlcmd | head -5 && test ! -d /src/go-sqlcmd/.git
#7 [2/3] ADD --checksum=affbc17e1e0e97b0bb625ef8185afea3d746067f https://github.com/microsoft/go-sqlcmd.git#v1.10.0 /src/go-sqlcmd
#7 0.643 affbc17e1e0e97b0bb625ef8185afea3d746067f	refs/tags/v1.10.0
...
#9 [3/3] RUN ls /src/go-sqlcmd | head -5 && test ! -d /src/go-sqlcmd/.git
#9 0.083 CODE_OF_CONDUCT.md
#9 0.083 Dockerfile
#9 0.083 LICENSE
#9 0.083 NOTICE.md
#9 0.083 README.md

# checksum'da bir karakter farklıyken
ERROR: failed to build: failed to solve: failed to load cache key: expected checksum to match affbc17e1e0f97b0bb625ef8185afea3d746067f, got affbc17e1e0e97b0bb625ef8185afea3d746067f

.git klasörü varsayılan olarak kopyalanmıyor; gerekiyorsa --keep-git-dir=true. Private repository için SSH (Secure Shell; uzak makineye şifreli bağlantı protokolü) adresi ve docker build --ssh default gerekir (6. yazı).

.NET projelerinde restore cache'i için önce yalnız csproj'ları kopyalıyoruz (6. yazı). Wildcard'la kopyalayınca klasör yapısı kayboluyor; frontend 1.20'den beri kararlı olan --parents yapıyı koruyor:

# syntax=docker/dockerfile:1.29.0
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS flat
WORKDIR /src
COPY src/*/*.csproj tests/*/*.csproj ./
RUN find . -type f | sort

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS parents
WORKDIR /src
COPY --parents Bookstore.slnx global.json src/*/*.csproj tests/*/*.csproj ./
RUN find . -type f | sort
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet restore Bookstore.slnx
#10 [flat 4/4] RUN find . -type f | sort
#10 0.079 ./Bookstore.Api.csproj
#10 0.079 ./Bookstore.Tools.Tests.csproj
#10 0.079 ./Bookstore.Tools.csproj

#10 [parents 4/5] RUN find . -type f | sort
#10 0.073 ./Bookstore.slnx
#10 0.073 ./global.json
#10 0.073 ./src/Bookstore.Api/Bookstore.Api.csproj
#10 0.073 ./src/Bookstore.Tools/Bookstore.Tools.csproj
#10 0.073 ./tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj
#11 [parents 5/5] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet restore Bookstore.slnx
#11 4.186   Restored /src/src/Bookstore.Tools/Bookstore.Tools.csproj (in 3.55 sec).
#11 4.619   Restored /src/src/Bookstore.Api/Bookstore.Api.csproj (in 4 sec).
#11 4.781   Restored /src/tests/Bookstore.Tools.Tests/Bookstore.Tools.Tests.csproj (in 4.16 sec).

Klasik COPY üç csproj'u /src'ye düz bir liste olarak koydu ve Bookstore.slnx'in beklediği yollar kayboldu. --parents ile yapı korunuyor ve tek komutla bütün solution restore ediliyor; her csproj için ayrı bir COPY satırı gerekmiyor.

Frontend 1.19'dan beri kararlı olan --exclude, .dockerignore'un instruction düzeyindeki karşılığı. .dockerignore bütün build context'i etkiler; bizim repository'mizde API, Tools ve testler aynı context'i paylaşıyor. Bookstore.Api'nin Dockerfile'ı src/Bookstore.Api/ içinde durduğu için Dockerfile'da bir yorumu düzeltmek bile o klasörün COPY'sini değiştiriyor ve publish yeniden çalışıyor. 15. yazıdaki Dockerfile'ın sonuna bir yorum satırı ekleyip tekrar build edince:

#14 [build 6/7] COPY src/Bookstore.Api/ src/Bookstore.Api/
#14 DONE 0.0s
#15 [build 7/7] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -c Release -a amd64         --no-restore -o /app -p:Version=1.0.0
#15 DONE 1.4s

Yeni Dockerfile'da yalnız geliştirici makinesinde anlamı olan dosyaları dışarıda bırakıyoruz (yollar kaynak klasöre göre):

COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json \
    src/Bookstore.Api/ src/Bookstore.Api/
#10 [build 5/6] COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json     src/Bookstore.Api/ src/Bookstore.Api/
#10 CACHED
#16 [build 6/6] RUN --network=none --mount=type=cache,id=nuget,target=/root/.nuget/packages <<EOF (set -eu...)
#16 CACHED

Aynı değişiklikten sonra publish cache'ten geldi. Üstelik appsettings.Development.json artık build stage'ine hiç girmiyor; 8. yazıdaki csproj ayarına ek ikinci bir güvence. Alternatifi, Dockerfile'ın yanına koyulan ve yalnız o Dockerfile'a uygulanan Dockerfile.dockerignore dosyası; ama o dosya kökteki .dockerignore'un yerine geçer, ikisi birleşmez.

--link (1.4'ten beri) kopyalanan dosyaları önceki layer'lardan bağımsız bir layer olarak üretir. Dokümana göre bunun kazancı iki yerde: base image güncellendiğinde uygulama layer'ı yeniden üretilmeden yeni base'in üstüne yerleştirilebilir ve push sırasında BuildKit base image'ın layer'larını indirmeden yalnız yeni layer'ları gönderebilir. Bedeli, --link'li bir COPY'nin önceki state'teki dosyaları okuyamaması. En sık görülen sonucu:

# syntax=docker/dockerfile:1.29.0
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled
COPY --link --chown=app:app data/books.csv /app/data/
# 1.29.0
ERROR: failed to build: failed to solve: --chown=app:app: user and group names can't be resolved when used with --link, use numeric uid[:gid] instead

# aynı dosya 1.27.1 ile
ERROR: failed to build: failed to solve: failed to load cache key: invalid user index -1

app adını uid'ye çevirmek için base image'daki /etc/passwd'yi okumak gerekiyor ve --link buna izin vermiyor. 1.28.0'ın release notu bu kombinasyonun artık daha iyi yakalandığını söylüyor; 1.29.0 açık bir mesaj veriyor, 1.27.1 ise anlaşılmaz bir hata. Çözüm sayısal id: --chown=1654:1654 (8. yazıdaki APP_UID). --chmod'u ise 10. yazıda nginx script'i için kullanmıştık.

SHELL, VOLUME, ONBUILD ve LABEL

SHELL, shell form'daki RUN, CMD ve ENTRYPOINT'in hangi shell'le çalışacağını değiştirir. Linux'taki en önemli kullanımı pipe'lar: varsayılan /bin/sh -c'de bir pipe'ın exit code'u son komutunkidir.

# syntax=docker/dockerfile:1.29.0
FROM mcr.microsoft.com/dotnet/sdk:10.0
# script adında yazım hatası var: curl 404 alıyor
RUN curl -fsSL https://raw.githubusercontent.com/dotnet/install-scripts/main/src/dotnet-instal.sh | bash -s -- --channel 11.0
RUN dotnet --list-sdks
#7 [2/3] RUN curl -fsSL https://raw.githubusercontent.com/dotnet/install-scripts/main/src/dotnet-instal.sh | bash -s -- --channel 11.0
#7 0.425 curl: (22) The requested URL returned error: 404
#7 DONE 0.4s
#8 [3/3] RUN dotnet --list-sdks
#8 0.093 10.0.404 [/usr/share/dotnet/sdk]
#8 DONE 0.1s

curl 404 aldı, bash boş girdiyi çalıştırıp 0 döndü ve build yeşil. .NET 11 SDK'sı kurulduğunu sanıyoruz, listede yok. FROM'un altına SHELL ["/bin/bash", "-o", "pipefail", "-c"] ekleyince aynı adım duruyor:

#7 0.232 curl: (22) The requested URL returned error: 404
#7 ERROR: process "/bin/bash -o pipefail -c curl -fsSL https://raw.githubusercontent.com/dotnet/install-scripts/main/src/dotnet-instal.sh | bash -s -- --channel 11.0" did not complete successfully: exit code: 22

Windows image'larında SHELL'in asıl işi PowerShell'e geçmek; bunu bir sonraki yazıda göreceğiz.

VOLUME'ü uygulama image'larında kullanmamaya çalış: bu image'dan açılan her container anonim bir volume yaratır (11. yazı) ve türeyen image'lar tanımı kaldıramaz (FROM --reset=volume şimdilik yalnız labs frontend'inde). Volume'ü çalıştırırken -v ile vermek daha açık.

ONBUILD image'a "benden türeyen image'ın build'inde şunu çalıştır" diye bir tetikleyici ekler:

# syntax=docker/dockerfile:1.29.0
FROM alpine:3.24
WORKDIR /seed
# bunlar daha sonra, bu image'dan türeyen her build'de çalışır
ONBUILD COPY data/books.csv ./
ONBUILD RUN wc -l books.csv
# syntax=docker/dockerfile:1.29.0
FROM dockerseries-onbuild-base:1.0
RUN ls /seed
#8 [2/4] ONBUILD COPY data/books.csv ./
#9 [3/4] ONBUILD RUN wc -l books.csv
#9 0.088 7 books.csv
#10 [4/4] RUN ls /seed
#10 0.102 books.csv

Child Dockerfile'da yazmayan iki adım çalıştı. Pratik görünür, ama Dockerfile'ı okuyan kişi neyin çalıştığını göremez ve base image değişince child'ın build adımları da değişir. 1.29.0'daki BUILDKIT_DISABLE_ONBUILD build argümanı bu tetikleyicileri bütün stage'lerde kapatıyor:

docker build --build-arg BUILDKIT_DISABLE_ONBUILD=1 -f labs/16-dockerfile/Dockerfile.onbuild-child .
#7 [2/2] RUN ls /seed

Child'ın syntax satırını 1.27.1 yapınca aynı argüman etkisiz kaldı, tetikleyiciler yine çalıştı. Frontend'i sabitlemek için bir neden daha.

LABEL'ı 15. yazıda MAINTAINER'ın yerine görmüştük. OCI (Open Container Initiative; container standartlarını yazan kuruluş) adlarıyla yazılan label'ları (org.opencontainers.image.version, .revision, .source) registry'ler ve tarama araçları okur; Bookstore.Api'ye aşağıda commit'i de ekliyoruz.

Build Check'ler ve hadolint

Frontend 1.29.0'da 22 build check kuralı var, biri deneysel. Bulgular varsayılan olarak uyarıdır ve build devam eder; # check=error=true direktifi onları build'i durduran hataya çevirir. Bookstore.Api'nin yeni Dockerfile'ında bu satır var; biri stage adını küçük harfli as ile yazınca:

 - FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 5)
ERROR: failed to build: failed to solve: lint violation found for rules: FromAsCasing

Belirli kuralları kapatmak için skip kullanılır (# check=skip=JSONArgsRecommended;error=true); aynı ayar CI'da --build-arg BUILDKIT_DOCKERFILE_CHECK=... ile de verilebilir.

hadolint ise Dockerfile'ı Docker'dan bağımsız inceleyen açık kaynak bir linter; RUN satırlarının içine ShellCheck ile de bakıyor. Repository köküne bir .hadolint.yaml koyduk (failure-threshold: warning: bilgi seviyesindeki bulgular build'i kırmasın) ve container olarak çalıştırdık:

for f in labs/16-dockerfile/Dockerfile.pipefail labs/15-legacy/Dockerfile.2021 src/Bookstore.Api/Dockerfile; do
    echo "== $f"
    docker run --rm -i -v "$PWD/.hadolint.yaml:/.config/hadolint.yaml:ro" hadolint/hadolint:v2.15.1 hadolint --no-color - < "$f"
done
== labs/16-dockerfile/Dockerfile.pipefail
-:4 DL4006 warning: Set the SHELL option -o pipefail before RUN with a pipe in it. ...
== labs/15-legacy/Dockerfile.2021
-:2 DL4000 error: MAINTAINER is deprecated
-:6 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
-:13 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments
== src/Bookstore.Api/Dockerfile
-:34 DL3066 info: Non-numeric user-id may not be resolvable by host system

15. yazıda aynı 2021 Dockerfile'ı için build check MaintainerDeprecated, LegacyKeyValueFormat ve JSONArgsRecommended vermişti. İki araç kısmen örtüşüyor, kısmen farklı şey görüyor:

Durumdocker build --checkhadolint 2.15.1
MAINTAINER, shell form ENTRYPOINTUyarıDL4000, DL3025
ENV key value eski biçimiLegacyKeyValueFormatYok
Pipe'lı RUN, pipefail yokYokDL4006
Ardışık RUN'larYokDL3059 (bilgi)
Tırnaksız heredoc'ta tanımsız değişkenUndefinedVarYok
Kuralı nerede bilirDocker'ın kendi parser'ı, base image config'iKendi parser'ı ve ShellCheck

İkisini birlikte kullanmak mantıklı. hadolint 2.15.1'de iki pürüz gördüm. Heredoc'un kapanış satırından hemen sonra yeni bir instruction gelirse parse hatası veriyor (unexpected 'U' expecting a new line followed by the next instruction); Dockerfile.dbinit'teki EOF'tan sonraki boş satır bu yüzden orada. FROM sqlcmd-${TARGETARCH} gibi değişkenli stage adını da tag'siz image sanıyor (DL3006); o satıra # hadolint ignore=DL3006 ekledik. API'deki DL3066 USER $APP_UID içindir; değer .NET image'larında sayısal (1654).

Bookstore.Api'nin Yeni Dockerfile'ı

# syntax=docker/dockerfile:1.29.0
# check=error=true
ARG DOTNET_VERSION=10.0

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
ARG APP_VERSION=1.0.0
WORKDIR /src
COPY --parents global.json src/Bookstore.Api/*.csproj ./
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet restore src/Bookstore.Api/Bookstore.Api.csproj -a $TARGETARCH
# yalnız geliştirici makinesinde anlamı olan dosyalar build stage'ine hiç girmez
COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json \
    src/Bookstore.Api/ src/Bookstore.Api/
# publish ağa ihtiyaç duymaz: bütün paketler restore adımından geldi
RUN --network=none --mount=type=cache,id=nuget,target=/root/.nuget/packages <<EOF
set -eu
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -c "$BUILD_CONFIGURATION" -a "$TARGETARCH" \
    --no-restore -o /app -p:Version="$APP_VERSION"
test -f /app/Bookstore.Api.dll
test ! -e /app/appsettings.Development.json
EOF

FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-noble-chiseled-extra AS final
ARG APP_VERSION=1.0.0
ARG GIT_SHA=unknown
WORKDIR /app
COPY --link --from=build /app ./
LABEL org.opencontainers.image.title="Bookstore.Api" \
      org.opencontainers.image.version=$APP_VERSION \
      org.opencontainers.image.revision=$GIT_SHA
EXPOSE 8080
USER $APP_UID
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD ["dotnet", "Bookstore.Api.dll", "healthcheck"]
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]

15. yazıdaki dosyaya göre değişenler: frontend 1.29.0'a sabitlendi ve check'ler hata seviyesinde; csproj --parents ile kopyalanıyor; geliştirici dosyaları --exclude ile dışarıda; publish bir heredoc'ta, set -eu ile ve iki kontrolle (dll var mı, development ayarı sızmış mı); final stage'e kopya --link'li; image'a commit SHA'sı label olarak ekleniyor.

Publish'teki --network=none bilinçli: restore ağ ister, publish istemez. Bütün paketler bir önceki adımda indirildiği için publish'in internete çıkması için bir neden yok; bir paket ya da MSBuild task'ı build sırasında dışarıdan bir şey indirmeye kalkarsa adım başarısız olur. Aynı bayrağı restore'a koyunca ne olduğu:

#10 6.123 /src/src/Bookstore.Api/Bookstore.Api.csproj : error NU1301: Unable to load the service index for source https://api.nuget.org/v3/index.json.
#10 6.123 /src/src/Bookstore.Api/Bookstore.Api.csproj : error NU1301:   Resource temporarily unavailable (api.nuget.org:443)

x64 için build ve kontrol:

docker build --check -f src/Bookstore.Api/Dockerfile .
docker build --progress=plain -f src/Bookstore.Api/Dockerfile \
    --build-arg APP_VERSION=1.3.0 --build-arg GIT_SHA=3f9c2e1 -t dockerseries-bookstore-api:1.3.0 .
Check complete, no warnings found.
...
#12 [build 3/6] COPY --parents global.json src/Bookstore.Api/*.csproj ./
#13 [build 4/6] RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages     dotnet restore src/Bookstore.Api/Bookstore.Api.csproj -a amd64
#13 1.425   Restored /src/src/Bookstore.Api/Bookstore.Api.csproj (in 970 ms).
#14 [build 5/6] COPY --exclude=Dockerfile --exclude=Properties/ --exclude=appsettings.Development.json     src/Bookstore.Api/ src/Bookstore.Api/
#15 [build 6/6] RUN --network=none --mount=type=cache,id=nuget,target=/root/.nuget/packages <<EOF (set -eu...)
#15 1.262   Bookstore.Api -> /src/src/Bookstore.Api/bin/Release/net10.0/linux-x64/Bookstore.Api.dll
#15 1.280   Bookstore.Api -> /app/
#16 [final 3/3] COPY --link --from=build /app ./

Image eskisi gibi çalışıyor:

docker run -d --name dockerseries-api -p 127.0.0.1:8080:8080 dockerseries-bookstore-api:1.3.0
curl http://127.0.0.1:8080/health
docker image inspect dockerseries-bookstore-api:1.3.0 --format '{{json .Config.Labels}}'
Healthy
{"org.opencontainers.image.revision":"3f9c2e1","org.opencontainers.image.title":"Bookstore.Api","org.opencontainers.image.version":"1.3.0"}

docker-bake.hcl'da varsayılan tag 1.3.0 oldu ve api target'ı yeni GIT_SHA değişkenini argüman olarak geçiyor; CI'da GIT_SHA=$(git rev-parse --short HEAD) docker buildx bake api. 6. yazıdaki lint grubu üç Dockerfile için de "no warnings found" diyor.

Üretimde Dikkat

Frontend yükseltmesini ayrı bir değişiklik olarak yap: syntax satırını yükselt, CI'da docker buildx bake lint ve tam build'i çalıştır, release notlarını oku. 1.29.0'daki ARG örneği gibi kırılmalar böylece deploy günü değil pull request'te çıkar.

ADD ile indirdiğin her dosya artık tedarik zincirinin parçası. Checksum dosyanın değişmediğini garanti eder ama güncellemeyi elle yaptırır: sürüm numarasını ve checksum'ı aynı commit'te değiştir. Image taraması DevSecOps serisinin "Container ve IaC Scanning: Image Zafiyetleri, Base Image Seçimi ve Manifest Taraması" yazısında.

--network=none'ı paket indirmesi gerekmeyen her adıma koy: publish, test, kod üretimi. Build sırasında çalışan kötü niyetli bir paketin dışarıya veri göndermesini de zorlaştırır.

Build check'leri ve hadolint'i CI'da build'den önce ayrı bir adım olarak çalıştır; ikisi de saniyeler sürüyor. Bir bulguyu skip ya da # hadolint ignore= ile kapatırken yanına nedenini yaz.

Sık Düşülen Tuzaklar

Heredoc'ta set -eu'yu unutmak. && zincirinden heredoc'a geçerken en sık yapılan hata; Önce Tahmin Et'teki build yeşil bitti.

COPY heredoc'unda tırnaksız sınırlayıcıyla script yazmak. $f gibi değişkenler build sırasında boşalır; script'ler için <<'EOF'.

ADD'yi alışkanlıkla yerel dosyalar için kullanmak. Yerel bir tar arşivi sessizce açılır; yalnız kopyalamak istiyorsan COPY.

RUN a | b'de pipe hatasının kaybolması. SHELL ile pipefail aç ya da pipe'ı iki adıma böl.

Syntax satırını hiç sabitlememek ya da sabitleyip hiç güncellememek. İlki bir gün sürpriz kırılma getirir, ikincisi güvenlik düzeltmelerini kaçırır.

Küçük Bir Deneme

★☆☆ src/Bookstore.Tools/Dockerfile'ın restore stage'indeki iki csproj COPY'sini --parents'lı tek satıra indir. Bookstore.Api.csproj'u değiştirince Tools'un restore adımı cache'ten geliyor mu? Gelmiyorsa wildcard'ı nasıl daraltırsın?

★★☆ dockerseries-bookstore-dbinit image'ını 12. yazıdaki network'te SQL Server container'ına karşı çalıştır. Parolayı ortam değişkeni yerine 13. yazıdaki gibi bir dosyadan ver; script'i buna göre değiştir.

★★★ Bookstore.Api Dockerfile'ının syntax satırını docker/dockerfile:1-labs yap ve FROM --reset'i dene: base image'ın bir ENV'ini ya da label'ını sıfırlayabiliyor musun? Sonra satırı 1.27.1'e indirip docker build --check çıktısını karşılaştır.

Bu yazıdaki her şey BuildKit'e ve Linux container'larına dayanıyordu. Bir sonraki yazıda okurların çoğunun masasındaki işletim sisteminin kendi container'larına geçiyoruz: .NET Framework 4.8 ve IIS (Internet Information Services; Windows'un yerleşik web sunucusu) ile yazılmış eski bir back office'i Windows container'ına almak, Server Core ve Nano Server, isolation modları ve orada heredoc'un da cache mount'un da neden olmadığı.