Sztuka optymalizacji Dockera - czyli jak (nie)budować obrazów przez godzinę

Daniel Olczyk

Agenda

  • Czym jest cache i jak działa w dockerze
  • Warstwy w dockerze
  • Jak optymalizować obrazy
  • Multistage builds - czym są i co nam dają

O mnie

  • SWE 3,5+ lat doświadczenia
  • Student 3. roku w PJATK
  • Przewodniczący w KN Rust Lab PJATK
  • Zacząłem uczyć się Rust 2 lata temu do projektów embedded
  • Obecnie poznaję inne obszary wykorzystania Rust

Ile wam zajmuje budowanie obrazów?

Dlaczego chcemy optymalizować czas budowania?

  1. Rust to język kompilowany, więc czas budowania jest dłuższy niż w przypadku języków interpretowanych
  2. Częste budowanie obrazów podczas developmentu może być frustrujące
  3. Optymalizacja czasu budowania pozwala na szybsze testowanie i wdrażanie aplikacji
  4. W dłuższej perspektywie może to prowadzić do oszczędności czasu i zasobów
graph LR A[FROM node:18] --> B[WORKDIR /app] B --> C[COPY package.json .] C --> D[RUN npm install] D --> E[COPY . .] E --> F[RUN npm run build]

Zmiana przy pliku package.json

graph LR A["FROM node:18 cache"] --> B["WORKDIR /app\ cache"] B --> C["COPY package.json\npsuje cache"] C --> D["RUN npm install"] D --> E["COPY . ."] E --> F["RUN npm run build"] classDef ok fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20,stroke-width:2px; classDef warn fill:#FFF8E1,stroke:#F9A825,color:#8D6E00,stroke-width:2px; classDef bad fill:#FFEBEE,stroke:#C62828,color:#7F0000,stroke-width:2px; class A,B ok; class C warn; class D,E,F bad;

Co wpływa na cache?

  • treść instrukcji (np. RUN, COPY)
  • pliki używane w instrukcji (COPY, ADD)
  • zmienne środowiskowe
  • argumenty builda (ARG)

Multistage builds - czym są i co nam dają?

Multistage builds pozwalają na tworzenie wielu etapów budowania w jednym Dockerfile, co umożliwia oddzielenie środowiska budowania od środowiska uruchomieniowego. Dzięki temu możemy zbudować aplikację w jednym etapie, a następnie skopiować tylko niezbędne pliki do finalnego obrazu, co znacznie zmniejsza jego rozmiar i czas budowania.

Magiczny plik .dockerignore

Plik .dockerignore pozwala na określenie, które pliki i katalogi mają być ignorowane podczas budowania obrazu. Dzięki temu możemy uniknąć kopiowania niepotrzebnych plików do obrazu, co przyspiesza proces budowania i zmniejsza rozmiar finalnego obrazu.

Przykładowy plik .dockerignore

target/
.git/

node_modules/
dist/

Przejdżmy do praktyki

Należy przejść do katalogu backend

FROM rust:1.88-slim AS builder

WORKDIR /app

ENV OPENSSL_NO_VENDOR=1

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        pkg-config \
        libssl-dev \
        perl \
    && update-ca-certificates \
    && rm -rf /var/lib/apt/lists/*
RUN rustup component add rustfmt
COPY . .

ARG ENABLE_DRIFT=false
RUN if [ "$ENABLE_DRIFT" = "true" ]; then \
        rustup install 1.76.0-x86_64-unknown-linux-gnu \
        && cargo build --release --features drift --bin backend-cli; \
    else \
        cargo build --release --bin backend-cli; \
    fi

# Collect runtime shared libs produced by drift-rs build script (if drift feature is enabled).
RUN mkdir -p /app/runtime-libs \
    && find /app/target/release/build -name 'libdrift_ffi_sys.so' -exec cp {} /app/runtime-libs/ \; \
    || true

FROM debian:bookworm-slim AS runner

WORKDIR /app

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates libssl3 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/backend-cli /usr/local/bin/backend-cli
COPY --from=builder /app/runtime-libs/ /usr/local/lib/
COPY --from=builder /app/config ./config

ENV RUST_LOG=info \
    PORT=5150 \
    LD_LIBRARY_PATH=/usr/local/lib:${LD_LIBRARY_PATH}

EXPOSE 5150

CMD ["backend-cli", "start", "-b", "0.0.0.0"]

Konfiguracja sprzętowa

CPU: Intel Core i7-11700k 16x @ 3.60GHz
RAM: 64GB DDR4 3200MHz
SSD: 5200 MB/s
OS: Fedora 43 (Linux 6.19.14-200)

Sposób 1: cargo-chef - idealne lekarstwo?

Narzędzie do optymalizacji warstw Dockera, które separuje kompilację zależności od kodu źródłowego, skracając czas budowania z minut do sekund.

Jak to działa?

flowchart LR A["Planner\nSkanuje projekt\nTworzy recipe.json"] --> B["Cacher\nBuduje cache zależności"] B --> C["Builder\nKopiuje kod i składa aplikację"] C --> D["Runner\nTworzy finalny obraz\nz gotową aplikacją"] classDef planner fill:#E3F2FD,stroke:#1565C0,color:#0D47A1,stroke-width:2px; classDef cacher fill:#FFF8E1,stroke:#F9A825,color:#8D6E00,stroke-width:2px; classDef builder fill:#FFF3E0,stroke:#EF6C00,color:#BF360C,stroke-width:2px; classDef runner fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20,stroke-width:2px; class A planner; class B cacher; class C builder; class D runner;

Zaimplementowany przykład

FROM rust:1.88-slim AS planner
WORKDIR /app

RUN cargo install --locked cargo-chef
COPY . .

RUN cargo chef prepare --recipe-path recipe.json

FROM rust:1.88-slim AS cacher
WORKDIR /app

RUN cargo install --locked cargo-chef
COPY --from=planner /app/recipe.json recipe.json

RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config libssl-dev perl ca-certificates && rm -rf /var/lib/apt/lists/*

ARG ENABLE_DRIFT=false
RUN if [ "$ENABLE_DRIFT" = "true" ]; then \
        rustup install 1.76.0-x86_64-unknown-linux-gnu && \
	rustup component add rustfmt && \
        cargo chef cook --release --features drift --recipe-path recipe.json; \
    else \
        cargo chef cook --release --recipe-path recipe.json; \
    fi

FROM rust:1.88-slim AS builder

WORKDIR /app

COPY . .

COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo

RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config libssl-dev perl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN rustup component add rustfmt

ARG ENABLE_DRIFT=false
RUN if [ "$ENABLE_DRIFT" = "true" ]; then \
        rustup install 1.76.0-x86_64-unknown-linux-gnu \
        && cargo build --release --features drift --bin backend-cli; \
    else \
        cargo build --release --bin backend-cli; \
    fi    

RUN cp /app/target/release/backend-cli /app/backend-cli

RUN mkdir -p /app/runtime-libs && \
    find /app/target/release/build -name 'libdrift_ffi_sys.so' -exec cp {} /app/runtime-libs/ \; || true

FROM debian:bookworm-slim AS runner

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/backend-cli /usr/local/bin/backend-cli
COPY --from=builder /app/runtime-libs/ /usr/local/lib/
COPY --from=builder /app/config ./config

ENV RUST_LOG=info PORT=5150 LD_LIBRARY_PATH=/usr/local/lib:${LD_LIBRARY_PATH}
EXPOSE 5150

CMD ["backend-cli", "start", "-b", "0.0.0.0"]

A czy da się osiągnąć efekt bez cargo-chef?

A czy da się osiągnąć efekt bez cargo-chef?

Jak najbardziej

Przykład Dockerfile bez cargo-chef

FROM rust:1.88-slim AS builder

WORKDIR /app

ENV OPENSSL_NO_VENDOR=1

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        pkg-config \
        libssl-dev \
        perl \
    && update-ca-certificates \
    && rm -rf /var/lib/apt/lists/*

RUN rustup component add rustfmt

COPY Cargo.toml Cargo.lock ./

RUN mkdir src && \
    rustup install 1.76.0-x86_64-unknown-linux-gnu && \
    echo 'fn main() { println!("Hello") }' > src/main.rs && \
    cargo build --release && \
    rm src/main.rs

COPY src ./src

ARG ENABLE_DRIFT=false
RUN if [ "$ENABLE_DRIFT" = "true" ]; then \
        cargo build --release --features drift --bin backend-cli; \
    else \
        cargo build --release --bin backend-cli; \
    fi

# Collect runtime shared libs produced by drift-rs build script (if drift feature is enabled).
RUN mkdir -p /app/runtime-libs \
    && find /app/target/release/build -name 'libdrift_ffi_sys.so' -exec cp {} /app/runtime-libs/ \; \
    || true

FROM debian:bookworm-slim AS runner

WORKDIR /app

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates libssl3 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/backend-cli /usr/local/bin/backend-cli
COPY --from=builder /app/runtime-libs/ /usr/local/lib/
COPY --from=builder /app/config ./config

ENV RUST_LOG=info \
    PORT=5150 \
    LD_LIBRARY_PATH=/usr/local/lib:${LD_LIBRARY_PATH}

EXPOSE 5150

CMD ["backend-cli", "start", "-b", "0.0.0.0"]

Sposób 2: Użycie --mount=type=cache

Mechanizm powiązany z Docker BuildKit. Dzięki temu możemy zdefiniować cache dla konkretnych katalogów, co pozwala na przechowywanie pobranych zależności lub wyniki kompilacji między kolejnymi buildami, nawet jeśli Dockerfile ulegnie zmianie.

Jak to działa?

RUN --mount=type=cache,target=/root/.cargo/registry \
    --mount=type=cache,target=/root/.cargo/git \
    cargo build --release

Zaimplementowany przykład

FROM rust:1.88-slim AS builder

WORKDIR /app

ENV OPENSSL_NO_VENDOR=1

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        pkg-config \
        libssl-dev \
        perl \
    && update-ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Necessary for building drift-sdk
RUN rustup component add rustfmt

COPY . .

ARG ENABLE_DRIFT=false
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/app/target <<EOF
	if [ "$ENABLE_DRIFT" = "true" ]; then \
		rustup install 1.76.0-x86_64-unknown-linux-gnu \
		&& cargo build --release --features drift --bin backend-cli; \
	else \
		cargo build --release --bin backend-cli; \
	fi

	cp /app/target/release/backend-cli /app/backend-cli
EOF

# Collect runtime shared libs produced by drift-rs build script (if drift feature is enabled).
RUN --mount=type=cache,target=/app/target \
    mkdir -p /app/runtime-libs \
    && find /app/target/release/build -name 'libdrift_ffi_sys.so' -exec cp {} /app/runtime-libs/ \; \
    || true

FROM debian:bookworm-slim AS runner

WORKDIR /app

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates libssl3 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/backend-cli /usr/local/bin/backend-cli
COPY --from=builder /app/runtime-libs/ /usr/local/lib/
COPY --from=builder /app/config ./config

ENV RUST_LOG=info \
    PORT=5150 \
    LD_LIBRARY_PATH=/usr/local/lib:${LD_LIBRARY_PATH}

EXPOSE 5150

CMD ["backend-cli", "start", "-b", "0.0.0.0"]

Sposób 3: Użycie zewnętrznego sccache

W tym przypadku w ruście mamy bibliotekę sccache, który działa jako zewnętrzny cache dla kompilacji. Możemy skonfigurować go tak, aby przechowywał skompilowane artefakty na dysku, a następnie montować ten katalog jako cache podczas budowania obrazu.

Przygotowanie Dockerfile z sccache

RUN cargo install sccache

# Opcjonalnie, jeśli chcemy używać zewnętrznego storage (np. S3), możemy ustawić odpowiednie zmienne środowiskowe
# ENV SCCACHE_BUCKET=my-rust-cache 
ENV RUSTC_WRAPPER=sccache

RUN cargo build --release

Podsumowanie różnych sposobów

Cecha Standardowy Cache (Warstwy) Cargo-chef (Recipe) Cache Mount (--mount) sccache
Mechanizm Kopiowanie całego projektu (COPY . .) Podzial na zależności i kod Zewnetrzny folder montowany podczas RUN Cache artefaktow kompilatora (hash komendy i zrodel)
Efekt zmiany w src/ Ponowna kompilacja wszystkich bibliotek Kompilacja tylko Twojego kodu Kompilacja przyrostowa Reuzycie niezmienionych jednostek kompilacji
Efekt zmiany w deps Wszystko od zera Ponowna kompilacja wszystkich bibliotek Dociaga tylko nowe biblioteki Kompiluje glownie nowe lub zmienione crate'y
Przenosnosc (CI/CD) Bardzo wysoka (warstwy w rejestrze) Bardzo wysoka (warstwy w rejestrze) Niska (cache zostaje na maszynie budujacej) Wysoka (S3/Redis)
Zastosowanie Male, rzadko zmieniane mikroserwisy Standard dla CI/CD (GitHub Actions, GitLab) Lokalny development i serwery dedykowane Duze projekty Rust, monorepo i wiele runnerow CI

Dziękuje za uwagę!

Jakieś pytania?

![bg left fit in](img/optimize.png)