In the past, we have built technology platforms using microservices, containerization, and service orchestration. To improve the efficiency of our development teams, we also provide the CICD platform for rapid deployment of code to Openshift (an enterprise-class Kubernetes) clusters.

The first step of deployment is to containerize the application, and the continuous integration deliverables have changed from jar packages, webpack, etc. to container images. Containerization packages the software code and all the required components (libraries, frameworks, runtime environments) together so that it can run consistently on any infrastructure in any environment and be “isolated” from other applications.

Our code needs to go from source to compile to final runnable image and even deployment, all in the CICD pipeline. Initially, we added three files to each code repository, which we also injected into new projects via a project builder (similar to Spring Initializer).

  • Jenkinsfile.groovy: used to define the Jenkins pipeline, and there will be multiple versions for different languages
  • Manifest YAML: used to define Kubernetes resources, i.e. descriptions of workloads and their operation
  • Dockerfile: used to build objects

These three files also need to evolve as we work. Initially, when there were fewer projects (a dozen or so), our basic team could go to various code repositories to maintain and upgrade them. As projects grew exponentially, the cost of maintenance became higher and higher. We iterated on the CICD platform, moving “Jenkinsfile.groovy” and “manifest YAML” out of the project and keeping the less-changed Dockerfile.

As the platform evolves, we need to consider decoupling the only “nail households”, Dockerfile, from the code, and upgrading it if necessary. So we researched buildpacks and came up with this article today.

What is Dockerfile

Docker automatically builds images by reading the instructions in a Dockerfile, which is a text file containing instructions that can be executed by Docker to build images. Let’s take the Dockerfile used to test Tekton’s Java project as an example.

1
2
3
4
5
6
FROM openjdk:8-jdk-alpine

RUN mkdir /app
WORKDIR /app
COPY target/*.jar /app/app.jar
ENTRYPOINT ["sh", "-c", "java -Xmx128m -Xms64m -jar app.jar"]

Mirror layering

You may have heard that Docker images contain multiple layers. Each layer corresponds to each command in the Dockerfile, such as RUN, COPY, and ADD. Some specific commands will create a new layer, and if some layers do not change during the image build, they will be fetched from the cache.

In the Buildpack below, the image is also layered and cached to speed up the image build.

What is Buildpack

BuildPack is a program that converts source code into a container image that can be run in any cloud environment. Typically buildpack encapsulates an ecological toolchain for a single language. Works with Java, Ruby, Go, NodeJs, Python, etc.

What is Builder?

A number of buildpacks are combined in order to form builder, and in addition to buildpacks, the builder also adds lifecycle and stack container image.

The stack container image consists of two images: the build image, which is used to run the buildpack, and the run image, which is the base image for building the application image.

How Buildpack works

Each buildpack runtime consists of two phases.

1. Detection phase

Determines if the current buildpack is applicable by checking some specific files/data in the source code. If applicable, it will enter the build phase; otherwise, it will exit. For example.

  • Java maven’s buildpack checks if pom.xml is present in the source code
  • Python’s buildpack checks for requirements.txt or setup.py files in the source code.
  • Node buildpack looks for the package-lock.json file.

2. Build phase

The following operations are performed during the build phase.

  1. set up the build and runtime environments
  2. download the dependencies and compile the source code (if required)
  3. set up the correct entrypoint and startup scripts.

For example.

  • Java maven buildpack checks for a pom.xml file and then executes mvn clean install -DskipTests.
  • Python buildpack checks for requrements.txt and then executes pip install -r requrements.txt.
  • Node build pack checks for package-lock.json and then executes npm install

BuildPack Hands-On

How do you build an image with builderpack without a Dockerfile? After reading the above, you can basically understand that the core of this is in the writing and use of buildpack.

In fact, there are many open source buildpacks that you can use now, so you don’t have to write them manually without a specific customization. For example, here are a few buildpacks that are open source and maintained by major manufacturers.

But before we go into detail about open source buildpacks, let’s take a deeper look into how Buildpacks work by creating our own buildpack. For testing the project, we’ll use Testing Tekton’s Java Project.

Everything below is committed to Github and can be accessed at: https://github.com/addozhang/buildpacks-sample for the code.

The final directory buildpacks-sample is structured as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
├── builders
│   └── builder.toml
├── buildpacks
│   └── buildpack-maven
│       ├── bin
│       │   ├── build
│       │   └── detect
│       └── buildpack.toml
└── stacks
    ├── build
    │   └── Dockerfile
    ├── build.sh
    └── run
        └── Dockerfile

Create buildpack

1
2
3
4
5
pack buildpack new examples/maven \
                         --api 0.5 \
                         --path buildpack-maven \
                         --version 0.0.1 \
                         --stacks io.buildpacks.samples.stacks.bionic

Look at the resulting buildpack-maven directory.

1
2
3
4
5
buildpack-maven
├── bin
│   ├── build
│   └── detect
└── buildpack.toml

The default preliminary test data in each file is not very useful. Something needs to be added.

bin/detect :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/bin/env bash

if [[ ! -f pom.xml ]]; then
    exit 100
fi

plan_path=$2

cat >> "${plan_path}" <<EOL
[[provides]]
name = "jdk"
[[requires]]
name = "jdk"
EOL

bin/build:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/bin/env bash

set -euo pipefail

layers_dir="$1"
env_dir="$2/env"
plan_path="$3"

m2_layer_dir="${layers_dir}/maven_m2"
if [[ ! -d ${m2_layer_dir} ]]; then
  mkdir -p ${m2_layer_dir}
  echo "cache = true" > ${m2_layer_dir}.toml
fi
ln -s ${m2_layer_dir} $HOME/.m2

echo "---> Running Maven"
mvn clean install -B -DskipTests

target_dir="target"
for jar_file in $(find "$target_dir" -maxdepth 1 -name "*.jar" -type f); do
  cat >> "${layers_dir}/launch.toml" <<EOL
[[processes]]
type = "web"
command = "java -jar ${jar_file}"
EOL
  break;
done

buildpack.toml

1
2
3
4
5
6
7
8
api = "0.5"

[buildpack]
  id = "examples/maven"
  version = "0.0.1"

[[stacks]]
  id = "com.atbug.buildpacks.example.stacks.maven"

Create stack

To build a Maven project, the preferred environment is Java and Maven, so we use maven:3.5.4-jdk-8-slim as the base image for the build image. The runtime of the application requires a Java environment, so we use openjdk:8-jdk-slim as the base image for the run image.

Create build and run directories in the stacks directory, respectively.

build/Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM maven:3.5.4-jdk-8-slim

ARG cnb_uid=1000
ARG cnb_gid=1000
ARG stack_id

ENV CNB_STACK_ID=${stack_id}
LABEL io.buildpacks.stack.id=${stack_id}

ENV CNB_USER_ID=${cnb_uid}
ENV CNB_GROUP_ID=${cnb_gid}

# Install packages that we want to make available at both build and run time
RUN apt-get update && \
  apt-get install -y xz-utils ca-certificates && \
  rm -rf /var/lib/apt/lists/*

# Create user and group
RUN groupadd cnb --gid ${cnb_gid} && \
  useradd --uid ${cnb_uid} --gid ${cnb_gid} -m -s /bin/bash cnb

USER ${CNB_USER_ID}:${CNB_GROUP_ID}

run/Dockerfile

1
2
3
4
5
6
7
8
FROM openjdk:8-jdk-slim

ARG stack_id
ARG cnb_uid=1000
ARG cnb_gid=1000
LABEL io.buildpacks.stack.id="${stack_id}"

USER ${cnb_uid}:${cnb_gid}

Then build out two images using the following command.

1
2
3
4
export STACK_ID=com.atbug.buildpacks.example.stacks.maven

docker build --build-arg stack_id=${STACK_ID} -t addozhang/samples-buildpacks-stack-build:latest ./build
docker build --build-arg stack_id=${STACK_ID} -t addozhang/samples-buildpacks-stack-run:latest ./run

Create Builder

After you have the buildpack and stack, you create the Builder. First, create the builder.toml file and add the following content.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[[buildpacks]]
id = "examples/maven"
version = "0.0.1"
uri = "../buildpacks/buildpack-maven"

[[order]]
[[order.group]]
id = "examples/maven"
version = "0.0.1"

[stack]
id = "com.atbug.buildpacks.example.stacks.maven"
run-image = "addozhang/samples-buildpacks-stack-run:latest"
build-image = "addozhang/samples-buildpacks-stack-build:latest"

Then execute the command, Note that we use the -pull-policy if-not-present parameter here, so we don’t need to push the two images of the stack to the mirror repository:

1
pack builder create example-builder:latest --config ./builder.toml --pull-policy if-not-present

Testing

Once we have the builder, we can use the created builder to build the image.

The same -pull-policy if-not-present parameter is added here to use the local builder image: -pull-policy if-not-present parameter is added here to use the local builder image.

1
2
# 目录 buildpacks-sample  与 tekton-test 同级,并在 buildpacks-sample  中执行如下命令
pack build addozhang/tekton-test --builder example-builder:latest --pull-policy if-not-present --path ../tekton-test

If you see something like the following, it means that the image was built successfully (the first time to build the image due to the need to download maven dependencies may take longer, the subsequent will be faster, you can perform twice to verify the next).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
...
===> EXPORTING
[exporter] Adding 1/1 app layer(s)
[exporter] Reusing layer 'launcher'
[exporter] Reusing layer 'config'
[exporter] Reusing layer 'process-types'
[exporter] Adding label 'io.buildpacks.lifecycle.metadata'
[exporter] Adding label 'io.buildpacks.build.metadata'
[exporter] Adding label 'io.buildpacks.project.metadata'
[exporter] Setting default process type 'web'
[exporter] Saving addozhang/tekton-test...
[exporter] *** Images (0d5ac1158bc0):
[exporter]       addozhang/tekton-test
[exporter] Adding cache layer 'examples/maven:maven_m2'
Successfully built image addozhang/tekton-test

Start the container and you will see that the spring boot application starts normally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
docker run --rm addozhang/tekton-test:latest
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.2.3.RELEASE)

 ...

Summary

In fact, there are many open source buildpacks available that you can use without having to write them manually for specific customizations. For example, the following buildpacks are open-sourced and maintained by major manufacturers.

The buildpacks libraries above are more comprehensive and may differ slightly in their implementation. For example, Heroku uses shell scripts for its execution phase, while Paketo uses Golang, which is more scalable, supported by the Cloud Foundry Foundation, and has a full-time core development team sponsored by VMware. These small, modular buildpacks can be extended to use different scenarios by combining them.

Of course, it’s easier to understand how a buildpack works by writing one yourself.