docker exec -it:为什么 cron 下不能用
从一次真实的报错说起
我在写一个定期把数据补回 ETCD 的脚本,第一版用了 docker exec -it。在交互式终端里跑得好好的,放到 cron 里就完全没效果——日志里只剩一行:
the input device is not a TTY
排查了一通才发现:-it 是给”人在终端前”准备的,cron 里既不该用,也不能用。下面把这背后的机制讲清楚。
一、先弄清 -i 和 -t 各自干什么
| flag | 全称 | 含义 |
|---|---|---|
-i |
--interactive |
保持容器内进程的 stdin 打开 |
-t |
--tty |
给容器内进程分配一个伪终端(PTY) |
合起来 -it 就是 “我要像 ssh 进容器那样操作它”。
二、伪终端(PTY)到底是什么
PTY 不是一根普通管道,它中间夹了一个行规约层(line discipline)。这个东西干这些事:
输入侧(你往容器里敲的字符):
– 缓存到行尾,要按回车才把整行发给程序
– 回显输入字符(你按 a,终端显示 a)
– 拦截 Ctrl+C、Ctrl+Z 这些控制字符,转成信号
– 把 \n 在某些模式下转成 \r
输出侧(程序打印出来的东西):
– 把单个 \n 转换成 \r\n(CRLF)
– 响应窗口大小变化(SIGWINCH)
PTY 在现代 Unix 系统里的物理形态,通常是 /dev/ptmx(master 端,主进程持有)+ /dev/pts/N(slave 端,容器里的程序持有)一对设备。
关键约束:master 端必须挂在一个有 controlling terminal 的进程上才能正常工作。如果父进程本身没有 TTY,分配出来的 PTY 就是”空壳”——容器内程序以为有终端,实际上 master 端没人接管。
三、cron 为什么”装不下” PTY
cron 启动子进程时的层级:
cron daemon(自己也没有 TTY)
└── /bin/sh -c /data/shell/your-script.sh
└── docker exec -it ...
└── 容器内的 etcdctl
每一层都没有 controlling terminal。当 docker 跑到 fork+exec 容器进程时,它没法找到一个可用的 TTY 设备给 master 端挂靠,于是抛出:
the input device is not a TTY
注意:这条错误是 docker client 在自己这边抛的,根本没走到容器内的进程——所以你看不到 etcdctl 的任何输出,连”OK”都没有。
不同 docker 版本行为略有差异:
| docker 版本 | 行为 |
|---|---|
| 较新版本(20.10+) | 报错退出,stderr 一行字 |
| 某些老版本 | 静默成功但 PTY 是空壳,行为不可预测 |
| 某些 patch 版本 | 偶尔成功偶尔失败,看环境心情 |
这正是 cron 里写 -it 最坑的地方:不可预测。同一份脚本,机器 A 能跑,机器 B 挂;今天能跑,明天挂。
四、PTY 行规约会”偷偷改”你的输出
即使在某些环境里 -t 没报错跑成功了,你的输出也已经被 PTY 处理过了:
# 容器内 etcdctl 真正输出的是
OK\n
# 经过 PTY 出来变成
OK\r\n
对于一行 "OK" 你可能感觉不到差异。但如果你的命令输出包含:
- 二进制数据(比如 etcd watch、kubectl logs)——PTY 行规约可能截断或损坏
- JSON / YAML ——大部分解析器能容忍多余
\r,但少数严格按字节比较的会挂 - 大量输出 ——PTY 有内核缓冲区,如果容器端 stdout 速率高过 master 端读取,会阻塞甚至丢数据
- 控制字符 ——
\x07(响铃)、\x1b(ANSI 转义)等可能被 PTY 解释或剥除
最常见的”莫名其妙解析失败”,根因往往就是 PTY 在中间插了一脚。
五、哪些场景该用、哪些不该用
✅ 该用 -it 的场景
# 你在终端前,想进容器交互
docker exec -it ubuntu bash
# 跑一个交互式 REPL
docker exec -it postgres psql
# 看带颜色的实时日志
docker exec -it app tail -f /var/log/app.log
判断标准:你人在终端前,需要提示符、行缓冲、Ctrl+C、颜色高亮。
❌ 不该用 -it 的场景
# cron / systemd timer
docker exec postgres psql -c "SELECT 1;"
# CI runner
docker exec app ./run-tests.sh
# 容器 entrypoint 脚本
docker exec backup ./dump.sh
# 任何不读 stdin、不需要 TTY 行为的命令
docker exec etcd etcdctl put foo bar
判断标准:程序不读 stdin,不需要提示符、行缓冲、终端控制字符。
🤔 真正需要思考的边界情况
-i 但不要 -t:有些脚本需要往 stdin 喂数据(比如 docker exec -i container mysql < schema.sql),这时只用 -i 就够了,加 -t 反而坏事(PTY 行规约会缓存输入到行尾,可能导致大批量数据卡住)。
-t 但不要 -i:基本不存在这种需求。
六、扩展场景清单:cron 之外的”非交互”环境
cron 只是冰山一角。下面这些场景和 cron 在本质上完全一样——执行命令时没有真实终端。每一种都要去掉 -it:
6.1 systemd timer / systemd service
[Service]
ExecStart=/usr/local/bin/my-script.sh # 里面跑 docker exec -it container ...
systemd 启动的 service 默认 StandardInput=null、不设置 TTYPath,没有 controlling terminal。
6.2 CI runner
# GitHub Actions / GitLab CI / Jenkinsfile
script:
- docker exec -it postgres psql -c "SELECT 1" # ❌ 会失败
CI runner 跑在容器或沙盒里,本身就没 TTY。CI 里配 docker exec -it 是高频错误。
6.3 SSH 非交互命令(特殊情况)
# ❌ 失败
ssh user@host "docker exec -it container bash -c 'whoami'"
# ✅ 正确:SSH 本身有 -t 强制分配 TTY
ssh -t user@host "docker exec -it container bash -c 'whoami'"
SSH 是特殊情况——SSH 客户端默认是 non-interactive(你只是传命令,不是开 shell),要加 -t 才强制要 TTY。这个 -t 和 docker exec 的 -t 是两层独立的 TTY 分配,任何一个缺失都会断。
6.4 容器内启动另一个容器
# 一个容器做 entrypoint,里面又跑 docker exec -it 启别的容器
FROM docker:dind
ENTRYPOINT ["sh", "-c", "docker exec -it myapp /run.sh"] # ❌
docker:dind 启动的容器默认没有 TTY 分配——除非你 docker run -it 显式要。
6.5 Kubernetes Pod 的 initContainer / sidecar
initContainers:
- name: setup
image: docker
command: ["docker", "exec", "-it", "redis", "redis-cli", "FLUSHALL"] # ❌
K8s 跑 Pod 时 stdin 默认关闭、tty 默认 false。就算你配了 stdin: true 和 tty: true,也只是给 Pod 自己的容器,不是给 docker exec 的目标容器。
6.6 Kubernetes CronJob
apiVersion: batch/v1
kind: CronJob
spec:
jobTemplate:
spec:
template:
spec:
containers:
- command: ["docker", "exec", "-it", "app", "./backup.sh"] # ❌
跟普通 cron 一个性质,但加上 K8s 又多一层封装(kubelet → CRI → containerd),TTY 不可能存在。
6.7 进程管理器(supervisord、s6、runit、pm2)
; supervisord.conf
[program:myapp]
command=docker exec -it myapp /run.sh # ❌
进程管理器启动的子进程自动 nohup + no TTY,PTY 分配失败。
6.8 后台 detach 的 shell
nohup ./script.sh & # ❌ script 里如果有 docker exec -it
disown ./script.sh # ❌
setsid ./script.sh # ❌
nohup 重定向 stdin 到 /dev/null,setsid 切新 session(脱离 controlling terminal),都和 cron 同性质。
6.9 screen / tmux detached 会话
tmux new-session -d -s mywork "docker exec -it app bash" # ❌
tmux -d 是 detached,server 在后台跑,会话里没有真实终端。除非你 tmux attach 上去再 docker exec。
6.10 Makefile 并行构建
test:
docker exec -it app go test ./... # ❌ make -j 并行时一定会挂
make 的每条命令都是 fork+exec,父进程 make 也没有 TTY(除非你在真终端里 make)。并行构建下 -it 必失败。
6.11 Ansible / Puppet / Chef 等配置管理工具
# ansible task
- name: run command in container
command: docker exec -it app ./setup.sh # ❌ Ansible 模块不会分配 TTY
这些工具的 command/shell 模块通过 SSH 或者本地 agent 执行命令,全程 non-interactive。
6.12 Web 应用的 request handler
// PHP / Python / Node 跑在 web server 里
shell_exec("docker exec -it mydb psql -c '...'"); // ❌ 极端不安全 + 必失败
不仅 -it 会失败,整个 web server 跑命令就是安全大坑,强烈不建议——独立写个 cron job 去做。
6.13 IDE / 编辑器的”后台任务”
VS Code 的 Tasks、JetBrains 的 External Tools 配置、Sublime 的 Build System:
// VS Code tasks.json
{
"command": "docker exec -it app /build.sh" // ❌ 跑在 IDE 的后台,没 TTY
}
6.14 自己写的 daemon / 长驻进程
# Python daemon
subprocess.run(["docker", "exec", "-it", "app", "echo", "hi"]) # ❌
daemon 化的进程标准三件套:fork + setsid + 关闭 stdin,TTY 不可能存在。
七、cron 下的”安全写法”清单
# ✅ 不要 -it
docker exec container etcdctl get /key
# ✅ 只在确实需要 stdin 喂数据时用 -i
docker exec -i container mysql < schema.sql
# ✅ 重定向要显式写,cron 下默认 stdout 可能丢
docker exec container etcdctl get /key > /var/log/etcd.log 2>&1
# ✅ 失败要检查退出码(cron 默认不检查)
docker exec container etcdctl get /key || {
echo "etcdctl failed at $(date)" >&2
exit 1
}
cron 脚本里默认就当”无终端、无 stdin、stdout 可能被吞、退出码必须自己看”来写,是最稳的。
八、最后用一张决策图收尾
你的命令会在"非交互"环境里执行吗?
(cron / systemd / CI / k8s / daemon / detached / 并行构建 / 配置管理 / ...)
│
├─ 是 ──> 命令需要读 stdin 吗?
│ ├─ 是 ──> 用 -i,但不要 -t
│ └─ 否 ──> 啥 flag 都不加
│
└─ 否 ──> 你人在终端前吗?
├─ 是 ──> 你需要交互式提示符/颜色/快捷键吗?
│ ├─ 是 ──> 用 -it
│ └─ 否 ──> 不加 flag
└─ 否 ──> (少见)啥 flag 都不加
一句话总结:docker exec -it 是”我在终端前要交互操作这个容器”的意思。任何非交互环境(cron、systemd、CI、k8s、daemon 等)都既不该用、也不能用——前者是规范,后者是物理约束(PTY 分配要 controlling terminal,这些环境都没有)。去掉 -it 是第一件事。
附录:我的实战教训
当时我排查这个 bug 花了不少时间,因为表面上看起来”和 -it 没关系”——脚本里其他命令也走
docker exec,就 put 那两条带-it,日志里还能看到c1=0、c2=0这些变量的赋值痕迹。我一度以为是 docker exec 本身在 cron 里有什么玄学,最后才注意到日志里夹了一行the input device is not a TTY,加上退出码非零(但我的脚本没检查退出码),就静默失败了。教训有两个:
1. cron 脚本必须显式检查每条命令的退出码,否则失败是”哑失败”,日志里也看不出
2.-it在交互终端是”惯用法”,搬到 cron 里就是”埋雷”,非交互环境一律不要带
进一步阅读
man 7 pty—— Linux 上 PTY 的内核层文档man 1 docker-exec—— docker exec 的 flag 说明- POSIX.1-2017 §11.1.3 —— “Pseudo-Terminals” 一节
