1. Introduction

Docker is a platform to create, configure, organize, and run containers. Within a container, an initial PID 1 process takes the role of the init origin in that environment. As such, this process is the one that the container depends on to continue functioning. Further, Docker can send signals to that particular process in different situations. Terminating the origin process usually terminates the whole instance as well.

In this tutorial, we explore a way to use a script and initialize a container but still run another process as the origin. First, we briefly refresh our knowledge about container initialization. After that, we consider scripts as a way to preconfigure a starting container. Next, we delve into process replacement as a way to optimize container initialization scripts. Finally, we make the last iteration of an example script more universal.

We tested the code in this tutorial on Debian 12 (Bookworm) with GNU Bash 5.2.15. Unless otherwise specified, it should work in most POSIX-compliant environments.

2. Container Initialization

Let’s start with a rudimentary overview of the Docker container initialization process.

For that, we first create a basic Dockerfile:

$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM debian:latest
CMD ["sleep", "666"]

Next, we build based on the definition above and –tag the result:

$ docker build --tag xnit:latest .
[+] Building 4.8s (11/11) FINISHED                                                     docker:default
 => [internal] load build definition from Dockerfile                                             0.0s
 => => transferring dockerfile: 249B                                                             0.0s
 => resolve image config for docker-image://docker.io/docker/dockerfile:1                        0.9s
 => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:ac85f380a63b13dfcefa89046420e178  0.0s
 => [internal] load metadata for docker.io/library/debian:latest                                 0.0s
 => [internal] load .dockerignore                                                                0.0s
 => => transferring context: 2B                                                                  0.0s
 => [1/4] FROM docker.io/library/debian:latest                                                   0.0s
 => [internal] load build context                                                                0.0s
 => => transferring context: 326B                                                                0.0s
 => CACHED [2/4] WORKDIR /xnit                                                                   0.0s
 => [3/4] COPY . .                                                                               0.0s
 => [4/4] RUN apt-get update && apt-get install -y procps && rm -rf /var/lib/apt/lists/*         3.6s
 => exporting to image                                                                           0.1s
 => => exporting layers                                                                          0.1s
 => => writing image sha256:5a544494957fcd275fe58db3e5c9bc48be107e3a79c4cef89528b37c91c27cdb     0.0s
 => => naming to docker.io/library/xnit:latest                                                   0.0s

At this point, we can run the container:

$ docker run --rm --detach xnit:latest
9f751e202bfbf9ca677caad38881a3e6682d703ed68c0b45a5000220dafd02b2

Now, let’s check the container listing via the ps subcommand of docker:

$ docker ps
CONTAINER ID  IMAGE        COMMAND      CREATED         STATUS        PORTS  NAMES
9f751e202bfb  xnit:latest  "sleep 666"  10 seconds ago  Up 9 seconds         youthful_shannon

Thus, we see the container is Up and running the sleep 666 COMMAND. Further, due to the –rm flag, once the origin process completes, the whole container is deleted.

Let’s turn all of the above into a single command for convenience:

$ docker run --rm --detach $(docker build --no-cache --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
CMD ["sleep", "666"]
')

In this case, we use a here-string to supply the Dockerfile. With this syntax, we avoid the need to create a separate Dockerfile –file, as the command uses stdin. In addition, –quiet ensures the build subcommand doesn’t output anything more than the resulting image identifier, which run employs via command substitution. Further, –no-cache prevents any leftover files from being kept.

Either way, the final result isn’t very useful as it would just terminate the whole instance once the sleep command completes.

3. Container Start Script

Instead of a specific command, containers often start with a script that initializes different parameters.

Let’s see an example of that:

$ cat docker-entrypoint.sh
#!/usr/bin/env sh

echo 'Initialization...' > xnit
sleep 666

In this case, the mockup script just writes a xnit file with the contents from echo.

So, to run this code as the main CMD in a new container, we use sh:

$ docker run --rm --detach $(docker build --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
WORKDIR /xnit
COPY ./docker-entrypoint.sh .
CMD ["sh", "/xnit/docker-entrypoint.sh"]
')

Here, we assume docker-entrypoint.sh is in the current working directory on the host.

Also, we use two additional directives in the Dockerfile:

  • WORKDIR sets the current working directory of the container to /xnit (creating it if necessary)
  • COPY copies the docker-entrypoint.sh script from the host to the container

Let’s check the running container listing:

$ docker ps
CONTAINER ID  IMAGE         COMMAND                 CREATED         STATUS        PORTS  NAMES
fcc2cf8083dd  1e79a51d26d4  "sh /xnit/docker-ent…"  10 seconds ago  Up 9 seconds         modest_engelbart

Now, the main process is the sh shell that runs docker-entrypoint.sh.

To verify the origin process, *we can install ps within the container and run it for a [-H]ierarchical view of [-A]ll processes*:

$ docker exec fcc2cf8083dd sh -c 'apt-get update && apt-get install --yes procps && ps -HA'
[...]
PID TTY          TIME CMD
  8 ?        00:00:00 sh
197 ?        00:00:00   ps
  1 ?        00:00:00 sh
  7 ?        00:00:00   sleep

As expected, the PID 1 origin process is the sh shell that runs sleep.

Thus, the container is currently running sleep 666 as the child of sh and should terminate once that’s done. Of course, if we replace sleep with another executable like a server or microservice, we’d be running it instead.

However, there are several potential issues with this approach:

  • the main process isn’t the origin process (server, service, or similar)
  • an extraneous shell is running (the main process parent)
  • not all signals would reach the main process

Because of these drawbacks, container initialization scripts usually employ a specific syntax.

4. Start Script Process Replacement

In Bash and other shells, exec is a special way to execute a command. In particular, instead of having the command spawn a child process of the shell that runs it, exec replaces the shell itself with that process.

Notably, this means that exec makes the original shell unavailable and, once the spawned process terminates, we won’t be able to continue using the same process tree.

To leverage this feature, we just prepend exec to the last line of docker-entrypoint.sh:

$ cat docker-entrypoint.sh
#!/usr/bin/env sh

echo 'Initialization...' > xnit
exec sleep 666

Now, we create and run the container, same as before:

$ docker run --rm --detach $(docker build --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
WORKDIR /xnit
COPY ./docker-entrypoint.sh .
CMD ["sh", "/xnit/docker-entrypoint.sh"]
')

Again, we check the docker listing:

$ docker ps
CONTAINER ID  IMAGE         COMMAND                 CREATED         STATUS        PORTS  NAMES
942bb64858b6  e7dbdf0b3900  "sh /xnit/docker-ent…"  10 seconds ago  Up 9 seconds         modest_engelbart

Interestingly, the COMMAND is still the same.

However, to verify the process hierarchy differs, we again deploy and use ps:

$ docker exec 942bb64858b6 sh -c 'apt-get update && apt-get install --yes procps && ps -HA'
[...]
 PID TTY          TIME CMD
2154 ?        00:00:00 sh
2253 ?        00:00:00   ps
   1 ?        00:00:00 sleep

This time, we see a stand-alone sleep command is the origin. So, outside of the sh and ps processes that support the ps command, the container only depends on sleep.

Yet, we still had a chance to perform any desired initialization within the docker-entrypoint.sh script.

5. Start Script Process Arguments

We can leverage its command-line arguments to make a container initialization script even more universal.

In particular, we can consider one or all of the script arguments as a command that should replace the script once it’s done:

$ cat docker-entrypoint.sh
#!/usr/bin/env sh

echo 'Initialization...' > xnit
exec "$@"

In this case, $@* is the array of all whitespace-separated arguments to *docker-entrypoint.sh. So, exec ensures all of these are lined up as the shell replacement process and its arguments.

So, if we now use a slightly modified container definition, we can achieve the same effect as before:

$ docker run --rm --detach $(docker build --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
WORKDIR /xnit
COPY ./docker-entrypoint.sh .
CMD ["sh", "/xnit/docker-entrypoint.sh", "sleep", "666"]
')

This way, we can control what command runs by adding arguments to the CMD line instead of modifying docker-entrypoint.sh.

6. Summary

In this article, we talked about Docker container initialization scripts and ways to optimize them via exec.

In conclusion, although there are many ways to run processes within a container, it’s usually best to ensure that the main process is also the PID 1 origin.