问题

由于把 Postgres 运行在 Docker 容器里,所以经常使用如下命令登录到数据库 docker exec -it -u postgres mypg psql mydb。然后在 psql 提示符下执行操作。然后经常会出现输入太长的 sql 语句时,psql 会在还没遇到终端宽度限制之前就开始换行,而且是把把输入回显换行到上面一行。这个行为说明了在容器内的 tty 的配置出现了问题。但是,很奇怪的是,退出 docker exec 之后,再次进入,就不会遇到这个问题了。

现象分析

这个问题看起来应该出在是 Docker 容器对于 tty size 的处理上。当出现这个问题的时候,通过 docker exec 进入容器的 shell,执行 stty size 会得到如下结果:

$ docker exec -it -u postgres postgres bash
bash-4.3$ stty size
stty: standard input
bash-4.3$ stty size 2> /dev/null
bash-4.3$ stty size > /dev/null
stty: standard input
bash-4.3$ stty -a
speed 38400 baud;stty: standard input
 line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R;
werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0;
-parenb -parodd cs8 -hupcl -cstopb cread -clocal -crtscts
-ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff
-iuclc -ixany -imaxbel -iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke
bash-4.3$ echo $LINES
24
bash-4.3$ echo $COLUMNS
80
bash-4.3$ stty rows 40 cols 100
bash-4.3$ echo $?
0
bash-4.3$ stty size
40 100

从上面的命令可以看出,stty 获取当前终端的窗口大小失败了,所以 psql 程序的回显就错乱了。不过,这里要注意的是,stty size 命令没有返回失败(返回值为0)。但是,通过 stty rows 40 cols 100 命令设置 tty 的参数确实可以成功的,说明问题可能是因为 tty 参数没有被正常初始化造成的。另外需要说明的是,这个问题不是必现的

这个问题在 Docker 项目的 issue 里被提到过,地址是 https://github.com/moby/moby/issues/33794。根据该 issue 的记录,目前该问题还没有找到原因。

关于这个问题,我不确定是 kernel 引起的,还是 shell 程序引起的,因为 termio 结构体会从内核拷贝到用户空间。

stty size 的返回值为什么是0?

我想知道为什么stty命令没有返回失败,所以去看了一下代码。我使用的是 Postgres Alpine 的镜像,安装的 shell 环境是 BusyBox,通过查看 BusyBox 的代码可以找到这里:https://github.com/mirror/busybox/blob/master/coreutils/stty.c#L899,对应的代码是:

static void display_window_size(int fancy)
{
	const char *fmt_str = "%s\0%s: no size information for this device";
	unsigned width, height;

	if (get_terminal_width_height(STDIN_FILENO, &width, &height)) {
		if ((errno != EINVAL) || ((fmt_str += 2), !fancy)) {
			perror_on_device(fmt_str);
		}
	} else {
		wrapf(fancy ? "rows %u; columns %u;" : "%u %u\n",
				height, width);
	}
}

这个函数获取当前窗口的大小,stty size命令会直接调用这个函数。这个函数继续调用了下面这个函数 https://github.com/mirror/busybox/blob/master/libbb/xfuncs.c#L263

/* It is perfectly ok to pass in a NULL for either width or for
 * height, in which case that value will not be set.  */
int FAST_FUNC get_terminal_width_height(int fd, unsigned *width, unsigned *height)
{
	struct winsize win;
	int err;
	int close_me = -1;

	if (fd == -1) {
		if (isatty(STDOUT_FILENO))
			fd = STDOUT_FILENO;
		else
		if (isatty(STDERR_FILENO))
			fd = STDERR_FILENO;
		else
		if (isatty(STDIN_FILENO))
			fd = STDIN_FILENO;
		else
			close_me = fd = open("/dev/tty", O_RDONLY);
	}

	win.ws_row = 0;
	win.ws_col = 0;
	/* I've seen ioctl returning 0, but row/col is (still?) 0.
	 * We treat that as an error too.  */
	err = ioctl(fd, TIOCGWINSZ, &win) != 0 || win.ws_row == 0;
	if (height)
		*height = wh_helper(win.ws_row, 24, "LINES", &err);
	if (width)
		*width = wh_helper(win.ws_col, 80, "COLUMNS", &err);

	if (close_me >= 0)
		close(close_me);

	return err;
}

这里的关键点就在于 get_terminal_width_height 函数的内部注释,即 ioctl 可能返回0,但是却没有获取到窗口大小。这种情况下,该函数返回 win.ws_row == 0,即 1。当 get_terminal_width_height 函数返回 1时,调用者 display_window_size 的处理逻辑如下:因为返回值不等于 EINVAL,所以直接调用 perror_on_device,这个函数会调用 libbb/perror_msg.c 中的 bb_perror_msg 函数,输出一个错误信息,然后就返回了(并不会导致程序错误退出)。随后,display_window_size 函数返回,stty size命令正常返回。

这个看起来是个 BusyBox 的 bug,所以有点怀疑是否是 Docker 和 BusyBox 的配合有问题,导致了bug。

解决办法

虽然没有找到原因,但是解决办法还是有的,就是在进入 shell 前显示的设定终端窗口的大小,通过指定 COLUMNSLINES 两个环境变量的方式,命令如下:

$ docker exec -it -u postgres -e COLUMNS=$(tput cols) -e LINES=$(tput lines) postgres bash
bash-4.3$ stty size
47 272

知识共享许可协议本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。