Building container images without writing a Dockerfile feels like magic, but the real trick is that Buildpacks are just a smarter, more opinionated way to achieve what Dockerfiles do, by automating the entire process based on your app’s code.
Let’s see this in action. Imagine you have a simple Node.js app:
// index.js
const http = require('http');
const port = process.env.PORT || 8080;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello, Buildpacks!\n');
});
server.listen(port, () => {
console.log(`Server running on port ${port}`);
});
And a package.json:
{
"name": "my-node-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {}
}
Now, with Skaffold and Buildpacks configured, you just run:
skaffold dev
Skaffold will detect your Node.js application, invoke the Buildpacks builder, and produce a container image. You’ll see output like this:
Generating Skaffold configuration...
INFO[0000] Skaffold version: v2.8.0
INFO[0000] Starting initial build in development mode...
INFO[0000] ** Building my-node-app for k8s
INFO[0000] ** Building image my-node-app
INFO[0000] Buildpacks: creating a buildpack for Node.js
INFO[0001] Buildpacks: calling builder
... (Buildpack output showing detection, building, and exporting) ...
INFO[0010] Built image my-node-app:latest
INFO[0010] Tagging image my-node-app:latest as my-registry/my-node-app:v0.1.0-1678886400
INFO[0011] Pushable image: my-registry/my-node-app:v0.1.0-1678886400
INFO[0011] *** Image my-node-app:latest built to my-registry/my-node-app:v0.1.0-1678886400 ***
INFO[0011] Deploying to Kubernetes...
... (Kubernetes deployment output) ...
The core problem Buildpacks solve is the boilerplate and maintenance overhead of Dockerfiles. Instead of specifying RUN apt-get install ..., COPY . /app, and CMD ["node", "index.js"], Buildpacks infer these steps. They inspect your application’s code and its dependencies (like package.json for Node.js, pom.xml for Java, requirements.txt for Python) to determine the best way to package it.
Internally, a Buildpack consists of two main components: a detector and a builder.
- Detector: This phase analyzes your application’s source code. It looks for specific files or patterns that indicate the language and framework being used. For example, the presence of
package.jsontriggers the Node.js detector. - Builder: Once detected, the builder takes over. It uses a pre-defined lifecycle to construct the container image. This lifecycle typically involves:
- Build: Setting up the environment, installing language runtimes and dependencies (e.g.,
npm install). - Launch: Creating the runnable application image, ensuring the correct entrypoint and command are set to start your application.
- Build: Setting up the environment, installing language runtimes and dependencies (e.g.,
Skaffold integrates with Buildpacks by invoking a Buildpack-compatible builder (like the one provided by the pack CLI or a cloud-native buildpacks service). When Skaffold detects changes, it re-runs this Buildpack process, creating a new image that’s then deployed to your cluster.
The beauty of this is the standardization and the ecosystem. Cloud Native Buildpacks (CNB) define a specification, and various implementations exist. This means you can use the same Buildpacks process locally with Skaffold, on a CI/CD server, or even on managed services like Google Cloud Build or Azure Container Instances, all without managing Dockerfiles.
The exact levers you control are primarily through Skaffold’s configuration, telling it which builder to use and how to pass information. For instance, in your skaffold.yaml, you might specify:
build:
buildpacks:
builder: "paketobuildpacks/builder-jammy-base:latest"
# You can also specify a specific image for the builder
# builder: "my-custom-registry/my-buildpack-builder:v1"
Here, paketobuildpacks/builder-jammy-base:latest is a well-known builder that knows how to detect and build many common application types. Skaffold passes your application code to this builder, which then executes its detection and build phases.
A subtle but powerful aspect of Buildpacks is their layered approach to creating images. Instead of a single monolithic Dockerfile, Buildpacks can generate images with distinct layers for the OS, the language runtime, application dependencies, and the application code itself. This allows for more efficient caching. If only your application code changes, but the dependencies and runtime remain the same, the build process can reuse existing layers, making subsequent builds significantly faster. This is managed by the Buildpacks lifecycle’s caching mechanisms, which Skaffold leverages.
The next step in exploring containerization without Dockerfiles is understanding how to customize Buildpack behavior, such as defining environment variables or passing specific build arguments.