|
Note
|
Command examples use a unified host prompt style: [user@node-<n> …]$.
|
1. Process Isolation
By the end of this exercise, you should be able to:
-
Compare containerized process IDs inside and outside of a kernel namespace
-
Apply control group limits for CPU and memory consumption in a container
Exploring the PID Kernel Namespace
Start a simple container we can explore:
[user@node ~]$ docker container run -d --name pinger alpine:3.20 ping 8.8.8.8
Use docker container exec to launch a child process inside the container’s namespaces:
[user@node ~]$ docker container exec pinger ps -a
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.0 24860 1884 ? Ss 02:20 0:00 ping 8.8.8.8
root 5 0.0 0.0 51720 3504 ? Rs 02:20 0:00 ps -aux
Run the same ps directly on the host, and search for your ping process:
[user@node ~]$ ps -aux | grep ping
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 11622 0.0 0.0 24860 1884 ? Ss 02:20 0:00 ping 8.8.8.8
centos 11839 0.0 0.0 112656 2132 pts/0 S+ 02:23 0:00 grep --color=auto ping
The ping process appears as PID 1 inside the container, but as some higher PID (11622 in this example) from outside the container.
List your containers to show this ping container is still running:
[user@node ~]$ docker container ls
CONTAINER ID IMAGE COMMAND ... STATUS ... NAMES
bb3a3b1cbb78 alpine:3.20 "ping 8.8.8.8" ... Up 6 minutes pinger
Kill the ping process by host PID, and show the container has stopped:
[user@node ~]$ sudo kill -9 [host PID of ping]
[user@node ~]$ docker container ls
CONTAINER ID IMAGE COMMAND ... STATUS ... NAMES
Killing the ping process on the host also kills the container - all a running container is is its PID 1 process, and the kernel tooling that isolates it from the host. Note using kill -9 is just for demonstration purposes here; never stop containers this way.
Imposing Resource Limitations With Cgroups
Start a container that consumes two full CPUs:
[user@node ~]$ docker container run -d training/stress:3.0 --vm 2
Here the --vm flag starts 2 dummy processes that allocate and free memory as fast as they can, each consuming as many CPU cycles as possible.
Check the CPU consumption of processes in the container:
[user@node ~]$ docker container top <container ID>
UID PID PPID C ... CMD
root 5806 5789 0 ... /usr/bin/stress --verbose --vm 2
root 5828 5806 99 ... /usr/bin/stress --verbose --vm 2
root 5829 5806 99 ... /usr/bin/stress --verbose --vm 2
That C column represents CPU consumption, in percent; this container is hogging two full CPUs! See the same thing by running ps -aux both inside and outside this container, like we did above; the same process and its CPU utilization is visible inside and outside the container:
[user@node ~]$ docker container exec <container ID> ps -aux
USER PID %CPU %MEM ... COMMAND
root 1 0.0 0.0 ... /usr/bin/stress --verbose --vm 2
root 5 98.9 6.4 ... /usr/bin/stress --verbose --vm 2
root 6 99.0 0.4 ... /usr/bin/stress --verbose --vm 2
root 7 2.0 0.0 ... ps -aux
And on the host directly, via the PIDs we found from docker container top above:
[user@node ~]$ ps -aux | grep <PID>
USER PID %CPU %MEM ... COMMAND
root 5828 99.3 4.9 ... /usr/bin/stress --verbose --vm 2
centos 6327 0.0 0.0 ... grep --color=auto 5828
Kill off this container:
[user@node ~]$ docker container rm -f <container ID>
This is the right way to kill and remove a running container (not kill -9).
Run the same container again, but this time with a cgroup limitation on its CPU consumption:
[user@node ~]$ docker container run -d --cpus="1" training/stress:3.0 --vm 2
Do docker container top and ps -aux again, just like above; you’ll see the processes taking up half a CPU each, for a total of 1 CPU consumed. The --cpus="1" flag has imposed a control group limitation on the processes in this container, constraining them to consume a total of no more than one CPU.
Find the host PID of a process running in this container using docker container top again, and then see what cgroups that process lives in on the host:
[user@node ~]$ cat /proc/<host PID of containerized process>/cgroup
0::/system.slice/docker-...
[user@node ~]$ ls /sys/fs/cgroup/system.slice/docker-...
cgroup.controllers cgroup.type cpuset.cpus.effective hugetlb.2MB.events.local memory.events.local memory.swap.events pids.current
cgroup.events cpu.idle cpuset.cpus.exclusive hugetlb.2MB.max memory.high memory.swap.high pids.events
cgroup.freeze cpu.max cpuset.cpus.exclusive.effective hugetlb.2MB.numa_stat memory.low memory.swap.max pids.events.local
cgroup.kill cpu.max.burst cpuset.cpus.partition hugetlb.2MB.rsvd.current memory.max memory.swap.peak pids.max
cgroup.max.depth cpu.pressure cpuset.mems hugetlb.2MB.rsvd.max memory.min memory.zswap.current pids.peak
cgroup.max.descendants cpu.stat cpuset.mems.effective io.max memory.numa_stat memory.zswap.max rdma.current
cgroup.pressure cpu.stat.local dmem.current io.pressure memory.oom.group memory.zswap.writeback rdma.max
cgroup.procs cpu.uclamp.max dmem.low io.prio.class memory.peak misc.current
cgroup.stat cpu.uclamp.min dmem.max io.stat memory.pressure misc.events
cgroup.stat.local cpu.weight dmem.min io.weight memory.reclaim misc.events.local
cgroup.subtree_control cpu.weight.nice hugetlb.2MB.current memory.current memory.stat misc.max
cgroup.threads cpuset.cpus hugetlb.2MB.events memory.events memory.swap.current misc.peak
[user@node ~]$ cat /sys/fs/cgroup/system.slice/docker-a8559051e3503c2142a882423f551b9ab707de9c3ee20d3677e03d56dfa5dcf1.scope/cpu.max
100000 100000
200000 100000 means the cgroup can consume 200000 µs of CPU time per 100000 µs period—effectively 2 CPUs worth of CPU time.
Get a summary of resources consumed by processes in a control group via systemd-cgtop:
[user@node ~]$ systemd-cgtop
Path Tasks %CPU Memory Input/s Output/s
/ 68 112.3 1.0G - -
/docker - 99.3 301.0M - -
/docker/31d03... 3 99.3 300.9M - -
...
Here again we can see that the processes living in the container’s control group (/docker/31d03…) are constrained to take up only about 1 CPU.
Remove this container, spin up a new one that creates a lot of memory pressure, and check its resource consumption with docker stats:
[user@node ~]$ docker container rm -f <container ID>
[user@node ~]$ docker container run -d training/stress:3.0 --vm 2 --vm-bytes 1024M
[user@node ~]$ docker stats
CONTAINER CPU % MEM USAGE / LIMIT MEM % ...
b29a6d877343 198.94% 937.2MiB / 3.854GiB 23.75% ...
Kill this container off, start it again with a memory constraint, and list your containers:
[user@node ~]$ docker container rm -f <container ID>
[user@node ~]$ docker container run \
-d -m 256M training/stress:3.0 --vm 2 --vm-bytes 1024M
[user@node ~]$ docker container ls -a
CONTAINER ID IMAGE ... STATUS
296c8f76af5c training/stress:3.0 ... Exited (1) 26 seconds ago
It exited immediately this time.
Inspect the metadata for this container, and look for the OOMKilled key:
[user@node ~]$ docker container inspect <container ID> | grep 'OOMKilled'
"OOMKilled": true,
When the containerized process tried to exceed its memory limitation, it gets killed with an Out Of Memory exception.
Conclusion
In this exercise, you explored two core technologies behind containerization: kernel namespaces and control groups. The key takeaway is that containerized workloads are normal host processes with isolation and resource boundaries applied by the kernel.
2. Creating Images
By the end of this exercise, you should be able to:
-
Explain each step of Docker image build output
-
Inspect and reason about intermediate image layers and cache behavior
-
Distinguish between dangling layers and
<missing>entries in image history
Understanding Image Build Output
Make a folder demo for our image demo:
[user@node ~]$ mkdir demo ; cd demo
And create a Dockerfile therein with the following content:
FROM alpine:3.20
RUN apk update
RUN apk add --no-cache which
RUN apk add --no-cache wget
RUN apk add --no-cache vim
Build your image from your Dockerfile, just like we did in the last exercise:
[user@node demo]$ docker image build -t demo .
Examine the output from the build process. The very first line looks like:
Sending build context to Docker daemon 2.048kB
Here the Docker daemon is archiving everything at the path specified in the docker image build command (. or the current directory in this example). This is why we made a fresh directory demo to build in, so that nothing extra is included in this process.
The next lines look like:
Step 1/5 : FROM alpine:3.20
---> e34f9f...
Do an image ls:
[user@node demo]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
demo latest 59e595750dd5 10 seconds ago 46MB
alpine 3.20 e34f9f... 2 days ago 8MB
Notice the image ID for alpine:3.20 matches that second line in the build output. The build starts from the base image defined in the FROM command.
The next few lines look like:
Step 2/5 : RUN apk update
---> Running in 8734b14cf011
fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main/x86_64/APKINDEX.tar.gz
...
This is the output of the RUN command, apk update. The line Running in 8734b14cf011 specifies a container that this command is running in, which is spun up based on all previous image layers (just the alpine:3.20 base at the moment). Scroll down a bit and you should see something like:
---> 433e56d735f6
Removing intermediate container 8734b14cf011
At the end of this first RUN command, the temporary container 8734b14cf011 is saved as an image layer 433e56d735f6, and the container is removed. This is the exact same process as when you used docker container commit to save a container as a new image layer, but now running automatically as part of a Dockerfile build.
Look at the history of your image:
[user@node demo]$ docker image history demo
IMAGE CREATED CREATED BY SIZE
59e595750dd5 2 minutes ago /bin/sh -c apk add --no-cache vim 35MB
bba17f8df167 2 minutes ago /bin/sh -c apk add --no-cache wget 2MB
b9f2efa616de 2 minutes ago /bin/sh -c apk add --no-cache which 1MB
433e56d735f6 2 minutes ago /bin/sh -c apk update 0B
e34f9f... 2 days ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 2 days ago /bin/sh -c #(nop) ADD file:... in / 8MB
As you can see, the different layers of demo correspond to a separate line in the Dockerfile and the layers have their own ID. You can see the image layer 433e56d735f6 committed in the second build step in the list of layers for this image.
Look through your build output for where steps 3/5 (installing which), 4/5 (installing wget), and 5/5 (installing vim) occur - the same behavior of starting a temporary container based on the previous image layers, running the RUN command, saving the container as a new image layer visible in your docker image history output, and deleting the temporary container is visible.
Every layer can be used as you would use any image, which means we can inspect a single layer. Let’s inspect the wget layer, which in my case is bba17f8df167 (yours will be different, look at your docker image history output):
[user@node demo]$ docker image inspect bba17f8df167
Let’s look for the command associated with this image layer by using --format:
[user@node demo]$ docker image inspect bba17f8df167
[/bin/sh -c apk add --no-cache wget]
We can even start containers based on intermediate image layers; start an interactive container based on the wget layer, and look for whether wget and vim are installed:
[user@node demo]$ docker container run -it bba17f8df167 sh
[root@a766a3d616b7 /]# which wget
/usr/bin/wget
[root@a766a3d616b7 /]# which vim
/usr/bin/which: no vim in
(/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin)
wget is installed in this layer, but since vim didn’t arrive until the next layer, it’s not available here.
Managing Image Layers
Change the last line in the Dockerfile from the last section to install nano instead of vim:
FROM alpine:3.20
RUN apk update
RUN apk add --no-cache which
RUN apk add --no-cache wget
RUN apk add --no-cache nano
Rebuild your image, and list your images again:
[user@node demo]$ docker image build -t demo .
[user@node demo]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
demo latest 5a6aedc1feab 8 seconds ago 34MB
<none> <none> 59e595750dd5 23 minutes ago 46MB
alpine 3.20 e34f9f... 2 days ago 8MB
What is that image named <none>? Notice the image ID is the same as the old image ID for demo:latest (see your history output above). The name and tag of an image is just a pointer to the stack of layers that make it up; reuse a name and tag, and you are effectively moving that pointer to a new stack of layers, leaving the old one (the one containing the vim install in this case) as an untagged or 'dangling' image.
Rewrite your Dockerfile one more time, to combine some of those install steps:
FROM alpine:3.20
RUN apk update
RUN apk add --no-cache which wget nano
Rebuild using a new tag this time, and list your images one more time:
[user@node demo]$ docker image build -t demo:new .
...
[user@node demo]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
demo new 568b29a0dce9 20 seconds ago 24MB
demo latest 5a6aedc1feab 5 minutes ago 34MB
<none> <none> 59e595750dd5 28 minutes ago 46MB
alpine 3.20 e34f9f... 2 days ago 8MB
Image demo:new is much smaller in size than demo:latest, even though it contains the exact same software - why?
Conclusion
In this exercise, you explored how layered images are built and reused. Each Dockerfile step creates a layer that can be cached locally to accelerate subsequent builds, while pulled images like alpine:3.20 provide only the final image state and not your local intermediate build cache.
3. Basic Volume Usage
By the end of this exercise, you should be able to:
-
Create, inspect, mount, and remove Docker named volumes
-
Explain how volumes differ from the container layered filesystem
-
Use host path mounts for local development workflows
Using Named Volumes
Create a volume, and inspect its metadata:
[user@node ~]$ docker volume create demovol
[user@node ~]$ docker volume inspect demovol
[
{
"CreatedAt": "2018-11-03T19:07:56Z",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/demovol/_data",
"Name": "demovol",
"Options": {},
"Scope": "local"
}
]
We can see that by default, named volumes are created under /var/lib/docker/volumes/<name>/_data.
Run a container that mounts this volume, and list the filesystem therein:
[user@node ~]$ docker container run -it -v demovol:/demo alpine:3.20 sh
[root@f4aca1b60965 /]# ls
anaconda-post.log bin demo dev etc home ...
The demo directory is created as the mountpoint for our volume, as specified in the flag -v demovol:/demo. This should also appear in your container filesystem’s list of mountpoints:
[root@f4aca1b60965 /]# cat /proc/self/mountinfo | grep demo
1199 1180 202:1 /var/lib/docker/volumes/demovol/_data /demo
rw,relatime - xfs /dev/xvda1 ...
Put a file in this volume:
[root@f4aca1b60965 /]# echo 'dummy file' > /demo/mydata.dat
Exit the container, and list the contents of your volume on the host:
[user@node ~]$ sudo ls /var/lib/docker/volumes/demovol/_data
You’ll see your mydata.dat file present at this point in the host’s filesystem. Delete the container:
[user@node ~]$ docker container rm -f <container ID>
The volume and its contents will still be present on the host.
Start a new container mounting the same volume, attach a shell to it, and show that the old data is present in your new container:
[user@node ~]$ docker container run -d -v demovol:/demo alpine:3.20 ping 8.8.8.8
[user@node ~]$ docker container exec -it <container ID> sh
[root@11117d3de672 /]# cat /demo/mydata.dat
Exit this container, and inspect its mount metadata:
[user@node ~]$ docker container inspect <container ID>
"Mounts": [
{
"Type": "volume",
"Name": "demovol",
"Source": "/var/lib/docker/volumes/demovol/_data",
"Destination": "/demo",
"Driver": "local",
"Mode": "z",
"RW": true,
"Propagation": ""
}
],
Here too we can see the volumes and host mountpoints for everything mounted into this container.
Build a new image out of this container using docker container commit, and start a new container based on that image:
[user@node ~]$ docker container commit <container ID> demo:snapshot
[user@node ~]$ docker container run -it demo:snapshot sh
[root@ad62f304ba18 /]# cat /demo/mydata.dat
cat: /demo/mydata.dat: No such file or directory
The information mounted into the original container is not part of the container’s layered filesystem, and therefore is not captured in the image creation process; volume mounts and the layered filesystem are completely separate.
Clean up by removing that volume:
[user@node ~]$ docker volume rm demovol
You will get an error saying the volume is in use - docker will not delete a volume mounted to any container (even a stopped container) in this way. Remove the offending container first, then remove the volume again.
Mounting Host Paths
Make a directory with some source code in it for your new website:
[user@node ~]$ mkdir /home/ubuntu/myweb
[user@node ~]$ cd /home/ubuntu/myweb
[user@node myweb]$ echo "<h1>Hello Wrld</h1>" > index.html
Start up an nginx container that mounts this as a static website:
[user@node myweb]$ docker container run -d \
-v /home/ubuntu/myweb:/usr/share/nginx/html \
-p 8000:80 nginx
Visit your website at the public IP of this node, port 8000.
Fix the spelling of 'world' in your HTML, and refresh the webpage; the content served by nginx gets updated without having to restart or replace the nginx container.
Conclusion
In this exercise, you verified that volumes live outside a container’s layered filesystem, so their data is not captured by image commits and is managed independently of copy-on-write layers. You also saw how host path mounts enable rapid local iteration by making host-side file edits immediately visible in running containers.
4. Single Host Networks
By the end of this exercise, you should be able to:
-
Create and inspect Docker bridge networks
-
Attach containers to networks and validate service reachability
-
Read network metadata from containers and network objects
-
Explain veth pairs and namespace-scoped interfaces
-
Identify host-side interfaces and iptables rules created by Docker
Following Default Docker Networking
Switch to a fresh node you haven’t run any containers on yet, list your networks:
[user@node-1 ~]$ docker network ls
NETWORK ID NAME DRIVER SCOPE
7c4e63830cbf bridge bridge local
c87d2a849036 host host local
902af00d5511 none null local
Get some metadata about the bridge network, which is the default network containers attach to when doing docker container run:
[user@node-1 ~]$ docker network inspect bridge
Notice the IPAM section:
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "172.17.0.0/16",
"Gateway": "172.17.0.1"
}
]
}
Docker’s IP address management driver assigns a subnet (172.17.0.0/16 in this case) to each bridge network, and uses the first IP in that range as the network’s gateway.
Also note the containers key:
"Containers": {}
So far, no containers have been plugged into this network.
Have a look at what network interfaces are present on this host:
[user@node-1 ~]$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP qlen 1000
link/ether 12:eb:dd:4e:07:ec brd ff:ff:ff:ff:ff:ff
inet 10.10.17.74/20 brd 10.10.31.255 scope global dynamic eth0
valid_lft 2444sec preferred_lft 2444sec
inet6 fe80::10eb:ddff:fe4e:7ec/64 scope link
valid_lft forever preferred_lft forever
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN
link/ether 02:42:e2:c5:a4:6b brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 scope global docker0
valid_lft forever preferred_lft forever
We see the usual eth0 and loopback interfaces, but also the docker0 linux bridge, which corresponds to the docker software defined network we were inspecting in the previous step; note it has the same gateway IP as we found when doing docker network inspect.
Create a docker container without specifying any networking parameters, and do the same docker network inspect as above:
[user@node-1 ~]$ docker container run -d alpine:3.20 ping 8.8.8.8
[user@node-1 ~]$ docker network inspect bridge
...
"Containers": {
"f4e8f3f1b918900dd8c9b8867aa3c81e95cf34aba7e366379f2a9ade9987a40b": {
"Name": "zealous_kirch",
"EndpointID": "f9f246a...",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
}
}
...
The Containers key now contains the metadata for the container you just started; it received the next available IP address from the default network’s subnet. Also note that the last four digits of the container’s MAC address are the same as its IP on this network - this encoding ensures containers get a locally unique MAC address that linux bridges can route traffic to.
Look at your network interfaces again:
[user@node-1 ~]$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP qlen 1000
link/ether 12:eb:dd:4e:07:ec brd ff:ff:ff:ff:ff:ff
inet 10.10.17.74/20 brd 10.10.31.255 scope global dynamic eth0
valid_lft 2188sec preferred_lft 2188sec
inet6 fe80::10eb:ddff:fe4e:7ec/64 scope link
valid_lft forever preferred_lft forever
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
link/ether 02:42:e2:c5:a4:6b brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::42:e2ff:fec5:a46b/64 scope link
valid_lft forever preferred_lft forever
5: vethfbd45f0@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue
master docker0 state UP
link/ether 6e:3c:e4:21:7b:e2 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet6 fe80::6c3c:e4ff:fe21:7be2/64 scope link
valid_lft forever preferred_lft forever
A new interface has appeared: interface number 5 is the veth connection connecting the container’s network namespace to the host’s network namespace. But, what happened to interface number 4? It’s been skipped in the list.
Look closely at interface number 5:
5: vethfbd45f0@if4
That @if4 indicates that interface number 5 is connected to interface 4. In fact, these are the two endpoints of the veth connection mentioned above; each end of the connection appears as a distinct interface, and ip addr only lists the interfaces in the current network namespace (the host in the above example).
Look at the interfaces in your container’s network namespace (you’ll first need to connect to the container and install iproute):
[user@node-1 ~]$ docker container exec -it <container ID> sh
[root@f4e8f3f1b918 /]# apk add --no-cache iproute2
...
[root@f4e8f3f1b918 /]# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue
state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
4: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue
state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 scope global eth0
valid_lft forever preferred_lft forever
Not only does interface number 4 appear inside the container’s network namespace connected to interface 5, but we can see that this veth endpoint inside the container is getting treated as the eth0 interface inside the container.
Establishing Custom Docker Networks
Create a custom bridge network:
[user@node-1 ~]$ docker network create my_bridge
[user@node-1 ~]$ docker network ls
NETWORK ID NAME DRIVER SCOPE
7c4e63830cbf bridge bridge local
c87d2a849036 host host local
a04d46bb85b1 my_bridge bridge local
902af00d5511 none null local
my_bridge gets created as another linux bridge-based network by default.
Run a couple of containers named c2 and c3 attached to this new network:
[user@node-1 ~]$ docker container run \
--name c2 --network my_bridge -d alpine:3.20 ping 8.8.8.8
[user@node-1 ~]$ docker container run \
--name c3 --network my_bridge -d alpine:3.20 ping 8.8.8.8
Inspect your new bridge:
[user@node-1 ~]$ docker network inspect my_bridge
...
"IPAM": {
"Driver": "default",
"Options": {},
"Config": [
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
}
]
},
...
"Containers": {
"084caf415784fb4d58dc6fb4601321114b93dc148793fd66c95fc2c9411b085e": {
"Name": "c3",
"EndpointID": "8046005...",
"MacAddress": "02:42:ac:12:00:03",
"IPv4Address": "172.18.0.3/16",
"IPv6Address": ""
},
"23d2e307325ec022ce6b08406bfb0f7e307fa533a7a4957a6d476c170d8e8658": {
"Name": "c2",
"EndpointID": "730ac71...",
"MacAddress": "02:42:ac:12:00:02",
"IPv4Address": "172.18.0.2/16",
"IPv6Address": ""
}
},
...
The next subnet in sequence (172.18.0.0/16 in my case) has been assigned to my_bridge by the IPAM driver, and containers attached to this network get IPs from this range exactly as they did with the default bridge network.
Try to contact container c3 from c2:
[user@node-1 ~]$ docker container exec c2 ping c3
It works - containers on the same custom network are able to resolve each other via DNS lookup of container names. This means that our application logic (c2 ping c3 in this simple case) doesn’t have to do any of its own service discovery; all we need to know are container names, and docker does the rest.
Start another container on my_bridge, but don’t name it:
[user@node-1 ~]$ docker container run --network my_bridge -d alpine:3.20 ping 8.8.8.8
[user@node-1 ~]$ docker container ls
CONTAINER ID IMAGE ... STATUS PORTS NAMES
625cb95b922d alpine:3.20 ... Up 2 seconds competent_leavitt
084caf415784 alpine:3.20 ... Up 5 minutes c3
23d2e307325e alpine:3.20 ... Up 5 minutes c2
f4e8f3f1b918 alpine:3.20 ... Up 21 minutes zealous_kirch
As usual, it got a default name generated for it (competent_leavitt in my case). Try resolving this name by DNS as above:
[user@node-1 ~]$ docker container exec c2 ping competent_leavitt
ping: competent_leavitt: Name or service not known
DNS resolution fails. Containers must be explicitly named in order to appear in docker’s DNS tables.
Find the IP of your latest container (competent_leavitt in my case) via docker container inspect, and ping it from c2 directly by IP:
[user@node-1 ~]$ docker network inspect my_bridge
...
"625cb95b922d2502fd016c6517c51652e84f902f69632d5d399dc38f3f7b2711": {
"Name": "competent_leavitt",
"EndpointID": "2fdb093d97b23da43023b07338a329180995fc0564ed0762147c8796380c51e7",
"MacAddress": "02:42:ac:12:00:04",
"IPv4Address": "172.18.0.4/16",
"IPv6Address": ""
}
...
[user@node-1 ~]$ docker container exec c2 ping 172.18.0.4
PING 172.18.0.4 (172.18.0.4) 56(84) bytes of data.
64 bytes from 172.18.0.4: icmp_seq=1 ttl=64 time=0.083 ms
64 bytes from 172.18.0.4: icmp_seq=2 ttl=64 time=0.060 ms
The ping succeeds. While the default-named container isn’t resolvable by DNS, it is still reachable on the my_bridge network.
Finally, create container c1 attached to the default network:
[user@node-1 ~]$ docker container run --name c1 -d alpine:3.20 ping 8.8.8.8
Attempt to ping it from c2 by name:
[user@node-1 ~]$ docker container exec c2 ping c1
ping: c1: Name or service not known
DNS resolution is scoped to user-defined docker networks. Find c1 's IP manually as above (mine is at 172.17.0.3), and ping this IP directly from c2:
[user@node-1 ~]$ docker container exec c2 ping 172.17.0.3
The request hangs until it times out (press CTRL+C to give up early if you don’t want to wait for the timeout). Different docker networks are firewalled from each other by default; dump your iptables rules and look for lines similar to the following:
[user@node-1 ~]$ sudo iptables-save
...
-A DOCKER-ISOLATION-STAGE-1 -i br-dfda80f70ea5
! -o br-dfda80f70ea5 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o br-dfda80f70ea5 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
...
The first line above forwards traffic originating from br-dfda80f70ea5 (that’s your custom bridge) but destined somewhere else to the stage 2 isolation chain, where if it is destined for the docker0 bridge, it gets dropped, preventing traffic from going from one bridge to another.
Forwarding a Host Port to a Container
Start an nginx container with a port exposure:
[user@node-1 ~]$ docker container run -d -p 8000:80 nginx
This syntax asks docker to forward all traffic arriving on port 8000 of the host’s network namespace to port 80 of the container’s network namespace. Visit the nginx landing page at <node-1 public IP>:8000.
Inspect your iptables rules again to see how docker forwarded this traffic:
[user@node-1 ~]$ sudo iptables-save | grep 8000
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8000
-j DNAT --to-destination 172.17.0.4:80
Inspect your default bridge network to find the IP of your nginx container; you should find that it matches the IP in the network address translation rule above, which states that any traffic arriving on port tcp/8000 on the host should be network address translated to 172.17.0.4:80 - the IP of our nginx container and the port we exposed with the -p 8000:80 flag when we created this container.
Clean up your containers and networks:
[user@node-1 ~]$ ids="$(docker container ls -aq)"
[user@node-1 ~]$ if [ -n "$ids" ]; then docker container rm -f $ids; fi
[user@node-1 ~]$ docker network rm my_bridge
Conclusion
In this exercise, you stepped through Docker bridge networking from both operational and kernel-level perspectives, including Linux bridges, veth links, and iptables forwarding/isolation rules. In practice, containers need to share the same user-defined network to communicate directly, and explicit container names are required for predictable DNS-based service discovery.
5. Docker Compose
By the end of this exercise, you should be able to:
-
Start an application defined in a Compose file
-
Verify inter-service communication using DNS service names
Exploring the Compose File
Please download the DockerCoins app from Github and change directory to ~/orchestration-workshop/dockercoins.
[user@node ~]$ git clone -b ee3.0 \
https://github.com/docker-training/orchestration-workshop.git
[user@node ~]$ cd ~/orchestration-workshop/dockercoins
Let’s take a quick look at our Compose file for Dockercoins:
version: "3.1"
services:
rng:
image: training/dockercoins-rng:1.0
networks:
- dockercoins
ports:
- "8001:80"
hasher:
image: training/dockercoins-hasher:1.0
networks:
- dockercoins
ports:
- "8002:80"
webui:
image: training/dockercoins-webui:1.0
networks:
- dockercoins
ports:
- "8000:80"
redis:
image: redis
networks:
- dockercoins
worker:
image: training/dockercoins-worker:1.0
networks:
- dockercoins
networks:
dockercoins:
This Compose file contains 5 services, along with a bridge network.
When we start the app, we will see the service images getting downloaded one at a time:
[user@node dockercoins]$ docker-compose up -d
After starting, the images required for this app have been downloaded:
[user@node dockercoins]$ docker image ls | grep "dockercoins"
Make sure the services are up and running, as is the dedicated network:
[user@node dockercoins]$ docker-compose ps
[user@node dockercoins]$ docker network ls
If everything is up, visit your app at <node-0 public IP>:8000 to see Dockercoins in action.
Communicating Between Containers
In this section, we’ll demonstrate that containers created as part of a service in a Compose file are able to communicate with containers belonging to other services using just their service names. Let’s start by listing our DockerCoins containers:
[user@node dockercoins]$ docker container ls | grep 'dockercoins'
Now, connect into one container; let’s pick webui:
[user@node dockercoins]$ docker container exec -it <Container ID> sh
From within the container, ping rng by name:
[root@<Container ID>]# ping rng
Logs should be outputted resembling this:
PING rng (172.18.0.5) 56(84) bytes of data.
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=1 ttl=64 time=0.108 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=2 ttl=64 time=0.049 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=3 ttl=64 time=0.073 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=4 ttl=64 time=0.067 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=5 ttl=64 time=0.057 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=6 ttl=64 time=0.074 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=7 ttl=64 time=0.052 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=8 ttl=64 time=0.057 ms
64 bytes from dockercoins_rng_1... (172.18.0.5): icmp_seq=9 ttl=64 time=0.080 ms
Use CTRL+C to terminate the ping. DNS lookup for the services in DockerCoins works because they are all attached to the user-defined dockercoins network.
After exiting this container, let’s navigate to the worker folder and take a look at a section of worker.py:
[user@node dockercoins]$ cd worker
[user@node ~ worker]$ cat worker.py
import logging
import os
from redis import Redis
import requests
import time
DEBUG = os.environ.get("DEBUG", "").lower().startswith("y")
log = logging.getLogger(__name__)
if DEBUG:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
logging.getLogger("requests").setLevel(logging.WARNING)
redis = Redis("redis")
def get_random_bytes():
r = requests.get("http://rng/32")
return r.content
def hash_bytes(data):
r = requests.post("http://hasher/",
data=data,
headers={"Content-Type": "application/octet-stream"})
hex_hash = r.text
return hex_hash
As we can see in the last two stanzas, we can direct traffic to a service via a DNS name that exactly matches the service name defined in the Docker Compose file.
Shut down Dockercoins and clean up its resources:
[user@node dockercoins]$ docker-compose down
Conclusion
In this exercise, you started an application with Docker Compose and validated service-to-service communication through built-in DNS. The key concept is that Compose service names provide stable endpoints so application components can discover each other without custom service discovery logic.
6. Kubernetes basics
By the end of this exercise, you should be able to:
-
Explain the role of Pods, Deployments, Services, and Secrets
-
Apply declarative manifests with
kubectl -
Verify and access a simple Kubernetes application
Prerequisites
You need access to a Kubernetes cluster and kubectl configured for it.
Check your connection:
[user@node-1 ~]$ ssh -L 8080:127.0.0.1:8080 ubuntu@3.123.32.101 -i containerization-training/0/access
[user@node-1 ~]$ kubectl --kubeconfig /home/ubuntu/.kube/config -n headlamp port-forward --address 0.0.0.0 service/headlamp 8080:80
[user@node-1 ~]$ kubectl -n headlamp create token headlamp
[user@node-1 ~]$ kubectl config current-context
[user@node-1 ~]$ kubectl get nodes
Applying the Demo App
The demo manifest is located at resources/kubernetes-demo/simple-app.yaml and contains:
-
Namespace:
k8s-demo -
Secret:
hello-config -
Deployment:
hello-web(2 replicas) -
Service:
hello-web(ClusterIP)
Apply the manifest:
[user@node-1 ~]$ kubectl apply -f resources/kubernetes-demo/simple-app.yaml
Verify resources:
[user@node-1 ~]$ kubectl -n k8s-demo get deployment,pods,svc,secrets
[user@node-1 ~]$ kubectl -n k8s-demo rollout status deployment/hello-web
Explaining Kubernetes Objects in the Demo
-
Pod: runtime unit for one or more containers
-
Deployment: desired state and rollout controller for Pods
-
Service: stable virtual endpoint in front of the Pods
-
Secret: sensitive value injected into container environment
Inspect the Secret and Deployment wiring:
[user@node-1 ~]$ kubectl -n k8s-demo describe secret hello-config
[user@node-1 ~]$ kubectl -n k8s-demo describe deployment hello-web
Accessing the Application
Since hello-web is a ClusterIP service, forward it to your local machine:
[user@node-1 ~]$ kubectl -n k8s-demo port-forward service/hello-web 8080:80
Open http://localhost:8080 and refresh multiple times to see different Pod hostnames.
Rotating a Secret and Restarting Pods
Update the Secret value:
[user@node-1 ~]$ kubectl -n k8s-demo create secret generic hello-config \
--from-literal=WELCOME_MESSAGE='Hello after secret rotation' \
-o yaml --dry-run=client | kubectl apply -f -
Restart the Deployment so Pods pick up the updated environment value:
[user@node-1 ~]$ kubectl -n k8s-demo rollout restart deployment/hello-web
[user@node-1 ~]$ kubectl -n k8s-demo rollout status deployment/hello-web
Refresh http://localhost:8080 to verify the updated message.
Cleaning Up
[user@node-1 ~]$ kubectl delete namespace k8s-demo
Conclusion
In this exercise, you deployed a simple Kubernetes application and validated the core workflow: declare desired state in YAML, apply it with kubectl, verify rollout and service routing, and manage sensitive configuration through Secrets.