Search code examples
c++dockercmakedockerfile

Docker: minimal C++ program example


EDIT: Full solution is in the last update to this post below.

Context: I've been trying to get into docker and everything related to it for over 2 weeks, and so far I have failed to run anything except the very first tutorial on the Docker website (which is not particularly helpful, you just do what it says and have no idea what you actually did). This question is a cry of desperation, I simply don't have other options left (other than deploying on a virtual machine and calling it a day).

Goal: run minimal C++ program in a container. Standard C++ library is required, and I want to be able to include some external library (via source code; standalone ASIO is my real practical goal). For starters though, I just want to compile and run a C++ hello world program. Specific compilers and standard library implementations and other tools are not too important for now.


Problem description:

Host machine: Windows 10, WSL2

This attempt was inspired by one of the very few actually useful-looking guides available online, but it doesn't work either, so I thought I could get some help to make it run. For now, the Dockerfile posted in that article fails (details below).
Article.
I basically copy-pasted stuff from there. Of course, I read the code that I copy and made sure I understand what those Docker commands do. Didn't help me to get it running though.

main.cpp (the only source code file at this point):

#include <iostream>
int main(int argc, char **argv){
    std::cout << "This is a containerized C++ app!" << std::endl;
    std::cout << "Exiting..." << std::endl;
    return 0;
}

The folder with the Dockerfile looks the following way:

>ls
CMakeLists.txt  Dockerfile  src

Inside the src folder there is a main folder, main.cpp with the code from above is inside it. That's all.

Dockerfile, copied from the article:

FROM alpine:3.17.0 AS build
RUN apk update && \
    apk add --no-cache \
        build-base=0.5-r3 \
        cmake=3.24.3-r0 \
        boost1.80-dev=1.80.0-r3

WORKDIR /simplehttpserver
COPY src/ ./src/
COPY CMakeLists.txt .
WORKDIR /simplehttpserver/build
RUN cmake -DCMAKE_BUILD_TYPE=Release .. && \
    cmake --build . --parallel 2
    
FROM alpine:3.17.0
RUN apk update && \
    apk add --no-cache \
    libstdc++=12.2.1_git20220924-r4 \
    boost1.80-program_options=1.80.0-r3
RUN addgroup -S shs && adduser -S shs -G shs
USER shs
COPY --chown=shs:shs --from=build \
    ./simplehttpserver/build/src/simplehttpserver \
    ./app/
ENTRYPOINT [ "./app/simplehttpserver" ]

I'm not experienced at Cmake (minimal experience), so I found an example file on stackoverflow. Luckily, if something is wrong with the cmake file, it's not the root of the issue here - docker build doesn't even get that far. Let's solve problems as they come.

In the CMakeLists.txt, I only changed C++ version from 1y to 14, and the number of cores to compile with, nothing else. I glanced through it, I mostly understand what it does. But as I said, at this stage CMakeLists.txt is irrelevant.

When I use Windows CLI (as admin) and do docker build . -t myrepository/simplehttpserver:myfirsttag, it quickly runs into an error. It fails on the second line of the Dockerfile:

RUN apk update && \
    apk add --no-cache \
        build-base=0.5-r3 \
        cmake=3.24.3-r0 \
        boost1.80-dev=1.80.0-r3

With relevant error message being

6.249 ERROR: unable to select packages:
6.249   boost1.80-dev (no such package):
6.249     required by: world[boost1.80-dev=1.80.0-r3]
6.273   cmake-3.27.8-r0:
6.273     breaks: world[cmake=3.24.3-r0]

Of course, I tried to solve it myself. It complains about cmake version and boost name (right?). Changing cmake version (in the dockerfile) doesn't change anything, errors out with the same thing. Changing boost1.80-dev to just boost also changed nothing, it gives the same error. I have absolutely no clue what goes wrong and how to fix it, and I'm pretty desperate already.

Question: can you walk me through the process of configuring docker to run a containerized C++ program? My idea of it is to take what I currently have and incrementally fix all issues with it until it works. I think I understand the logical steps I need to do, but I simply don't understand how to make them actually happen.

It would be nice to see something along the lines of:

  • Step 1, minimal working example.
  • Step 2, minimal working example.
  • Step 3, minimal working example.
  • Step 4: working containerized C++ program with standard library.

Bonus - full docker build output, in case you need it:

>docker build . -t myrepository/simplehttpserver:myfirsttag
[+] Building 11.5s (9/15)                                                                                docker:default
 => [internal] load build definition from Dockerfile                                                               0.2s
 => => transferring dockerfile: 734B                                                                               0.1s
 => [internal] load .dockerignore                                                                                  0.1s
 => => transferring context: 2B                                                                                    0.1s
 => [internal] load metadata for docker.io/library/alpine:3.17.0                                                   2.2s
 => [auth] library/alpine:pull token for registry-1.docker.io                                                      0.0s
 => CACHED [stage-1 1/4] FROM docker.io/library/alpine:3.17.0@sha256:8914eb54f968791faf6a8638949e480fef81e697984f  0.0s
 => [internal] load build context                                                                                  0.0s
 => => transferring context: 124B                                                                                  0.0s
 => CACHED [stage-1 2/4] RUN apk update &&     apk add --no-cache     libstdc++=12.2.1_git20220924-r4     boost1.  0.0s
 => CACHED [stage-1 3/4] RUN addgroup -S shs && adduser -S shs -G shs                                              0.0s
 => ERROR [build 2/7] RUN apk update &&     apk add --no-cache         build-base=0.5-r3         cmake=3.24.3-r0   9.0s
------
 > [build 2/7] RUN apk update &&     apk add --no-cache         build-base=0.5-r3         cmake=3.24.3-r0         boost1.80-dev=1.80.0-r3:
0.626 fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/main/x86_64/APKINDEX.tar.gz
1.754 fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/community/x86_64/APKINDEX.tar.gz
5.621 v3.17.6-20-gd1b196e9d1e [https://dl-cdn.alpinelinux.org/alpine/v3.17/main]
5.621 v3.17.6-17-g90d4666e0dc [https://dl-cdn.alpinelinux.org/alpine/v3.17/community]
5.621 OK: 17824 distinct packages available
5.806 fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/main/x86_64/APKINDEX.tar.gz
6.463 fetch https://dl-cdn.alpinelinux.org/alpine/v3.17/community/x86_64/APKINDEX.tar.gz
8.682 ERROR: unable to select packages:
8.855   cmake-3.24.4-r0:
8.855     breaks: world[cmake=3.24.3-r0]
------
Dockerfile:2
--------------------
   1 |     FROM alpine:3.17.0 AS build
   2 | >>> RUN apk update && \
   3 | >>>     apk add --no-cache \
   4 | >>>         build-base=0.5-r3 \
   5 | >>>         cmake=3.24.3-r0 \
   6 | >>>         boost1.80-dev=1.80.0-r3
   7 |
--------------------
ERROR: failed to solve: process "/bin/sh -c apk update &&     apk add --no-cache         build-base=0.5-r3         cmake=3.24.3-r0         boost1.80-dev=1.80.0-r3" did not complete successfully: exit code: 1

Update 1:
I was asked to replace cmake=3.24.3-r0 with cmake=3.24.4-r0, and it advanced further! I have no clue why one version worked but the other one didn't (an answer to that would be a nice bonus), but now it errors out at:

 => ERROR [stage-1 4/4] COPY --chown=shs:shs --from=build     ./simplehttpserver/build/src/simplehttpserver     .  0.0s
------
 > [stage-1 4/4] COPY --chown=shs:shs --from=build     ./simplehttpserver/build/src/simplehttpserver     ./app/:
------
Dockerfile:22
--------------------
  21 |     USER shs
  22 | >>> COPY --chown=shs:shs --from=build \
  23 | >>>     ./simplehttpserver/build/src/simplehttpserver \
  24 | >>>     ./app/
  25 |     ENTRYPOINT [ "./app/simplehttpserver" ]
--------------------
ERROR: failed to solve: failed to compute cache key: failed to calculate checksum of ref 3e7fc6b1-894c-4a1f-ad62-7e73ad07ed7f::3l0pg7r0tkusk049o66sy33lr: failed to walk /var/lib/docker/tmp/buildkit-mount3578043760/simplehttpserver/build/src: lstat /var/lib/docker/tmp/buildkit-mount3578043760/simplehttpserver/build/src: no such file or directory

It seems to be related to the fact that I didn't fix paths in CMakeLists, but I can't know for sure, so an expert opinion would be welcome.

Update 2: I followed the recommendations of David Maze, and now Docker build goes further and fails later!

Updated Dockerfile:

FROM alpine:3.17.0 AS build
RUN apk update && \
    apk add --no-cache \
        build-base \
        cmake \
        boost1.80-dev

WORKDIR /simplehttpserver
COPY src/ ./src/
COPY CMakeLists.txt .
WORKDIR /simplehttpserver/build
RUN cmake -DCMAKE_BUILD_TYPE=Release .. && \
    cmake --build . --parallel 2
    
FROM alpine:3.17.0
RUN apk update && \
    apk add --no-cache \
    libstdc++=12.2.1_git20220924-r4 \
    boost1.80-program_options=1.80.0-r3
RUN addgroup -S shs && adduser -S shs -G shs
COPY --from=build \
    /simplehttpserver/build/src/simplehttpserver \
    ./app/
USER shs
CMD [ "simplehttpserver" ]

It has successfully installed all the GCC and CMake, but still fails at a copy --from=build part, and the message looks pretty cryptic to me:

>docker build . -t myrepository/simplehttpserver:myfirsttag
...all ok...   
 => ERROR [stage-1 4/4] COPY --from=build     /simplehttpserver/build/src/simplehttpserver     ./app/              0.0s
------
 > [stage-1 4/4] COPY --from=build     /simplehttpserver/build/src/simplehttpserver     ./app/:
------
Dockerfile:21
--------------------
  20 |     RUN addgroup -S shs && adduser -S shs -G shs
  21 | >>> COPY --from=build \
  22 | >>>     /simplehttpserver/build/src/simplehttpserver \
  23 | >>>     ./app/
  24 |     USER shs
--------------------
ERROR: failed to solve: failed to compute cache key: failed to calculate checksum of ref 3e7fc6b1-894c-4a1f-ad62-7e73ad07ed7f::wfoxawjqbl6c6evrscjtdm9n2: failed to walk /var/lib/docker/tmp/buildkit-mount2192535715/simplehttpserver/build/src: lstat /var/lib/docker/tmp/buildkit-mount2192535715/simplehttpserver/build/src: no such file or directory

I have no clue what file or directory is missing, and it uses some hash-looking strings, reports failing to produce a checksum of some random-looking string, and on top of that, it also says it "failed to walk", whatever that means. I have no clue how to interpret this error.

Update 3: I have continued to follow David's invaluable advice, and I managed to get into the shell of the first stage build. Basically, after I compile, but before I run what I compiled. I can't get it to run automatically just yet. It compiled successfully, and I have a working executable that I can easily run from the shell of the build stage image.

>docker run --rm -it build-stage sh
/simplehttpserver/build # ./example
This is a containerized C++ app!
Exiting...

Now I just have the last step, it seems, to make COPY do what it is supposed to do, because for now it refuses to see the file that is clearly there.

Here is full updated Dockerfile:

FROM alpine:3.19.0 AS build
RUN apk update && \
    apk add --no-cache \
        build-base \
        cmake \
        boost-dev

WORKDIR /simplehttpserver
COPY src/ ./src/
COPY CMakeLists.txt .
WORKDIR /simplehttpserver/build
RUN cmake -DCMAKE_BUILD_TYPE=Release .. && \
    cmake --build . --parallel 2
    
FROM alpine:3.19.0 AS runimage
RUN apk update && \
    apk add --no-cache \
    libstdc++ \
    boost-program_options
RUN addgroup -S shs && adduser -S shs -G shs
#The next line inexplicably fails to find "example"
COPY --from=build \
    example \
    ./app/
USER shs
CMD [ "example" ]

I also tried to COPY as

COPY --from=build \
    simplehttpserver/build/example \
    ./app/

Either of these fail to find the file example, and I have no idea why and what to do about it. I even tried another WORKDIR before it, it changed nothing at all. Why would COPY fail to recognize the file that is right in the folder it's looking at?


UPDATE 4! It works!
The main misconception on my part was about the working directory and the fact that every stage seems to have its own filesystem. As soon as I assumed that my working directory on the second stage is / (root), everything suddenly worked!

Of course, there is a chance that linking some external library would require extra tweaks, and my default CMakeLists would require attention, but C++ with standard library successfully compiled and then executed! I even changed the code on the host machine, rebuilt the image, and the changes took effect!

I actually used shell to inspect the second stage just like I inspected the first stage, this is what helped me reveal my mistake of having multiply file trees and their working directories mixed up in my head. Sounds like a very powerful tool.

Here is the final working version:

FROM alpine:3.19.0 AS buildstage
RUN apk update && \
    apk add --no-cache \
        build-base \
        cmake \
        boost-dev

WORKDIR /simplehttpserver
COPY src/ ./src/
COPY CMakeLists.txt .
WORKDIR /simplehttpserver/build
RUN cmake -DCMAKE_BUILD_TYPE=Release .. && \
    cmake --build . --parallel 2
    
FROM alpine:3.19.0 AS runstage
RUN apk update && \
    apk add --no-cache \
    libstdc++ \
    boost-program_options
RUN addgroup -S shs && adduser -S shs -G shs
COPY --from=buildstage \
    simplehttpserver/build/example \
    ./app/
USER shs
CMD [ "/app/example" ]

And the output is

>docker build . -t myrepository/simplehttpserver:myfirsttag
...
>docker run myrepository/simplehttpserver:myfirsttag
This is a containerized C++ app!
I added this line on a host machine after first successful run
Exiting...

With the final .cpp being

#include <iostream>

int main(int argc, char **argv){
    std::cout << "This is a containerized C++ app!" << std::endl;
    std::cout << "I added this line on a host machine after first successful run" << std::endl;
    std::cout << "Exiting..." << std::endl;
    return 0;
}

Solution

  • The Dockerfile you show looks basically correct, and in line with best practices. The two things you're specifically doing correctly are using a multi-stage build so that the build toolchain doesn't show up in the final image, and making sure to include the non-dev versions of the C(++) shared libraries you need in the runtime image.

    When using the apk Alpine package manager (and similarly using the Debian/Ubuntu dpkg/APT tools) I would avoid specifying extremely specific package version numbers, and probably avoid specifying any version numbers at all. The underlying Linux distributions get updates fairly regularly, and when they do, it's common to not keep every version of every package forever. I probably also wouldn't pin to a patch-level version of Alpine, again since you'll miss updates when there starts being a newer one.

    If you relax these version constraints, you'll get the versions of the software that are part of the distribution, which are unlikely to significantly change.

    FROM alpine:3.17 AS build # not specifically 3.17.0
    RUN apk update && \
        apk add --no-cache \
            build-base \
            cmake \
            boost1.80-dev     # without version numbers
    

    Testing locally, removing the version constraints in apk add is enough to get the image to build.

    The second error suggests that the path in the first build stage doesn't match the left-hand side of the COPY --from=build in the second stage. A concrete thing to try is to make sure you're using an absolute path on the left-hand side, since it's not clear what directory the path is relative to

    COPY --from=build /simplehttpserver/build/src/simplehttpserver ...
    #                 ^ no . here
    

    If that path is wrong, you'll need to run a temporary container on the build image to look around and find the right path.

    # Build only the build stage
    docker build -t build-stage . --target build
    
    # Run a temporary container with an interactive shell
    docker run --rm -it build-stage sh
    
    # Inside the container look around
    cd /simplehttpserver/build
    ls
    ...
    

    Two comments in terms of generic best practices: first, do not chown your binary to the non-root user. It will be executable but not writable, which means you'll still be able to run it but not accidentally overwrite it. Second, if you're using a self-contained binary in a compiled language, consider copying the application into a directory that's normally on the command search path, like /usr/local/bin.

    COPY --from=build \
      /simplehttpserver/build/src/simplehttpserver \
      /usr/local/bin/simplehttpserver
    USER shs                  # _after_ the COPY
    CMD ["simplehttpserver"]  # on $PATH already
    

    (I tend to prefer CMD over ENTRYPOINT, as being easier to debug the container and to allow using ENTRYPOINT for a wrapper script to do startup-time setup. Opinions vary here, and if you'll routinely need to pass additional command-line options via docker run but not run an alternate binary then ENTRYPOINT is a little more ergonomic.)