The Problem: COPY . .
I’ll outline the problem using a standard go
project layout, but any project
building multiple Docker images—each with root dependencies—will benefit from
this approach.
├── cmd/
│ ├── app1/
│ │ └── main.go
│ └── app2/
│ └── main.go
├── pkg/
│ └── utils.go
├── internal/
│ └── helper.go
├── kustomize/
│ ├── base/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ └── kustomization.yaml
│ └── overlays/
│ └── dev/
│ └── kustomization.yaml
├── go.mod
├── go.sum
├── Dockerfile.app.buildkit
├── .dockerignore
├── skaffold.yaml
├── README.md
Example Dockerfile:
# Dockerfile.app.buildkit
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
# Leverage BuildKit cache for Go modules
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
ARG APP_NAME
# Use BuildKit cache for build artifacts as well (optional)
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o /app /src/cmd/${APP_NAME}
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
And build…
DOCKER_BUILDKIT=1 docker build --build-arg APP_NAME=app1 -t my-app1 .
The source of the problem is this line: COPY . .
. Because the project root is
copied into the Docker build, any change to the project not explicitly
ignored by .dockerignore
will result in all Docker containers rebuilding (and
you waiting, waiting, waiting…).
Solution: FROM $BASE AS builder
The solution is relatively simple—add more Docker.
The BASE image will download all dependencies and only update when they change.
# Dockerfile.base.buildkit
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
# Leverage BuildKit cache for Go modules
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
And the updated app Dockerfile:
# Dockerfile.app.buildkit
# syntax=docker/dockerfile:1.7
ARG BASE
FROM $BASE AS builder
WORKDIR /src
ARG APP_NAME
# Use ARGs or multiple Dockerfiles to copy only the required folders to build
your app
COPY cmd cmd
COPY pkg pkg
COPY internal internal
# Use BuildKit cache for build artifacts as well (optional)
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o /app /src/cmd/${APP_NAME}
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
If you’re familiar with Skaffold, you may have wondered how the dependencies
image will be referenced, since Skaffold will build and update the image
reference on each change. The trick is to explicitly pass the BASE image as
a required dependency in skaffold.yaml
.
# skaffold.yaml
build:
artifacts:
# Build the 'base' dependencies image
- image: base
docker:
dockerfile: Dockerfile.base.buildkit
# Reference BASE in other image builds
- image: app1
docker:
dockerfile: Dockerfile.app.buildkit
buildArgs:
APP_NAME: app1
requires:
- image: base
alias: BASE
- image: app2
docker:
dockerfile: Dockerfile.app.buildkit
buildArgs:
APP_NAME: app2
requires:
- image: base
alias: BASE
Skaffold will ensure the base
image is built first and pass it as the
BASE
build argument to your app’s Dockerfile.
Pro Tips
-
.dockerignore: Make sure your
.dockerignore
is set up to exclude files and directories that aren’t needed for dependency resolution (likebin/
,vendor/
, ornode_modules/
). This keeps your build context small and leverages Docker’s cache more effectively. -
--build-concurrency=0
: Enables full concurrency. -
--trigger=manual
: Will not rebuild until triggered. -
--cleanup=false
: Keeps k8s resources even when the build fails (useful for debugging).Altogether:
skaffold dev --cleanup=false --trigger=manual --build-concurrency=0
Conclusion
By introducing a shared base image for your dependencies, you can significantly reduce redundant work in your Skaffold builds. This technique is simple to implement and pays off immediately in faster, more efficient development cycles—especially as your project scales.