分类
devops

the input device is not a TTY

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” 一节