Containers all 4 written
What a container actually is, real Dockerfiles and images, and running more than one of them together.
11 What a container actually is
A virtual machine boots its own kernel and takes tens of seconds. A container starts in milliseconds because it is not booting anything -- it is a process, launched by the same kernel the host is already running, with a curtain drawn around what it can see. This lesson pulls the curtain back and shows what is actually behind it.
Inside the curtain: a container's process table has almost nothing in it
Every one of the hundreds of processes actually running on this machine is invisible from inside a container, by design. What a container sees when it asks "what is running" is only itself.
echo "processes visible on the real machine right now:"
host_count=$(ps aux | wc -l | tr -d ' ')
echo " $host_count lines from ps aux"
echo
echo "processes visible from INSIDE a fresh container:"
docker run --rm alpine:3.20 ps aux
container_count=$(docker run --rm alpine:3.20 ps aux | wc -l | tr -d ' ')
container_pid1=$(docker run --rm alpine:3.20 sh -c 'echo $$')
echo
echo "the container sees $container_count line(s) of output from ps aux"
echo "and its own process is PID $container_pid1 -- from inside its own view"
check "the host has far more real processes running than a container reports" \
"$([ "$host_count" -gt "$container_count" ] && echo yes)" "yes"
check "every fresh container's main process believes it is PID 1" "$container_pid1" "1"
What it printed, when the build ran it
processes visible on the real machine right now:
798 lines from ps aux
processes visible from INSIDE a fresh container:
PID USER TIME COMMAND
1 root 0:00 ps aux
the container sees 2 line(s) of output from ps aux
and its own process is PID 1 -- from inside its own view
ok the host has far more real processes running than a container reports
ok every fresh container's main process believes it is PID 1
PID 1 inside the container and some other PID on the host are the SAME process. Namespaces do not create a new process, they change what an existing one is allowed to see and address. The next panel proves this by asking the host what PID it actually gave the container.
The same process, seen from both sides
docker inspect asks the Docker daemon -- which lives on the host, outside
any container -- for the real, host-level process ID it assigned. That number, and the
process the container itself sees as PID 1 in its own namespace, are two views of exactly
one running process.
docker run --rm -d --name cd-course-inspect alpine:3.20 sleep 30 > /dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
docker inspect cd-course-inspect > /dev/null 2>&1 && break
sleep 0.2
done
host_pid=$(docker inspect cd-course-inspect --format '{{.State.Pid}}')
# 'docker exec' peeks INTO the container's existing namespace and asks its own
# ps what PID 1 there actually is running -- rather than spawning a brand new
# process and assuming it inherits PID 1, which it does not.
container_view=$(docker exec cd-course-inspect ps aux)
pid1_command=$(echo "$container_view" | awk '$1 == "1" {print $4; exit}')
echo "the host (via the Docker daemon) says this container's process is PID: $host_pid"
echo
echo "the SAME container, asked about itself from the inside:"
echo "$container_view"
echo "...PID 1 in that namespace is running: $pid1_command"
docker kill cd-course-inspect > /dev/null 2>&1
docker rm cd-course-inspect > /dev/null 2>&1
check "the host's PID for this process is a real, large process ID" \
"$([ "$host_pid" -gt 1 ] && echo yes)" "yes"
check "inside its own namespace, the container's main process really is PID 1" "$pid1_command" "sleep"
What it printed, when the build ran it
the host (via the Docker daemon) says this container's process is PID: 42161
the SAME container, asked about itself from the inside:
PID USER TIME COMMAND
1 root 0:00 sleep 30
7 root 0:00 ps aux
...PID 1 in that namespace is running: sleep
ok the host's PID for this process is a real, large process ID
ok inside its own namespace, the container's main process really is PID 1
Filesystem isolation: a container's root is not the host's root
The same trick applies to files. A container's / is whatever the image
shipped, not the host's actual filesystem -- writing to /etc/hostname inside a
container never touches the real machine's /etc/hostname at all.
echo "the REAL machine's hostname file, untouched by anything below:"
real_hostname_before=$(cat /etc/hostname 2>/dev/null || hostname)
echo " $real_hostname_before"
echo
echo "writing a completely different hostname INSIDE a container:"
docker run --rm alpine:3.20 sh -c 'echo totally-fake-name > /etc/hostname; cat /etc/hostname'
real_hostname_after=$(cat /etc/hostname 2>/dev/null || hostname)
echo
echo "the real machine's hostname file, checked again afterwards:"
echo " $real_hostname_after"
check "the real machine's hostname file was never touched" "$real_hostname_after" "$real_hostname_before"
What it printed, when the build ran it
the REAL machine's hostname file, untouched by anything below: Emmanuels-MacBook-Pro.local writing a completely different hostname INSIDE a container: totally-fake-name the real machine's hostname file, checked again afterwards: Emmanuels-MacBook-Pro.local ok the real machine's hostname file was never touched
What this is NOT: strong security isolation on the level of a real virtual machine. Containers share the host's actual kernel, so a kernel vulnerability can, in principle, be exploited to escape a container in a way that is architecturally impossible against a real VM's separate kernel. This is exactly why "run untrusted code" and "package our own trusted application" call for different levels of isolation, and why cloud providers offer both containers and VMs rather than only one.
Starting fast is not a trick -- it follows directly from what a container is
Because starting a container is "ask the kernel to start a process with some namespaces," not "boot a kernel," the startup time is dominated by however long the program itself takes to become ready -- often milliseconds for something small.
echo "timing a real container's total start-to-finish time:"
start_ns=$(date +%s%N)
docker run --rm alpine:3.20 true
end_ns=$(date +%s%N)
elapsed_ms=$(( (end_ns - start_ns) / 1000000 ))
echo "alpine container, start to exit: ${elapsed_ms}ms"
check "starting and exiting a minimal container takes well under a second" \
"$([ "$elapsed_ms" -lt 5000 ] && echo yes)" "yes"
What it printed, when the build ran it
timing a real container's total start-to-finish time: alpine container, start to exit: 425ms ok starting and exiting a minimal container takes well under a second
Why 5 seconds, not 100ms, as the threshold above. This check runs during a real build, on whatever machine happens to be building the course, which may be loaded with other work. The point being verified is "this is process-speed, not boot-speed" -- a real VM boot is measured in tens of seconds even on fast hardware, so anything under five is already a categorically different thing, and picking a strict threshold here would make the check about this machine's mood, not about containers.
12 A real Dockerfile, and images as layers
A Dockerfile is a recipe for building an image one layer at a time, and each instruction becomes its own layer, cached independently. Understanding that one fact -- and specifically, the order the instructions are written in -- is most of what separates a Dockerfile that rebuilds in two seconds from one that reinstalls every dependency on every single change.
A real Dockerfile, building a real HTTP server
Five lines, and a genuine, working web server on the other end of it: a base image, a working directory, a file copied in, a port documented, and the command that starts it.
work=$(mktemp -d)
cd "$work"
cat > app.py << 'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(b'hello from a real container\n')
HTTPServer(('0.0.0.0', 8000), Handler).serve_forever()
PY
cat > Dockerfile << 'DOCKERFILE'
FROM python:3.12-alpine
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python3", "app.py"]
DOCKERFILE
echo "the Dockerfile:"
cat Dockerfile
echo
echo "building it for real:"
docker build -t cd-course-lesson12:v1 --progress=plain . 2>&1 | grep -E '^\#[0-9]+ (\[|naming|exporting to)'
build_exit=${PIPESTATUS[0]}
echo
echo "running it and making a real HTTP request against it:"
docker run --rm -d --name cd-course-l12 -p 18801:8000 cd-course-lesson12:v1 > /dev/null
response=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
response=$(curl -s -f http://localhost:18801/ 2>/dev/null) && break
sleep 0.3
done
echo "the container's real response: $response"
docker stop cd-course-l12 > /dev/null 2>&1
docker rmi cd-course-lesson12:v1 > /dev/null 2>&1
cd / && rm -rf "$work"
check "the image built without error" "$build_exit" "0"
check "a real HTTP request to the running container got the expected body" \
"$response" "hello from a real container"
What it printed, when the build ran it
the Dockerfile: FROM python:3.12-alpine WORKDIR /app COPY app.py . EXPOSE 8000 CMD ["python3", "app.py"] building it for real: #1 [internal] load build definition from Dockerfile #2 [internal] load metadata for docker.io/library/python:3.12-alpine #3 [internal] load .dockerignore #4 [internal] load build context #5 [1/3] FROM docker.io/library/python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a #6 [2/3] WORKDIR /app #7 [3/3] COPY app.py . #8 exporting to image #8 naming to docker.io/library/cd-course-lesson12:v1 #8 naming to docker.io/library/cd-course-lesson12:v1 done running it and making a real HTTP request against it: the container's real response: hello from a real container ok the image built without error ok a real HTTP request to the running container got the expected body
Layers, and the build cache that keys off them
Docker caches each instruction's result by hashing that instruction and everything it
depends on. Change nothing, and a rebuild replays the cache instead of doing any real work
-- shown here as CACHED against each step.
work=$(mktemp -d)
cd "$work"
# Content unique to this run: rebuilding this course must never let a layer
# cached by an earlier run make the "cold build" step look cached already.
unique="$(date +%s%N)"
echo "line one $unique" > app.py
cat > Dockerfile << DOCKERFILE
FROM alpine:3.20
WORKDIR /app
COPY app.py .
RUN echo "processed $unique" > /marker.txt
DOCKERFILE
echo "first build (nothing OF THIS DOCKERFILE'S OWN STEPS to reuse yet):"
first=$(docker build -t cd-course-l12b:v1 --progress=plain . 2>&1)
echo "$first" | grep -E '^\#[0-9]+ (\[|CACHED)'
# Scoped to COPY/RUN only: the base image (FROM) may legitimately already be
# cached from an earlier, unrelated pull on this machine, which is correct
# Docker behaviour, not something to hide behind a falsely "cold" assertion.
first_own_cached=$(echo "$first" | grep -E '(COPY|RUN)' -A1 | grep -c 'CACHED')
echo
echo "second build, with NO changes at all:"
second=$(docker build -t cd-course-l12b:v1 --progress=plain . 2>&1)
echo "$second" | grep -E '^\#[0-9]+ (\[|CACHED)'
second_own_cached=$(echo "$second" | grep -E '(COPY|RUN)' -A1 | grep -c 'CACHED')
docker rmi cd-course-l12b:v1 > /dev/null 2>&1
cd / && rm -rf "$work"
echo
echo "this Dockerfile's own steps cached on the cold build: $first_own_cached"
echo "this Dockerfile's own steps cached on the identical rebuild: $second_own_cached"
check "the first build does real work on its own COPY/RUN steps" "$first_own_cached" "0"
check "the identical rebuild reuses both of this Dockerfile's own steps" "$second_own_cached" "2"
What it printed, when the build ran it
first build (nothing OF THIS DOCKERFILE'S OWN STEPS to reuse yet): #1 [internal] load build definition from Dockerfile #2 [internal] load metadata for docker.io/library/alpine:3.20 #3 [internal] load .dockerignore #4 [1/4] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc #5 [internal] load build context #6 [2/4] WORKDIR /app #6 CACHED #7 [3/4] COPY app.py . #8 [4/4] RUN echo "processed 1789503443804367000" > /marker.txt second build, with NO changes at all: #1 [internal] load build definition from Dockerfile #2 [internal] load metadata for docker.io/library/alpine:3.20 #3 [internal] load .dockerignore #4 [internal] load build context #5 [1/4] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc #4 [internal] load build context #6 [2/4] WORKDIR /app #6 CACHED #7 [3/4] COPY app.py . #7 CACHED #8 [4/4] RUN echo "processed 1789503443804367000" > /marker.txt #8 CACHED this Dockerfile's own steps cached on the cold build: 0 this Dockerfile's own steps cached on the identical rebuild: 2 ok the first build does real work on its own COPY/RUN steps ok the identical rebuild reuses both of this Dockerfile's own steps
Instruction order decides how much of that cache survives a real change
This is the actual lesson inside the lesson: put whatever changes least often first. A dependency file changes rarely; application code changes constantly. Copying dependencies in and installing them before copying the code means an ordinary code change never invalidates the (often slow) install step.
work=$(mktemp -d)
cd "$work"
echo "# pretend dependency list" > requirements.txt
# Content unique to this run, so a repeat build of this course can never
# collide with a layer BuildKit cached from an earlier run of this exact
# panel -- which would make the "before" state falsely look cached already.
unique="$(date +%s%N)"
echo "print('version 1, $unique')" > app.py
cat > Dockerfile << 'DOCKERFILE'
FROM alpine:3.20
WORKDIR /app
COPY requirements.txt .
RUN echo "pretend: pip install -r requirements.txt" > /installed.marker
COPY app.py .
DOCKERFILE
docker build -t cd-course-l12c:v1 --progress=plain . > /dev/null 2>&1
echo "changing ONLY app.py -- requirements.txt is untouched:"
echo "print('version 2, unrelated to dependencies, $unique')" > app.py
rebuild=$(docker build -t cd-course-l12c:v1 --progress=plain . 2>&1)
echo "$rebuild" | grep -E '^\#[0-9]+ (\[|CACHED)'
deps_step_cached=$(echo "$rebuild" | grep -A1 'COPY requirements.txt' | grep -c 'CACHED')
install_step_cached=$(echo "$rebuild" | grep -A1 'RUN echo' | grep -c 'CACHED')
code_step_cached=$(echo "$rebuild" | grep -A1 'COPY app.py' | grep -c 'CACHED')
docker rmi cd-course-l12c:v1 > /dev/null 2>&1
cd / && rm -rf "$work"
echo
echo "dependency copy step cached: $deps_step_cached"
echo "the slow install step cached: $install_step_cached"
echo "the code copy step cached (it changed, so it should NOT be): $code_step_cached"
check "the dependency-copying step was reused, since requirements.txt did not change" "$deps_step_cached" "1"
check "the slow install step was reused too, for the same reason" "$install_step_cached" "1"
check "only the step that actually changed had to redo any work" "$code_step_cached" "0"
What it printed, when the build ran it
changing ONLY app.py -- requirements.txt is untouched: #1 [internal] load build definition from Dockerfile #2 [internal] load metadata for docker.io/library/alpine:3.20 #3 [internal] load .dockerignore #4 [internal] load build context #5 [1/5] FROM docker.io/library/alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc #6 [3/5] COPY requirements.txt . #6 CACHED #7 [2/5] WORKDIR /app #7 CACHED #8 [4/5] RUN echo "pretend: pip install -r requirements.txt" > /installed.marker #8 CACHED #9 [5/5] COPY app.py . dependency copy step cached: 1 the slow install step cached: 1 the code copy step cached (it changed, so it should NOT be): 0 ok the dependency-copying step was reused, since requirements.txt did not change ok the slow install step was reused too, for the same reason ok only the step that actually changed had to redo any work
This is not a micro-optimisation. On a real project with a genuine
dependency install step -- npm install, pip install, a Go
module download -- getting this order wrong means every single code change re-triggers a
full dependency reinstall, turning a two-second rebuild into a two-minute one, on every
push, for every developer, for the life of the project.
What actually ended up in the image
docker history lists every layer an image is made of, in the order the
Dockerfile created them -- the real, on-disk consequence of every instruction above.
work=$(mktemp -d)
cd "$work"
echo "app" > app.py
cat > Dockerfile << 'DOCKERFILE'
FROM alpine:3.20
WORKDIR /app
COPY app.py .
RUN echo "built" > /marker.txt
DOCKERFILE
docker build -t cd-course-l12d:v1 --progress=plain . > /dev/null 2>&1
echo "every layer this image is actually made of:"
docker history cd-course-l12d:v1 --no-trunc --format '{{.CreatedBy}}' | head -5
layer_count=$(docker history cd-course-l12d:v1 -q | wc -l | tr -d ' ')
echo
echo "total layers: $layer_count"
docker rmi cd-course-l12d:v1 > /dev/null 2>&1
cd / && rm -rf "$work"
check "the image really does have multiple distinct layers" "$([ "$layer_count" -ge 3 ] && echo yes)" "yes"
What it printed, when the build ran it
every layer this image is actually made of: RUN /bin/sh -c echo "built" > /marker.txt # buildkit COPY app.py . # buildkit WORKDIR /app CMD ["/bin/sh"] ADD alpine-minirootfs-3.20.10-x86_64.tar.gz / # buildkit total layers: 5 ok the image really does have multiple distinct layers
13 Docker Compose: several containers as one system
Every container so far in this course has been alone. Compose's whole job is removing that limitation: one YAML file describing several services, brought up together, on a private network where each one can find the others by name -- not by an IP address you have to look up and hope does not change.
Two real services, and one reaching the other by name
An nginx web server and a Redis cache, defined in one file, started with one command.
The interesting part is not that both start -- it is that web can reach
cache by typing exactly that word, because Compose gives every service in the
file its own DNS name on a private network it creates automatically.
work=$(mktemp -d)
cd "$work"
cat > compose.yaml << 'YAML'
services:
web:
image: nginx:alpine
ports:
- "18900:80"
cache:
image: redis:alpine
YAML
echo "the compose file:"
cat compose.yaml
echo
echo "bringing both services up:"
docker compose -p cd-course-l13a up -d --quiet-pull
up_exit=$?
for _ in 1 2 3 4 5 6 7 8 9 10; do
curl -s -f http://localhost:18900/ > /dev/null 2>&1 && break
sleep 0.3
done
web_status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18900/)
echo
echo "a real HTTP request to the web service: status $web_status"
echo
echo "the web service reaching the cache service BY NAME, not by IP:"
ping_result=$(docker compose -p cd-course-l13a exec -T web ping -c1 cache 2>&1)
echo "$ping_result"
docker compose -p cd-course-l13a down > /dev/null 2>&1
cd / && rm -rf "$work"
check "compose brought both services up without error" "$up_exit" "0"
check "the web service actually answers HTTP requests" "$web_status" "200"
check "one service reaches another purely by service name" \
"$(echo "$ping_result" | grep -c '1 packets transmitted, 1 packets received')" "1"
What it printed, when the build ran it
the compose file:
services:
web:
image: nginx:alpine
ports:
- "18900:80"
cache:
image: redis:alpine
bringing both services up:
Network cd-course-l13a_default Creating
Network cd-course-l13a_default Creating
Network cd-course-l13a_default Created
Network cd-course-l13a_default Created
Container cd-course-l13a-web-1 Creating
Container cd-course-l13a-cache-1 Creating
Container cd-course-l13a-cache-1 Created
Container cd-course-l13a-web-1 Created
Container cd-course-l13a-cache-1 Starting
Container cd-course-l13a-web-1 Starting
Container cd-course-l13a-cache-1 Started
Container cd-course-l13a-web-1 Started
a real HTTP request to the web service: status 200
the web service reaching the cache service BY NAME, not by IP:
PING cache (172.21.0.2): 56 data bytes
64 bytes from 172.21.0.2: seq=0 ttl=64 time=0.217 ms
--- cache ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.217/0.217/0.217 ms
ok compose brought both services up without error
ok the web service actually answers HTTP requests
ok one service reaches another purely by service name
This is the entire reason Compose exists over a plain
docker run for anything with more than one container. Making two
bare containers talk by name needs a manually created Docker network and manual
--network flags on every run. Compose does both automatically,
just from the services being listed in the same file.
Sharing real data between two containers with a volume
A named volume is storage that outlives any single container and can be mounted into more than one at a time. Here, one throwaway container writes a value; a second, completely separate container reads that exact value back through the shared volume.
work=$(mktemp -d)
cd "$work"
cat > compose.yaml << 'YAML'
services:
writer:
image: alpine:3.20
environment:
- SHARED_MESSAGE=hello-from-compose-env
command: sh -c "echo $$SHARED_MESSAGE > /shared/message.txt && sleep 2"
volumes:
- shared-data:/shared
reader:
image: alpine:3.20
command: sh -c "sleep 1 && cat /shared/message.txt"
volumes:
- shared-data:/shared
depends_on:
- writer
volumes:
shared-data:
YAML
echo "note the double dollar sign, \$\$SHARED_MESSAGE, in the compose file above:"
echo "a single \$ would be substituted by COMPOSE ITSELF, from the shell running"
echo "'docker compose', before the container ever sees it -- \$\$ is how you tell"
echo "Compose 'leave this one alone, let the CONTAINER's shell expand it instead.'"
docker compose -p cd-course-l13b up --quiet-pull > /tmp/cd-l13b.log 2>&1
compose_exit=$?
reader_output=$(docker compose -p cd-course-l13b logs reader 2>&1 | grep -v Attaching)
echo
echo "what the reader container actually read back, from a completely separate container's write:"
echo "$reader_output"
docker compose -p cd-course-l13b down -v > /dev/null 2>&1
cd / && rm -rf "$work" /tmp/cd-l13b.log
check "the compose run completed" "$compose_exit" "0"
check "the reader saw the exact value the writer set, through a shared volume" \
"$(echo "$reader_output" | grep -c 'hello-from-compose-env')" "1"
What it printed, when the build ran it
note the double dollar sign, $$SHARED_MESSAGE, in the compose file above: a single $ would be substituted by COMPOSE ITSELF, from the shell running 'docker compose', before the container ever sees it -- $$ is how you tell Compose 'leave this one alone, let the CONTAINER's shell expand it instead.' what the reader container actually read back, from a completely separate container's write: reader-1 | hello-from-compose-env ok the compose run completed ok the reader saw the exact value the writer set, through a shared volume
The $$ above is a genuinely common gotcha, not decoration.
Compose performs its own variable substitution on the YAML file before any container
starts. Write a single $VARNAME intending the container's shell to expand
it, and Compose usually expands it first instead -- often to nothing, silently, which is
exactly what the warning in the raw output of the broken version of this panel said
while this lesson was being written: the "SHARED_MESSAGE" variable is not set.
depends_on controls start order, not readiness
The compose file above used depends_on: [writer] so reader
starts after writer is created -- but "created" is not "finished writing the
file." The sleep 1 inside reader's own command is what actually
gives writer time to do its work; depends_on alone would not have
been enough.
depends_on— startsBafterAhas started. Does not wait forAto be ready to accept work.depends_on: condition: service_healthy— the real fix, ifAdefines ahealthcheck: nowBwaits forAto report healthy, not merely started.- Without either, the application itself must retry its own connection on startup -- the same discipline a real Kubernetes Pod needs, covered in Part 11.
14 Registries, tags and shipping an image
"Push an image" and "pull an image" sound like Docker-specific
magic. They are HTTP requests against a documented API -- the OCI Distribution Spec --
which is exactly why Docker Hub, ECR, GHCR and a registry you run yourself all work with
the same docker push/pull commands. This lesson runs the
cheapest possible proof: a real registry, containerized, with nothing hidden.
A registry, self-hosted, receiving a real push and a real pull
registry:2 is the official, open-source reference implementation --
literally the same software Docker Hub itself is built from. Starting one locally costs
nothing and needs no account.
docker run -d -p 15000:5000 --name cd-course-registry registry:2 > /dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
curl -s -f http://localhost:15000/v2/ > /dev/null 2>&1 && break
sleep 0.3
done
echo "tagging a real, already-pulled image for this registry:"
docker tag alpine:3.20 localhost:15000/course-image:v1
echo
echo "pushing it for real:"
docker push localhost:15000/course-image:v1 2>&1 | grep -v "^[0-9a-f]*: \(Unavailable\|Preparing\|Waiting\)"
echo
echo "removing the local copy, so the next pull cannot be cheating from local cache:"
docker rmi localhost:15000/course-image:v1 > /dev/null 2>&1
no_local_copy=$(docker images -q localhost:15000/course-image:v1)
echo "local copy after removal: '$no_local_copy' (empty means really gone)"
echo
echo "pulling it back from the registry:"
docker pull localhost:15000/course-image:v1 2>&1 | tail -3
pulled_id=$(docker images -q localhost:15000/course-image:v1)
echo "local copy after pulling back: $pulled_id"
docker rmi localhost:15000/course-image:v1 > /dev/null 2>&1
docker stop cd-course-registry > /dev/null 2>&1
docker rm cd-course-registry > /dev/null 2>&1
check "the local copy was genuinely removed before pulling" "$no_local_copy" ""
check "the pull actually brought a real image back" "$([ -n "$pulled_id" ] && echo yes)" "yes"
What it printed, when the build ran it
tagging a real, already-pulled image for this registry:
pushing it for real:
The push refers to repository [localhost:15000/course-image]
25f1d6b1951a: Pushed
v1: digest: sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e size: 1023
Info -> Not all multiplatform-content is present and only the available single-platform image was pushed
sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc -> sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
removing the local copy, so the next pull cannot be cheating from local cache:
local copy after removal: '' (empty means really gone)
pulling it back from the registry:
Digest: sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
Status: Downloaded newer image for localhost:15000/course-image:v1
localhost:15000/course-image:v1
local copy after pulling back: c64c687cbea9
ok the local copy was genuinely removed before pulling
ok the pull actually brought a real image back
Underneath the docker command: it really is just HTTP
The registry exposes a plain, documented HTTP API. Nothing here needs the
docker CLI at all -- curl against the same endpoints Docker
itself calls gets the same answers.
docker run -d -p 15001:5000 --name cd-course-registry2 registry:2 > /dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
curl -s -f http://localhost:15001/v2/ > /dev/null 2>&1 && break
sleep 0.3
done
echo "the registry's catalog, before anything is pushed:"
before=$(curl -s http://localhost:15001/v2/_catalog)
echo "$before"
docker tag alpine:3.20 localhost:15001/curl-visible:v1
docker push localhost:15001/curl-visible:v1 > /dev/null 2>&1
echo
echo "the same catalog endpoint, plain curl, after a real push:"
after=$(curl -s http://localhost:15001/v2/_catalog)
echo "$after"
echo
echo "the tags for that one repository, again via plain HTTP:"
tags=$(curl -s http://localhost:15001/v2/curl-visible/tags/list)
echo "$tags"
docker rmi localhost:15001/curl-visible:v1 > /dev/null 2>&1
docker stop cd-course-registry2 > /dev/null 2>&1
docker rm cd-course-registry2 > /dev/null 2>&1
check "the catalog starts empty" "$before" '{"repositories":[]}'
check "curl alone (no docker CLI) can see the pushed image afterwards" \
"$(echo "$after" | grep -c 'curl-visible')" "1"
check "the tag we pushed shows up via the same plain HTTP endpoint" \
"$(echo "$tags" | grep -c '"v1"')" "1"
What it printed, when the build ran it
the registry's catalog, before anything is pushed:
{"repositories":[]}
the same catalog endpoint, plain curl, after a real push:
{"repositories":["curl-visible"]}
the tags for that one repository, again via plain HTTP:
{"name":"curl-visible","tags":["v1"]}
ok the catalog starts empty
ok curl alone (no docker CLI) can see the pushed image afterwards
ok the tag we pushed shows up via the same plain HTTP endpoint
This is why multi-cloud image portability is realistic. An image pushed to Amazon ECR, Google Artifact Registry or a self-hosted registry is the same bytes in the same format, reachable through the same API shape. Moving a workload between clouds is rarely blocked by the image itself -- it is blocked by everything around it: IAM, networking, managed service equivalents.
Tags are labels, not versions -- and :latest is not special
A tag is just a name pointing at a specific image, the same way a Git branch points at a
commit. Nothing enforces that :latest means "the newest" -- it means whatever
was last pushed with that literal tag, which is exactly why relying on it in production is
a common, avoidable source of "it worked yesterday."
docker tag alpine:3.20 course-tag-demo:latest
docker tag alpine:3.20 course-tag-demo:v1
id_latest=$(docker images -q course-tag-demo:latest)
id_v1=$(docker images -q course-tag-demo:v1)
echo "course-tag-demo:latest -> $id_latest"
echo "course-tag-demo:v1 -> $id_v1"
echo "(both point at the same image right now, because both were just tagged from the same source)"
docker rmi course-tag-demo:latest course-tag-demo:v1 > /dev/null 2>&1
check "two different tags can point at the exact same image" "$id_latest" "$id_v1"
What it printed, when the build ran it
course-tag-demo:latest -> d9e853e87e55 course-tag-demo:v1 -> d9e853e87e55 (both point at the same image right now, because both were just tagged from the same source) ok two different tags can point at the exact same image
The practical rule this leads to: deploy by a specific, immutable
tag or digest (a version number, a Git commit hash, or the image's own
sha256:... digest) -- never by :latest -- so that "which exact
image is running in production" is always an answerable question, not a guess about
when someone last pushed.
What a real cloud registry adds on top of the open protocol
# Authenticating to and pushing into a real cloud registry -- Amazon ECR here.
# NOT RUN HERE: this needs a real AWS account with ECR access, which moto (this
# course's local AWS stand-in) does not emulate for actual image storage.
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
docker tag my-app:v1 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:v1
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:v1
# Everything after 'docker login' is identical to pushing to the self-hosted
# registry above -- same protocol, same commands. What ECR actually adds is
# IAM-based access control, vulnerability scanning, and lifecycle policies
# that auto-delete old images -- not a different way of storing them.
Needs a real AWS account with ECR access. moto emulates AWS APIs for compute/storage/database services but does not provide real image storage for ECR, so this cannot run inside this course's build.