---
# System prepended metadata

title: run vs attach vs exec
tags: [Linux, Kubernetes, Cloud Native]

---

---
title: run vs attach vs exec
date: 2025-11-12 04:00:00
categories:
  - Container
tags:
  - Cloud Native
  - Kubernetes
  - Linux
code_block_shrink: false
---

[![hackmd-github-sync-badge](https://hackmd.io/q5J_T_HFSUCuINx62_v_5A/badge)](https://hackmd.io/q5J_T_HFSUCuINx62_v_5A)

如果用過 docker、podman 之類的 container 管理工具，一定看過 `run` `attach` `exec` 這三個子命令，也大概知道它們可以**跟容器互動**，但實際上差別在哪?

先來看一下 docker 啟動 container 時會用到的幾個 component 以及順序: 

```
docker-cli (docker client)
  ⭣
dockerd (docker daemon)
  ⭣
containerd (high-level runtime)
  ⭣
containerd-shim (runtime shim)
  ⭣
runc (low-level OCI runtime)
  ⭣
container (isolated and restricted Linux process)
```

如果是 podman 的話，因為是 **daemon-less** 所以會變成:

```
podman (daemonless container engine / CLI)
  ⭣
conmon (runtime shim)
  ⭣
runc / crun (low-level OCI runtime)
  ⭣
container (isolated and restricted Linux process)
```

# `run`

當我們使用下面這個指令去執行一個 Ubuntu container 時，整個體驗看起來很像是在啟動一個一般的前景行程 (foreground process): 這個指令會卡住 (block) 現在的終端機 session，container 應用程式的輸出 (包含 `stdout` 和 `stderr`) 會直接輸出在終端機上，且在終端機裡輸入的任何東西，會被 container 中的應用程式讀取 (`stdin`)。如果按下 `Ctrl+C`，那個中斷訊號 (`SIGINT`) 也會被送到 container 內的應用程式。

```shell!
docker run -it --rm ubuntu
```

但要是用 `ps`、`top` 或是 `htop` 觀察行程樹，可以發現這個 container 的父行程 (parent process) 既不是原本的 `docker run ...` 指令也不是 `dockerd`，而是 `containerd-shim-runc-v2`，也就是前面提到的 runtime shim

```shell
$ ps auxf
...
... /usr/bin/containerd
... /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
... sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
...  \_ sshd: root [priv]
...      \_ sshd: root@pts/0
...          \_ -bash
...              \_ docker run -it --rm ubuntu
... /usr/bin/containerd-shim-runc-v2 -namespace moby -id xxx -address /run/containerd/containerd.sock
...  \_ /bin/bash
...
```

甚至直接關閉執行 `docker run ...` 的終端機，這個 container 也不會被停止。

## 流程

`docker run` 是一個複合指令，它會在背後依序做幾件事: 拉 image、建立 container、把 container 跟網路連起來、啟動 container，最後再幫你把終端機 attach 上 container 的應用程式。實際執行細節會依照下的 flags 和 image 而略有不同，但流程大致相同。

從主機的角度來看，前景行程是 docker 這個 CLI，它跑在終端機 session 裡。當輸入 `docker run nginx:latest` 時，docker 並不是自己去建立容器，而是透過 UNIX socket 對 dockerd (docker daemon) 發送 HTTP REST API 請求。所以 CLI 只是客戶端，真正執行的是 dockerd。

dockerd 收到 run 的請求後，會再透過另一個 UNIX socket，用 gRPC API 呼叫 containerd 這個 daemon。containerd 則負責更低一層 (相較於 dockerd) 的 container 生命週期管理: 建立 container、準備 rootfs、設定 namespace 還有 cgroup 等等。

## runtime shim

當 container 要真的跑起來時，containerd 會為這個 container 啟動一個 runtime shim (Docker 是 containerd-shim，podman 則是 [conmon](https://github.com/containers/conmon))，它本身是一個背景行程。這個 shim 會去呼叫 OCI runtime (通常是 runc 或 crun)，依據 OCI spec 在主機上 `fork/exec` 出 container (隔離且被限制的行程)，並在裡面啟動像 nginx 這樣的應用程式。runc 完成後就直接退出，接下來由 runtime shim 接手，持續管理這個 container。

在這個過程中，被 reparent 到 PID 1 (init 行程，通常是 systemd) 的，是 shim，而不是 container 裡的應用程式本身: 

-  shim 會將自己轉為背景行程、reparent 到 PID 1，並將自己的 stdio stream 關掉
-  shim 接管被封裝的應用程式的 stdio stream (`stdin`、`stdout`、`stderr`)

整個 pipeline 裡，`stdin`、`stdout`、`stderr` 並不是直接從 container 連接到終端機，而是一路被轉接:

```
terminal session <-> docker-cli <-> dockerd <-> containerd <-> runtime shim <-> container
```

同樣地，像 `Ctrl+C` 這種訊號也是由 CLI 經過 daemon 一層層轉發到容器裡的主行程。

傳統把程式 daemonize (丟到背景、跟啟動它的終端機分離) 時，典型做法是: 

-  把它 reparent 到 PID 1 (init / systemd)
-  把它的 stdio stream 全部關閉

如果 container 也是這樣實作，就看不到它的輸出、更別說從終端機輸入資料。實際上，Docker 把這套行為用在 shim 身上，同時讓 shim 接管 container 的 stdio，於是才有現在的使用體驗: 看得到 log、可以送資料到 stdin，還能 attach 及 detach。

下面這個指令會在背景啟動一個 Ubuntu container:

```shell
docker run -it --rm -d --name ubuntu ubuntu
```

這行指令會很快結束，把控制權還給終端機。但我們可以用 attach 重新把終端機接回這個 container，讓它又看起來像前景行程: 

```shell
docker attach ubuntu
```

背後之所以做得到，就是因為 shim 一直在背景維護 container 的 stdio 與生命週期。

這個背景執行的 shim 會從 container 的 `stdout`、`stderr` 讀資料，再把讀到的資料丟給對應的 log driver (預設是 `json-file`，或自行設定的 driver)。

> 預設情況下，shim 會關閉容器的 `stdin`。但如果 `docker run` 有加 `-i`，shim 就會把 `stdin` 保持打開，這樣之後用 `docker attach` 時，才能把鍵盤輸入的資訊送到 container 裡的 process。

# `attach`

大部分 production 的應用程式都是跑在背景的 container 裡，當需要**臨時連進去**看看它目前在輸出什麼，或想直接把鍵盤輸入送進去時，`attach` 就是那條接回去的線。

前面提到 runtime shim 會接管 container 的 stdio streams。具體一點來說，shim 扮演一個伺服器的角色: 

- 在本機上提供一個 RPC 介面 (通常是 UNIX socket)
- 當有 client 連上這個 socket 時，shim 會開始把 container 的 `stdout`、`stderr` 內容往 socket 另一端串流回去
- 反過來也可以從這個 socket 讀資料，再轉送給 container 的 `stdin`

當你執行 `docker attach` 時，Docker CLI 就是扮演 client，透過 dockerd 一路接到 shim，於是你的終端機又重新連接到 container 的 stdio streams 上。

所以，如果一開始用 `docker run -d` 啟動一個背景 container，只要這個 container 還在、shim 還活著，都可以隨時用 `docker attach` 把終端機再接回去，讓它暫時又變得像前景行程一樣。

另外值得注意的是，`docker attach` 只能看到接回去後新產生的輸出，完整的歷史 log 則是由 shim 同步寫到對應的 log driver，可以用 `docker logs -f` 去查看。attach 比較像是把目前的 stdio 直接接到眼前，而 logs 則是讀取已經被保存下來的輸出紀錄。

# `exec`

`exec` 最常出現在排查和除錯的場景，例如已經有一個 container 在跑了，臨時想要: 

- 進去開個 shell 看環境
- 裡面跑個 `curl`、`ping`、`ps`、`top`
- 看某個檔案、查某個設定

這時通常會下:

```shell
docker exec -it CONTAINER bash

docker exec -it CONTAINER curl 127.0.0.1:80
```

乍看之下，`exec` 跟 `attach` 有點像: 兩者都是對一個已存在的 container 進行操作，但關鍵差別在於

- `attach` 只是把終端機**重新接回**原本的 container 應用程式的 stdio 上，外加訊號轉發
- `exec` 則會**新開**一個隱藏的輔助 container，在目標 container 內，再跑一個新的行程

所以 `exec` 其實更接近 `run`，而不是 `attach`。只是它不是重新建立一整個新 container，而是在既有 container 的那一組 namespaces 跟 cgroup 內再塞一個行程進去。

這邊補充一下，OCI Runtime Spec 其實並沒有定義 `run` 或 `exec` 指令。runtime 只需要會 `create` 跟 `start` 就夠了。`exec` 只是高階一點的便利功能——底層還是透過**再 create 一個臨時的 container，然後 start** 去實作。

`docker exec` 做的事情，可以想成: 

1. docker CLI 收到 `docker exec` 指令。
2. 一樣透過 HTTP / gRPC 把請求送到 dockerd / containerd。
3. containerd 透過 shim，要求 runtime 幫忙在目標 container 相同的 namespaces 跟 cgroups 裡，再起一個新 process
4. 這個新 process 有自己的 stdio streams，shim 再把它的 stdio 接到終端機上

# 小結

- `run`: 建立並啟動一個新的 container
- `attach`: 什麼都不新建，只是把終端機**重新接回**既有 container 的 stdio 上，並把輸入及訊號轉發進去
- `exec`: 在既有 container 的 namespaces 及 cgroups 裡再新開一個 process，並把終端機接到這個新 process 的 stdio 上，比較像是在 container 裡多跑一個指令

不管用的是 Docker、Podman，還是在 Kubernetes 上透過 `kubelet` 跟 `kubectl` 操作 container，它們的核心概念其實都大同小異:

- 一個負責接收 user 指令的前端 (CLI / API)
- 一個或多個負責管理 lifecycle 的 daemon / manager
- 一層 runtime shim 幫忙轉接 stdio、轉發 signal、維持生命週期
- 最底下那個依 OCI 規範啟動、實際在 kernel 上跑的 Linux process