3-1: Images
It's time to start doing stuff with Docker. I'm a traditionalist when it comes to new tech, so we'll begin as always with "Hello, World!" In Docker, this means a container that does one thing and one thing only: say "Hello, World!" and prove Docker works.
We've used the terms "image" and "container" a few times now without properly defining them. In the world of containers, an image is a snapshot of a filesystem that contains what an application needs to run. A container is an image that is actively running. There's a bit more to it that we'll get to, but for now: containers are running images. Images are static. They don't change unless we update them. But containers are dynamic, containing any changes from the execution. Additionally, containers are ephemeral. They're meant to be created and destroyed easily, whereas images remain the constant base from which new containers will be run.
Let's see this in action. Start by confirming we have no images downloaded by running:
docker image ls
You should see a blank table.
REPOSITORY TAG IMAGE ID CREATED SIZE
Okay, nothing up our sleeves. Let's now attempt to run a container.
docker container run hello-world
Whoah! A whole lot just happened. But helpfully, the thing that happened explained itself! As the container itself reported, first, the "Docker client" (the command-line interface) contacted the "Docker daemon" (the running service that constitutes the "container runtime"), and queried it for the image hello-world
.
That's how the docker container run
command works—it takes an image name to launch as a container.
But we already demonstrated we didn't have any images—locally. So Docker reached out to its image repository, known as Docker Hub, to look for an image of that name. Once it found it, it downloaded the latest
version of that image and reported the image's SHA256 hash.
Then, Docker ran a new container from that image, and displayed its output.
And here we are.
Let's run docker image ls
again. Hey look at that! An image by the name of hello-world
has appeared. We now have the image locally, which means the next time we run a container from it, nothing needs to be downloaded.
Try it now. Re-run docker container run hello-world
.
See? Just the output, no preamble.
Images -> Containers
So where did those containers get to? Enterprising learners here might have already run docker container ls
and found nothing.
But here's the thing: docker container ls
only shows running containers, and our hello-world
containers have exited. If we want to see exited containers as well, we need to run:
docker container ls -a
A-ha! Here we go:
Here we can see two Exited
containers, one for each docker container run
we performed.
Each container has its own ID, like images. They also have a name, the command being run at launch, a created time, and a status. The name might look a little goofy. That's a default name given by Docker, but we can customize that, and many other choices about the container, with command line options. We'll see that shortly.
A More Useful Image
The hello-world
image is really just for demo purposes; we can't do anything useful with it. Let's go grab a base image that we can use.
docker image pull alpine:latest
What we've just pulled is an image based on Alpine Linux. Its small size and security focus make it an ideal base for many Docker projects. docker image ls
shows that it's only 7.34 MB! I promise that's much, much smaller than the Ubuntu image.
Inspecting
So what can we do with this thing? For starters, we can learn more about the image. Let's get a full readout of this thing's details with:
docker image inspect alpine:latest
That's a lotta JSON! At this point, we will want to install a handy tool to parse JSON on the command line: jq
.
sudo apt install -y jq
Once installed, send it the output of our Docker command.
docker inspect alpine:latest | jq
We get color, for starters, but that's hardly all. We also get the ability to slice the data for precisely the information we want. jq
is really a subject unto itself, but some basic slicing can really come in handy here. Let's learn how the image is configured to launch as a container.
docker image inspect alpine:latest | jq ' .[] | .ContainerConfig'
We should get back a subset of the larger JSON object, including the Cmd
key, an array of arguments like:
[
"/bin/sh",
"-c",
"#(nop) ",
"CMD [\"/bin/sh\"]"
]
The Cmd
array tells us that by default, Alpine will run /bin/sh
as its startup command. You can try it now, but there's kind of a catch.
docker container run alpine:latest
The container will run and exit instantaneously. To "capture" and interact with the launched shell before it exits, we'll need to pass some additional command line options to docker container run
. Specifically, we'll need:
-i
for interactivity-t
to create a virtual terminal device (tty
) to handle the interaction
You may notice that these options directly contradict some of the options we just read from ContainerConfig
. Good thing we can override them, huh?
Launching With Options
Let's try one more time, but with options!
docker container run -it alpine:latest
Yes, you can chain option flags like that.
Oh hey, something new happened! Our command prompt changed to a tiny lil root
prompt! Run hostname
to demonstrate we're in a new system now!
We're now in a shell within the container. We can further prove it by running hostname
, which will show a snippet of the full container ID.
You can also run ip a s
to see that the container has an IP address within Docker's own network (more on that later), and not the subnet we configured for our virtual machines.
When we run exit
, the container will stop, and we'll be back in our host's shell. And now, docker container ls -a
will show the exited container.
Containers -> Images
Before we move on from images, I want to demonstrate one way to create new images from our containers. Remember that running containers are simply an additional layer of changes on top of the base image. So if we can merge that layer with the base, we'll have ourselves a new image to start from.
Let's start by rerunning Alpine interactively.
docker container run -it alpine:latest
You might have noticed that the shell is /bin/sh
, not /bin/bash
. This image does not have Bash installed; that's how barebones it is! But we could make an Alpine image with that simple creature comfort by installing it in our container.
Alpine uses the apk
package manager. So to start, let's run apk
update to refresh the repos.
Then, we can run apk add bash
. Now we can run bash!
Exit out of bash
(if you ran it) and sh
, so we're back to our host.
Another run of docker container ls -a
shows our just-exited container. But this time, we're going to convert the container to an image with docker container commit
. This takes the container ID (or name) and the new name/tag of the image.
Image tags are after the colon in our image names. They allow us to differentiate versions of the same image type. So in our case, we'll use the bash
tag to differentiate from the normal Alpine image.
Got that container ID or name? Great. Run:
docker container commit <container_id> alpine:bash
Now, running docker image ls
will show a new image! We can run bash from this image, with:
docker container run -it alpine:bash /bin/bash
And there we go. We have a functioning bashified Alpine image!
This is not normally how we make new images—for any change more complicated than a simple package add, this process quickly gets onerous. Nevertheless, it demonstrates that images are based on layers of changes, and we can add layers of change introduced in containers.
In fact, images have a handy way to see the layers. Let's run docker image history
against our new image:
docker image history alpine:bash
What you'll see is a history of changes to the layers that make up the image. You'll see some odd commands like CMD
and ADD
. We'll see those again later, but those are specific build instructions used by Docker. Alpine doesn't have a lot of layers, but if you pull a heavier image like ubuntu
, you'll see quite a few.
Saving/Loading Images
We've already seen that we can grab images from Docker Hub (and other container registries), but is that the only way? Seems kinda...locked in.
Yeah, no, we don't have to rely on image repos to save and load images. docker image save
will export a TAR-formatted archive of an image—to stdout
by default, for some insane reason. So if we wanted to save out our new image, we'd do:
docker image save alpine:bash > alpine_bash.tar
docker image load
works in the reverse, including the use of stdin
. So to load our archive as an image, we'd do:
docker image load < alpline_bash.tar
(This avoids cat
abuse, which we always strive for.)
Up next, we'll dive deeper into running containers.
Check For Understanding
Explain the difference between an image and a container. Add a new package to an alpine:bash
container and create a new image from that container.