Skip to content

Image Distribution Example Part 1


Everything in this section so far has used images someone else built. This is where that changes: a small calculator server, built from scratch with a Dockerfile, versioned properly, and pushed to a registry you run yourself. This first part covers building the image correctly — the registry and versioning work comes next.

The App: A Tiny Calculator Server

The calculator is a small C program that listens on a TCP port, accepts one connection, reads a request like add 2 3, computes the result, and writes it back. No external libraries — just the C standard library and POSIX sockets, both included in any base image with a C compiler.

Note

You are learning how image is distributed not C. So, it doesn’t matter if you know C or not. You can continue in either case.

Create calculator.c in my-project folder with the given code:

mkdir my-project && cd my-project
calculator.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <port>\n", argv[0]);
        return 1;
    }
    int port = atoi(argv[1]);

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); return 1; }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(port);

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind"); return 1;
    }
    if (listen(server_fd, 1) < 0) {
        perror("listen"); return 1;
    }

    printf("calculator-server listening on port %d\n", port);

    int client_fd = accept(server_fd, NULL, NULL);
    if (client_fd < 0) { perror("accept"); return 1; }

    char buffer[256] = {0};
    read(client_fd, buffer, sizeof(buffer) - 1);

    char op[16];
    double a, b, result;
    int parsed = sscanf(buffer, "%15s %lf %lf", op, &a, &b);

    char response[256];
    if (parsed != 3) {
        snprintf(response, sizeof(response), "ERROR invalid request\n");
    } else if (strcmp(op, "add") == 0) {
        result = a + b;
        snprintf(response, sizeof(response), "%.2f\n", result);
    } else if (strcmp(op, "subtract") == 0) {
        result = a - b;
        snprintf(response, sizeof(response), "%.2f\n", result);
    } else if (strcmp(op, "multiply") == 0) {
        result = a * b;
        snprintf(response, sizeof(response), "%.2f\n", result);
    } else if (strcmp(op, "divide") == 0) {
        result = a / b;
        snprintf(response, sizeof(response), "%.2f\n", result);
    } else {
        snprintf(response, sizeof(response), "ERROR unknown operation '%s'\n", op);
    }

    write(client_fd, response, strlen(response));
    close(client_fd);
    close(server_fd);

    return 0;
}

A quick walkthrough for anyone who hasn’t written C before:

  • argv[1] is the port number, passed in when the program starts — this is what CMD will supply later.
  • socket(), bind(), and listen() set up a listening TCP socket on that port. This is the exact same networking layer your OS uses for any server — nothing Docker-specific about it.
  • accept() blocks until one client connects, then hands back a separate file descriptor (client_fd) just for talking to that one client.
  • read() pulls in whatever the client sent — expected to look like add 2 3.
  • sscanf() splits that into an operation name and two numbers.
  • The if/else if chain picks the operation and computes the result. Notice divide has no check for b being zero yet — that’s intentional, and gets fixed as a deliberate bug fix once versioning is in the picture.
  • write() sends the result back, then both sockets are closed and the program exits.

That last point matters: this version handles exactly one connection and then the process ends — which means the container stops right after. That’s a deliberate first version, not an oversight; it becomes relevant once you start versioning this app for real.

Save this as calculator.c.

The Multi-Stage Dockerfile

You’ve already covered why multi-stage builds exist, so just the specifics here: the build stage needs gcc and the C standard headers to compile the program; the final image doesn’t need any of that; it only needs the compiled binary. Create this Dockerfile alongside your code:

Dockerfile
# ---- Build stage ----
FROM alpine:3.19 AS builder

RUN apk add --no-cache gcc musl-dev

WORKDIR /build
COPY calculator.c .
RUN gcc -static -o calculator-server calculator.c

# ---- Final stage ----
FROM alpine:3.19

COPY --from=builder /build/calculator-server /calculator-server
EXPOSE 8080

ENTRYPOINT ["/calculator-server"]
CMD ["8080"]

gcc -static links the C standard library directly into the binary instead of depending on it being present at runtime. Combined with the multi-stage split, the final image doesn’t need gcc, musl-dev, or even a working C library installed — just the one binary sitting on top of a bare alpine. Build it and compare, to see the difference for yourself:

docker image build --target builder -t calculator:builder-stage .
docker image build -t calculator:1.0.0 .
docker image ls calculator

The builder-stage tag (built with --target to stop at that stage) will be noticeably larger than the final 1.0.0 image — that’s the entire payoff of the multi-stage split, made visible.

Why Both ENTRYPOINT and CMD

The Dockerfile above uses both ENTRYPOINT and CMD together, which is worth pausing on — the difference between them is easy to memorize as a rule and still not understand until you see what each one is actually for.

  • ENTRYPOINT is the program that always runs when the container starts. It’s not meant to be casually overridden — if it were, someone could accidentally run the image without starting the server at all.
  • CMD supplies the default arguments passed to that entrypoint. Unlike ENTRYPOINT, it’s specifically designed to be overridden at run time.

Try both, back to back:

docker container run -d -p 8080:8080 --name calc-default calculator:1.0.0

With no extra arguments, Docker runs /calculator-server 8080 — the entrypoint, plus CMD’s default port.

docker container run -d -p 9090:9090 --name calc-custom calculator:1.0.0 9090

Here, 9090 at the end of the command replaces CMD’s ["8080"] — Docker now runs /calculator-server 9090. The entrypoint itself — which program runs — never changed; only the argument fed into it did.

This is the actual reason real-world Dockerfiles so often define both: ENTRYPOINT locks down what the image fundamentally does, CMD exposes the one thing a user should reasonably be allowed to change — here, which port to listen on — without needing to know or override the entrypoint at all.

Testing Calculator App

Try the server for each container to confirm both are actually listening where expected. You can directly run this command on host machine because ports are mapped.

For the first container listening on default 8080:

echo "add 2 3" | nc -q 1 localhost 8080

For the second container listening on 9090:

echo "subtract 22 13" | nc -q 1 localhost 9090

Note

Since this version of calculator-server only handles one connection before exiting, each container answers exactly once — try it again against the same container and the connection will simply fail, because the process has already exited. That’s expected, and it’s exactly what the next part starts to change.

Cleanup Before Moving On

Remove image and containers:

docker container rm -f calc-default calc-custom
docker image rm calculator:builder-stage

Keep calculator:1.0.0 around — the next part picks up right here, versioning and pushing this exact image to a registry you’ll run yourself.

Last updated on