Tutorial  on  ContainersLinux

Docker Run, Attach, and Exec: How They Work Under the Hood (and Why It Matters)

You'll see the docker run command in every Docker tutorial or demo. It looks simple enough - docker run -it debian or even docker run nginx. But that simplicity is deceptive. What actually happens under the hood may surprise you.

The difference between Docker's attach and exec commands is a common source of confusion - and understandably so. Both commands have similar arguments and, at first glance, similar behavior. However, they aren't interchangeable. They're designed for different use cases, and their implementations differ accordingly. Still, it can be tricky to remember when to use which.

So let's break down what run, attach, and exec really do by looking at how they're implemented. This understanding will make it easier to remember the right tool for the job. And, like any real understanding, it frees you from rote memorization and gives you the power to apply the same logic to similar tools - like Podman, nerdctl, containerd, and even Kubernetes 😎

Docker architecture overview

First, a quick recap of the Docker architecture:

Docker: Layered Architecture with the high-level dockerd, mid-level containerd, and low-level runc runtimes.

Three takeaways that are particularly important for our discussion:

What does docker run command do

When you docker run a container, it may feel like you're starting a regular foreground process - the command blocks your current terminal session, and the containerized application's stdout and stderr are printed to your terminal. Anything you type in the terminal is sent to the containerized application's stdin, and if you hit Ctrl+C, accidentally or on purpose, the interrupt signal (SIGINT) is sent to the containerized application.

However, in reality, the docker CLI is just a (relatively thin) client of the dockerd daemon, so the docker run process is not even a parent process of the containerized application (and neither is dockerd). Instead, the docker run process establishes some elaborate client-server relay between your terminal and the containerized application, making it look like the containerized application is the foreground process.

Remember the layered container management architecture from the previous section? Here is what actually happens when you start a container with docker run:

terminal <-> docker <-> dockerd <-> containerd <-> shim <-> application (container)
docker run command under the hood: pulling the image, creating a container, attaching to the container, connecting the container to the network, and finally starting it.

Containerized application and stdio streams

Inside a container, there is a regular Linux process (or a few of them). However, if the foreground process in your terminal is docker run, the containerized application must always be a background, daemon-like process.

As every normal process, the containerized application has stdio streams: stdin, stdout, and stderr. Back in the day, when you started a process as a daemon (i.e., detaching it from the starter process), it would be reparented to PID 1, and its stdio streams would be simply closed. However, it's clearly not happening with Docker containers, allowing us to see the containerized application's stdout and stderr in our terminal, and even send data to its stdin.

Docker came up with a clever idea of putting an extra process between the container and the rest of the system, called a container runtime shim. In the docker run example above, the container manager actually starts a shim process, that in turn, uses an OCI-compatible runtime (e.g., runc) to start the actual container.

Container runtime shim is reparented to PID 1.

This is the shim process that becomes a daemon - it's reparented to PID 1 and its stdio streams get closed. At the same time, the shim takes the full control over the stdio streams of the containerized application. The daemonized shim process reads from the container's stdout and stderr and dumps the read bytes to the log driver.

💡 By default, the shim closes the container's stdin stream, but it can keep it open if -i was passed to the corresponding docker run command.

What does docker attach command do

Compared to docker run, the docker attach command is much less frequently used. However, it's still a useful tool to have in your toolbox because most production applications run in background containers, and attach allows you to re-connect to their stdio streams should you need to.

The container runtime shim component actually acts as a server. It provides RPC means (e.g., a UNIX socket) to connect to it. And when you do so, it starts streaming the container's stdout and stderr back to your end of the socket. It also can read from this socket and forward data to the container's stdin. Hence, you re-attach to the container's stdio streams.

So, if you started a background container using the docker run command with the -d|--detach flag like this:

docker run -d nginx

...you can re-attach to the container's stdio streams using the docker attach <container> command.

Here is what happens when you attach to a running container:

terminal <-> docker <-> dockerd <-> containerd <-> shim <-> container's stdio streams
docker attach command under the hood: connecting the current terminal session to the containerized application's stdio streams.

Difference between docker attach and docker logs

On the diagram above, docker attach streams the container's logs back to the terminal. However, the docker logs command does a similar thing. So, what's the difference?

The logs command provides various options to filter the logs while attach in that regard acts as a simple tail. But what's even more important is that the stream established by the logs command is always unidirectional and connected to the container's logs, not the container's stdio streams directly.

The logs command simply streams the content of the container's logs back to your terminal, and that's it. So, regardless of how you created your container (interactive or non-interactive, controlled by a pseudo-terminal or not), you cannot accidentally impact the container while using the logs command.

However, when attach is used:

What does docker exec command do

It's time to tackle the docker exec command. This command is frequently used in troubleshooting and debugging scenarios, when you need to execute a command or start an interactive shell inside of an already running container.

The exec command may resemble the attach command because you're targeting an existing container. However, in the case of attach, we are merely connecting our terminal to the containerized application's stdio streams (and starting forwarding the signals), while the exec command rather starts a new container, but kinda sorta inside of the existing container.

In other words, exec is a form of the run command 🤯

💡 Fun fact: The OCI Runtime Spec doesn't define run or exec commands. Check out the issues #345 and #388 for an interesting discussion of how the exec functionality is actually redundant and can be reproduced in runtimes implementing just create and start commands.

The trick here is that the auxiliary container created by the exec command shares all the isolation boundaries of the target container. I.e., the same net, pid, mount, etc. namespaces, same cgroups hierarchy, etc. So, from the outside, it feels like running a command inside of an existing container.

The confusion of attach and exec commands arises because the exec-uted command is also a process with its own stdio streams. So, you can choose whether to exec in detached mode, whether to keep the stdin open, whether to allocate a pseudo-TTY, etc. Also, when exec-ing, the relay looks quite similar to the one established by the attach command:

terminal <-> docker-cli <-> dockerd <-> containerd <-> shim <-> command's stdio streams
docker exec command under the hood: executing a new process in the already running container, sharing the same namespaces and cgroups.

How other runtimes implement run, attach, and exec

The above diagrams and examples were mostly about docker, but other container managers, such as containerd or nerdctl behave similarly when it comes to run, exec, or attach commands.

Podman is probably the most prominent example of a daemon-less container manager. However, even podman employs container runtime shims. There is just one less hop in the relay when you attach to a Podman's container.

Different ways to start containers (Docker, Podman, nerdctl, ctr).

An interesting specimen is Kubernetes. Kubernetes doesn't manage containers directly. Instead, every cluster node has a local agent, called kubelet, that in turn expects a compatible container runtime to be present on the node. However, on the lowest level, there are still the same shims and processes:

Containers in different runtimes: containerd (via nerdctl), Docker, and Kubernetes.

Much like docker, Kubernetes' command-line client (kubectl) also provides exec and attach commands with a similar UX. The difference is that Kubernetes operates in terms of Pods and not containers. Luckily, pods are just groups of semi-fused containers, so all the stuff we learned so far is still applicable.

Since attach-ing and exec-ing works on the container level, every kubectl attach and kubectl exec needs to specify the target container (-c <name>) in addition to the pod name. Unless the pod was annotated with kubectl.kubernetes.io/default-container, of course.

Kubernetes Container Runtime Interface (CRI): kubelet starts, stops, and executes commands in containers via an abstract CRI interface.

Summarizing

So, to summarize:

  • Containers are isolated and restricted execution environments.
  • Conventionally, there is one main process per container (but it can have a few children processes).
  • Containers are typically started in the detached mode (i.e., like daemons).
  • Container runtime shim wraps the container process and streams its stdout and stderr to logs.
  • Runtime shim allows attach-ing to it to wire the terminal with the container's stdio stream.
  • It's possible to start a container reusing the isolation primitives of an already running container.
  • The exec command is similar to the run command with all the namespaces and cgroups reused from another container.
  • Since exec-ing by default happens in the attached mode, it might look similar to the attach command, but its purpose and implementation are quite different.

Happy containerizing! 🐳

Level up your Server Side game — Join 11,000 engineers who receive insightful learning materials straight to their inbox