Examine the evolution of virtualization technologies from bare metal, virtual machines, and containers and the tradeoffs between them.
Explores the three core Linux features that enable containers to function (cgroups, namespaces, and union filesystems), as well as the architecture of the Docker components.
Install and configure Docker Desktop
Use publicly available container images in your developer workflows and learn how about container data persistence.
Building out a realistic microservice application to containerize.
Write and optimize Dockerfiles and build container images for the components of the example web app.
ā¢NodeJS API
ā¢Golang API
ā¢React Client
Use container registries such as Dockerhub to share and distribute container images.
Use Docker and Docker Compose to run the containerized application from Module 5.
Learn best practices for container image and container runtime security.
Explore how to use Docker to interact with containers, container images, volumes, and networks.
ā¢Images
ā¢Containers
ā¢Volumes
ā¢Networks
Add tooling and configuration to enable improved developer experience when working with containers.
ā¢Developer Experience Wishlist
ā¢Debuggers
ā¢Tests
Deploy containerized applications to production using a variety of approaches.
In this section of the course we will build out a Dockerfile for the Golang API, starting with a simple naive approach, and systematically improving it!
š - Security improvement
šļø - Build speed improvement
šļø - Clarity improvement
This Dockerfile starts from the official golang container image from DockerHub, sets the working directory, copies in the entire build context, installs dependencies with go mod
, and sets a command to be run upon startup.
FROM golang
WORKDIR /app
COPY . .
RUN go mod download
CMD ["go", "run", "./main.go"]
While this will technically work, there are many ways in which we can improve it.
The first way we can improve the Dockerfile is by pinning the base image to a specific version. With no tag, Docker will use the "latest"
tag which is the default tag applied to images. This would cause the base image to change with each new update to the upstream image, inevitably breaking our application.
We can choose a specific base image that is small and secure to meet the needs of our application.
#-------------------------------------------
# Pin specific version for stability. Use debian for easier build utilities.
FROM golang:1.19-bullseye AS build
#-------------------------------------------
WORKDIR /app
COPY . .
RUN go mod download
CMD ["go", "run", "./main.go"]
Pinning to the minor version should prevent known breaking changes while still allowing patch versions containing bugfixes to be utilized. If we want to truly lock the base image we can refer to a specific image hash such as:
FROM golang:1.19-bullseye@sha256:1370f30629243bb65e3e0f780ae08a54e50fc5b7e96f0b79e62ee846788d1178
The go run ./main.go
command both builds and runs the application. By using this as the CMD
the container will need to build the application upon startup every time.
We should build the application within the container image and then execute the build binary upon startup.
FROM golang:1.19-bullseye
WORKDIR /app
COPY . .
RUN go mod download
#-------------------------------------------
# Compile application during build rather than at runtime
RUN go build -o api-golang
CMD ["./api-golang"]
#-------------------------------------------
Each instruction within the Dockerfile creates a new layer within the image. Docker caches these layers to speed up subsequent builds. Previously, every change to the source code would invalidate the layer cache for COPY . .
causing the build to reinstall all of the dependencies (which can be SLOW!).
By copying only the dependency configuration files before running go mod download
we can protect the layer cache and avoid reinstalling the dependencies with each source code change.
We can also use a .dockerignore
file to specify files that should not be included in the container image.
FROM golang:1.19-bullseye
WORKDIR /app
#-------------------------------------------
# Copy only files required to install dependencies (better layer caching)
COPY go.mod go.sum ./
RUN go mod download
COPY . .
#-------------------------------------------
RUN go build -o api-golang
CMD ["./api-golang"]
In order to build the application we need a fair amount of utilities associated with golang (included in the golang base image). However, those are not necessary at runtime.
We can use Docker's multi-stage build feature to make our final deployable image MUCH smaller!
FROM golang:1.19-bullseye AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
#-------------------------------------------
# Add flags to statically link binary
RUN go build \
-ldflags="-linkmode external -extldflags -static" \
-tags netgo \
-o api-golang
# Use separate stage for deployable image
FROM scratch
WORKDIR /
# Copy the binary from the build stage
COPY --from=build /app/api-golang api-golang
#-------------------------------------------
CMD ["/api-golang"]
The API framework we are using (Gin) uses the GIN_MODE
environment variable to determine if it is running in a development or production environment.
We can set GIN_MODE=release
for our deployable image so it will run in production mode.
FROM golang:1.19-bullseye AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build \
-ldflags="-linkmode external -extldflags -static" \
-tags netgo \
-o api-golang
FROM scratch
WORKDIR /
#-------------------------------------------
# Set gin mode
ENV GIN_MODE=release
#-------------------------------------------
COPY --from=build /app/api-golang api-golang
CMD ["/api-golang"]
If configured properly, containers provide some protection (via user namespaces) between a root user inside a container and the host system user, but setting to a non-root user provides another layer to our defense in depth security approach!
We can use the useradd
command to add a non-root user to the build stage, and then copy the corresponding files into scratch to use it.
FROM golang:1.19-bullseye AS build
#-------------------------------------------
# Add non root user
RUN useradd -u 1001 nonroot
#-------------------------------------------
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build \
-ldflags="-linkmode external -extldflags -static" \
-tags netgo \
-o api-golang
FROM scratch
WORKDIR /
ENV GIN_MODE=release
#-------------------------------------------
# Copy the passwd file
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /app/api-golang api-golang
# Use nonroot user
USER nonroot
#-------------------------------------------
CMD ["/api-golang"]
There are a few Dockerfile instructions that don't change the container runtime behavior, do provide useful metadata for users of the resulting container image.
We can add LABEL
instructions with various annotations about the container image. For example we might want to include the Dockerfile author, version, licenses, etc... A set of suggested annotation keys from the Open Container Initiative can be found here: https://github.com/opencontainers/image-spec/blob/main/annotations.md.
The EXPOSE
command tells end users the port number that the containerized application expects to listen on. The port will still need to be published at runtime, but it is useful to include this instruction to make it clear to end users which port should be opened.
FROM golang:1.19-bullseye AS build
#-------------------------------------------
# Use LABELS to provide additional info
LABEL org.opencontainers.image.authors="sid@devopsdirective.com"
#-------------------------------------------
RUN useradd -u 1001 nonroot
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build \
-ldflags="-linkmode external -extldflags -static" \
-tags netgo \
-o api-golang
FROM scratch
WORKDIR /
ENV GIN_MODE=release
#-------------------------------------------
# Indicate expected port
EXPOSE 8080
#-------------------------------------------
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /app/api-golang api-golang
USER nonroot
CMD ["/api-golang"]
Buildkit provides many useful features, including the ability to specify a cache mount for specific RUN
instructions within a Dockerifle. By specifying a cache in this way, changing a dependency won't require redownloading all dependencies from the internet because previously installed dependencies will be stored locally.
Note: If building the image in a remote continuous Integration system (e.g. GitHub Actions), we would need to configure that system to store and retrieve this cache across pipeline runs.
FROM golang:1.19-bullseye AS build
RUN useradd -u 1001 nonroot
WORKDIR /app
COPY go.mod go.sum ./
#-------------------------------------------
# Use cache mount to speed up install of existing dependencies
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
#-------------------------------------------
COPY . .
RUN go build \
-ldflags="-linkmode external -extldflags -static" \
-tags netgo \
-o api-golang
FROM scratch
ENV GIN_MODE=release
EXPOSE 8080
WORKDIR /
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /app/api-golang api-golang
USER nonroot
CMD ["/api-golang"]
As we progressed through these improvements, we reduced the final image size from 926MB (š³) to a just 17MB (š)!