Linux-软件开发者指南-全-
Linux 软件开发者指南(全)
原文:
annas-archive.org/md5/8d15e54c45f14609467c6cbf3a30c70c
译者:飞龙
序言
许多软件工程师对类 Unix 系统不熟悉,尽管这些系统在软件工程领域无处不在。不管开发者是否意识到,他们都需要在自己的工作环境(macOS)、软件开发过程(Docker 容器)、构建和自动化工具(CI 和 GitHub)、生产环境(Linux 服务器和容器)等方面与类 Unix 系统打交道。
精通 Linux 命令行可以帮助软件开发人员超越他们的基本工作要求,使他们能够:
-
通过了解何时使用内建的 Unix 工具,而不是编写成千上万行的脚本或辅助程序来节省时间
-
帮助调试复杂的生产故障,通常涉及 Linux 服务器及其与应用程序的接口
-
辅导初级工程师
-
更全面地理解他们编写的软件如何融入更大的生态系统和技术栈
我们希望本书中包含的理论、示例和项目能够将你的 Linux 开发技能提升到一个新水平。
本书适合人群
本书面向的是那些对 Linux 和命令行不熟悉或稍有遗忘并希望快速恢复技能的软件开发人员。如果你在凌晨 2 点面对生产服务器上的 Linux 命令行提示符时仍然感到有些不自信,这本书适合你。如果你想快速填补 Linux 技能的空白,以推动自己的职业生涯,这本书适合你。如果你只是好奇,想了解通过添加一些命令行技巧,你可以在日常开发设置和例程中提高多少效率,这本书同样会对你有所帮助。
本书不适合的人群
我们尝试通过非常仔细地选择书中内容来实现我们为这本独特实用书籍所设定的愿景。我们尽量剔除所有对你作为开发者的生活或对 Linux 及其核心抽象的基本理解无关紧要的内容。换句话说,这本书之所以有用,是因为我们省略了所有不必要的内容。
本书不是一门完整的 Linux 课程。它不是为 Linux 系统工程师或内核开发人员编写的。因此,它并不像 750 多页那样长,你应该能够在几天内完成它,可能是在工作中的某个安静的冲刺阶段。
本书内容
第一章,命令行工作原理,解释了命令行界面的工作方式,什么是 shell,并立即教给你一些基本的 Linux 技能。你将获得一些理论知识,然后开始在命令行中移动,查找并处理文件,并学习当遇到困难时如何寻求帮助。本章通过教授最重要的命令行技能,特别适合新手开发者。如果你什么都不读,光是读这一章,你也会比开始时更有信心。
第二章,处理进程,将带你参观 Linux 进程的工作原理。然后,你将深入学习用于处理进程的实用命令行技能。我们将详细讨论一些作为软件开发人员常遇到的与进程相关的问题,比如权限问题,并为你提供一些排错的经验法则。你还将简要了解一些高级主题,这些主题将在本书后续的章节中再次出现。
第三章,使用 systemd 管理服务,在上一章关于进程的知识基础上,介绍了一个额外的抽象层——systemd
服务。你将了解init
系统为操作系统所做的工作,以及为什么你应该关心它。然后,我们将介绍处理 Linux 系统中服务所需的所有实用命令。
第四章,使用 Shell 历史记录,是一个简短的章节,介绍了一些技巧,你可以通过它们提高在命令行上的速度和效率。这些技巧主要围绕使用快捷键和利用 Shell 历史记录来避免重复输入。
第五章,引入文件,将文件作为理解 Linux 的核心抽象进行介绍。你将了解文件系统层次结构标准(FHS),这就像一张地图,你可以用它在任何 Unix 系统中进行定位。接下来是一些实用命令,用于在 Linux 中处理文件和目录,包括一些你可能没有听说过的特殊文件类型。你还将了解如何搜索文件和文件内容,这是作为开发人员掌握的最强大技能之一。
第六章,在命令行中编辑文件,介绍了两个文本编辑器——nano 和 vim。你将学习如何使用这些文本编辑器进行命令行编辑,同时意识到常见的编辑错误及如何避免它们。
第七章,用户和用户组,将向你介绍用户和用户组的概念,它们构成了 Unix 安全模型的基础,用于控制对文件和进程等资源的访问。接着,我们将教授你创建和修改用户和用户组所需的实用命令。
第八章,所有权和权限,在前一章关于用户和用户组的基础上,向你展示了 Linux 中如何对资源进行访问控制。本章通过长列表的文件信息,教你有关所有权和权限的知识。然后,我们将探讨在生产 Linux 系统中常见的文件和目录权限,接着讲解修改文件所有权和权限的 Linux 命令。
第九章,管理已安装的软件,展示了如何在各种 Linux 发行版(甚至是 macOS)上安装软件。首先,我们介绍了包管理器,它们是将软件安装到机器上的首选方式:你将学习作为软件开发人员所需的包管理操作的重要理论和实用命令。然后,我们将介绍其他几种方法,如下载安装脚本和经过考验的 Unix 传统:从源代码本地编译自己的软件(其实并不像听起来那么可怕!)。
第十章,配置软件,基于前一章安装软件的内容,帮助你在 Linux 系统上配置软件。你将了解大多数软件会查找配置的地方(“配置层次结构”)。这不仅能在深夜排查故障时派上用场,实际上还能帮助你编写更好的软件。我们将涵盖命令行参数、环境变量、配置文件,以及如何在像 Docker 容器这样的非标准 Linux 环境中使用它们。甚至还有一个小小的附加项目:你将看到如何将一个自定义程序转变为一个systemd
服务。
第十一章,管道和重定向,将介绍可能是 Unix 的“杀手级功能”之一:通过管道将现有程序连接成定制解决方案的能力。我们将讲解你需要理解的前提理论和实用技能:文件描述符和输入/输出重定向。然后,你将进入使用管道创建复杂命令的实践。你将接触一些重要的 CLI 工具和实用的管道模式,这些在你完成本书后仍然会用到。
第十二章,通过 Shell 脚本自动化任务,提供了一门 Bash 脚本编程速成课程,教你如何从在交互式 Shell 中输入单个命令到编写脚本。我们假设你已经是一个软件开发人员,所以这将是一个快速的入门,展示核心语言特性,而不会花时间重新解释编程基础。你将学习 Bash 语法、脚本编写的最佳实践以及一些需要避免的重要陷阱。
第十三章,通过 SSH 实现安全远程访问,探讨了安全外壳协议(Secure Shell Protocol)以及相关的命令行工具。你将学习公钥加密(PKI)的基础知识,这对于开发人员来说总是非常有用,然后你将深入了解如何创建 SSH 密钥,并通过网络安全地登录到远程系统。你还将基于这些知识,获取一些通过网络复制文件的经验,使用 SSH 创建临时代理或 VPN,并查看涉及通过加密 SSH 隧道移动数据的各种其他任务的示例。
第十四章,使用 Git 进行版本控制,展示了如何通过命令行使用一个你可能已经非常熟悉的工具——git
——而不是通过 IDE 或图形客户端。我们将快速讲解 git 背后的基本理论,然后直接进入你在命令行环境中需要使用的命令。我们会介绍两个非常有用的功能——二分查找和变基——并给出我们关于最佳实践和有用的 shell 别名的建议。最后,穷人的 GitHub 部分展示了一个小型但实用的项目,帮助你练习并整合到目前为止学到的 Linux 技能。
第十五章,使用 Docker 容器化应用程序,为你提供了使开发人员能够轻松使用 Docker 的基本理论和实践技能。我们将探讨 Docker 解决的问题,解释最重要的 Docker 概念,并带你了解你将使用的核心工作流程和命令。你还将看到如何通过容器化一个真实应用程序来构建自己的镜像。由于我们从软件开发和 Linux 角度来处理这些内容,你还将培养出如何理解容器化技术的直觉,以及它与虚拟机的不同之处。
第十六章,监控应用程序日志,概述了 Unix 和 Linux 上的日志记录。我们将展示如何(以及在哪里)使用 systemd
在大多数现代 Linux 系统上收集日志,以及更传统的方法是如何工作的(你在现实中可能会遇到这两种情况)。你将掌握寻找和查看日志的实用命令行技能,并了解更大规模基础设施中日志记录的做法。
第十七章,负载均衡与 HTTP,介绍了面向开发者的 HTTP 基础知识,特别关注在较大基础设施中与 HTTP 服务打交道时所遇到的复杂问题。我们将纠正一些关于 HTTP 状态、HTTP 头信息和 HTTP 版本的常见误解,并讲解应用程序应如何处理这些内容。我们还会介绍负载均衡器和代理服务器在实际环境中的工作原理,以及它们如何使得对实际应用程序进行故障排查的体验与在笔记本电脑上排查开发版本的体验截然不同。到目前为止你学到的许多 Linux 技能将在这里派上用场,我们还会介绍一个新工具——curl
——帮助你排查各种 HTTP 相关的问题。
为了最大限度地发挥这本书的价值
如果你能让自己进入 Linux shell 提示符——例如通过在虚拟机中安装 Ubuntu 或将其作为 Docker 容器运行——你就可以跟随本书中的所有内容进行学习。
你甚至可以使用更少的资源——在 Windows 上,有 WSL,macOS 是一个真正的 Unix 操作系统,因此你在本书中学习的大多数实用命令(除非明确标注为仅限 Linux)都能直接使用。话虽如此,为了获得最佳体验,建议在 Linux 操作系统上进行操作。
获取本书所需的技能只是你作为软件开发者已经具备的基础计算机技能——编辑文本、操作文件和文件夹、了解“操作系统”的基本概念、安装软件以及使用开发环境。除此之外的内容,我们将会教你。
下载彩色图片
我们还提供了一份 PDF 文件,包含本书中使用的截图/图表的彩色图片。你可以在这里下载: packt.link/gbp/9781804616925
。
使用的约定
本书中使用了多种文本约定。
CodeInText
:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号名。例如:“-f
标志代表 ‘follow’,-u
标志代表 ‘unit’。”
命令行代码块如下所示:
/home/steve/Desktop# ls
anotherfile documents somefile.txt stuff
/home/steve/Desktop# cd documents/
/home/steve/Desktop/documents# ls
contract.txt
粗体:表示新术语、重要词汇,或你在屏幕上看到的词汇。例如,菜单或对话框中的词语以此形式出现在文本中。例如:“当文件设置为可执行时,Unix 会尽最大努力执行它,无论是成功(在ELF(可执行与可链接格式,如今最常用的可执行文件格式)情况下)还是失败。”
警告或重要提示以这种方式出现。
提示和技巧以这种方式出现。
与我们联系
我们始终欢迎读者的反馈。
一般反馈:请通过电子邮件发送至 feedback@packtpub.com
,并在邮件主题中提到书名。如果你对本书的任何部分有问题,请通过 questions@packtpub.com
向我们发送邮件。
勘误表:虽然我们已尽最大努力确保内容的准确性,但错误难免。如果你发现本书中的错误,我们非常感谢你能将其报告给我们。请访问 www.packtpub.com/submit-errata
,选择你的书籍,点击勘误提交表格链接并输入相关信息。
盗版:如果你在互联网上发现任何非法的本书复制品,请向我们提供相关的网址或网站名称。请联系 copyright@packtpub.com
并附上相关链接。
如果你有兴趣成为作者:如果你在某个领域拥有专业知识,并且有兴趣为书籍撰写或贡献内容,请访问 authors.packtpub.com
。
分享你的想法
一旦你阅读完《软件开发者的 Linux 指南》,我们非常希望听到你的想法!请 点击这里直接前往 Amazon 书籍评论页面,分享你的反馈。
你的评论对我们以及技术社区都非常重要,将帮助我们确保提供优质的内容。
下载本书的免费 PDF 副本
感谢你购买本书!
你喜欢随时随地阅读,但又无法随身携带纸质书籍吗?
你的电子书购买与所选设备不兼容吗?
不用担心,现在购买每本 Packt 书籍时,你都会免费获得该书的无 DRM PDF 版本。
随时随地,任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。
福利不仅仅如此,你还可以独享折扣、新闻通讯以及每天收到的精彩免费内容。
按照以下简单步骤即可享受这些福利:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781804616925
-
提交你的购买凭证
-
就这样!我们将直接把免费的 PDF 和其他福利发送到你的电子邮件。
第一章:命令行是如何工作的
在深入学习实际的 Linux 命令之前,你需要对命令行的工作原理有一个基本的理解。本章将为你提供这样的理解。
对于新手开发者,我们将探讨你开始使用 Linux 命令行所需的基本技能。对于有一些经验的人来说,仍然有一些细微的差别需要了解,比如“shell”和“命令行”之间的区别。了解这些差异很有价值!
在本章中,我们将涵盖以下主题:
-
命令行界面(CLI)的基本概念
-
命令的格式
-
命令参数是如何工作的,当你输入命令以及查阅文档时,它们是什么样子的。
-
介绍“shell”及其与“命令行”的区别
-
shell 用来查找命令的核心规则
首先,我们将从命令行界面的基本概念开始。我们将快速掌握 CLI 的工作原理,并通过一个简单的例子来加以演练。
一开始……是 REPL
什么是 命令行界面(CLI)?它是一个基于文本的计算机交互环境:
-
从你那里读取一些输入,
-
评估(或处理)输入,
-
响应并打印一些输出到屏幕,然后
-
循环回到开始,重复该过程。
让我们实际看看每一步发生了什么,以 ls
(列出目录内容)命令为例,稍后你会在几页后看到它。现在,了解 ls
命令列出目录内容就足够了。
步骤 | 含义 |
---|---|
1. 读取输入 | 你输入 ls 命令并按 Enter 键。 |
2. 评估命令 | shell 查找 ls 二进制文件,找到它后指示机器执行该命令。 |
3. 打印输出 | ls 命令会输出一些文本——它找到的所有文件和目录的名称——然后 shell 会将这些输出打印到你的终端窗口。 |
4. 循环回到第 1 步(重复该过程) | 当命令调用的程序退出后,通过接受更多的用户输入来重复该过程。 |
如果你再次阅读步骤 1-4,你会注意到每个步骤的第一个字母拼出了“REPL”,这是在那些发明并完善了这种工作流的语言(如 Lisp)中,常用来指代这种读取-评估-打印循环的方式。
用编程术语来说,你可以将上面的 REPL 指令翻译成代码:
while (true) { // the loop
print(eval(read()))
}
实际上,你可以使用大多数编程语言的几行代码创建一个能够进行基本计算的 REPL。以下是用 Perl 编写的单行“shell”程序:
perl -e 'while (<>){print eval, "\n"}'
1+2
3
在这里,我们将代码写作一个参数,只要有输入可以读取,就会打印出评估后的输出。最后,我们添加一个新行并退出。
这个程序很小,但足够在命令行环境中实现一个交互式读评打印循环——一个shell。你在 Linux 和 Unix 中使用的 shell 比这个 Perl 小 shell 要复杂得多,但原理是相同的。
重点很简单:作为开发者,你可能已经在不自觉中使用 REPL,因为几乎所有现代脚本语言都会带有一个。实际上,Linux(或者 macOS、或其他 Unix 系统)命令行就像解释型语言提供的“交互式 shell”一样。所以即使你不熟悉 Lisp REPL,前面的 Perl 代码片段也应该会让你想起一个非常基础的 Ruby 或 Python shell。
现在你已经理解了将要在 Linux 中使用的命令行接口的基本机制,你准备好尝试你的第一个命令了。为此,你需要了解正确的命令行语法。
命令行语法(阅读)
所有的 REPL 都是通过读取一些输入来开始的。在 Linux 命令行中,shell 读取的命令需要具有正确的语法。命令的基本格式如下:
commandname options
在编程术语中,你可以把命令名看作函数名,把选项看作传递给该函数的任意数量的参数。这一点很重要,因为并没有统一的语法来处理所有选项——每个命令都定义了它将接受哪些参数。正因如此,shell 对命令的正确性验证非常有限,除了检查命令是否映射到一个可执行文件。
注意
在本章中,“程序”和“命令”这两个术语可以互换使用。它们之间有一个非常细微的差别,因为一些 shell 内建命令是定义在 shell 代码中的,因此从技术上讲,它们并不是独立的程序,但你不需要担心这个区别——把这点留给 Unix 老手们去琢磨吧。
让我们深入探讨这个“命令 [选项]”语法的更复杂变体,你将会经常看到:
command [-flags,] [--example=foobar] [even_more_options ...]
这是你在大多数 Linux 环境中看到的帮助文档中的常见格式,例如程序手册页(manpages),它相当简单:
-
command
是你正在运行的程序 -
方括号中的项是可选的,带省略号的方括号(
[xyz ...]
)表示你可以在此传递零个或多个参数 -
-flags
表示该程序的任何有效选项(在 Unix 中称为“标志”),例如-debug 或 -foobar
有些程序还会接受参数的短版本和长版本,通常通过单短划线和双短划线来区分:例如 -l
和 --long
可能会执行相同的操作。然而,这种行为在命令之间并不一致;这种行为要求命令的创建者实现了短版本和长版本的参数,以设置相同的参数。
不是所有命令都会在调用时实现所有这些配置传递方式,但这些是你最常见的几种形式。
默认情况下,空格表示参数的结束,所以像大多数编程语言一样,包含空格的参数字符串必须使用单引号或双引号。你将在第十二章《使用 Shell 脚本自动化任务》中阅读更多相关内容。
在接下来的内容中,我们将跟随这一过程,了解 shell 如何解释你使用这种语法输入的命令,但首先,我们需要清楚地定义一下本章中我们使用的两个有时可以互换的术语:“命令行”和“shell”。
命令行与 shell
在本书中,我们提到的“命令行环境”是指任何作为 REPL 一种形式的文本环境,专门用于与操作系统、编程语言解释器、数据库等进行交互。“命令行”环境或界面描述了你与系统交互的一般方式。
但这里有一个更具体的术语,我们将在这里使用:shell。
shell 是一个特定的程序,它实现了这个命令行环境,并让你可以输入文本命令。技术上讲,有很多不同的 shell 提供相同类型的基于 REPL 的命令行环境,通常用于截然不同的目的:
-
Bash 是一个常见的 shell 环境,用于与 Linux 和 Unix 操作系统交互。
-
流行的数据库,如 Postgres、MySQL 和 Redis,都提供了 shell 供开发者与之交互并执行命令。
-
大多数解释型语言都提供 shell 环境,以加快开发速度。在这些环境中,有效的命令只是编程语言的语句。比如 Ruby 的
irb
,Python 的交互式 shell 等。 -
Zsh(Z shell)是另一种操作系统 shell(类似 Bash),你可能会在一些开发者的笔记本上看到它,尤其是当他们自定义了环境设置时。
当我们在本书中提到 shell 时,我们指的是 Unix shell(通常是 Bash),它是一个专门设计用来让你与底层 Linux 或 Unix 操作系统进行交互的命令行界面。
Shell 如何知道该运行什么?(评估)
在读取命令后,shell 需要对其进行评估,通过执行程序、获取一些信息或做其他对你实际有用的事情。
注意
这样详细描述 shell 工作原理可能一开始会显得冗长,但我们保证,当你需要解决因缺少或权限不正确的程序而产生的问题时,这些知识将派上用场。
当你在像 Bash 这样的 shell 中输入类似 foobar -option1 test.txt
的命令并按下 Enter 键时,以下几件事会发生:
-
如果命令中指定了路径,它将被使用。路径可以有多种形式:
-
一个完整的路径,比如命令
/usr/bin/foobar -option1 test.txt
中的/usr/bin/foobar
。 -
相对路径,例如在命令
./foobar-option1 test.txt
中的当前工作目录(.
表示当前目录,我们将在绝对路径与相对路径部分中讨论;此命令基本上表示“请执行我的当前目录中的“foobar”文件”)。 -
路径可能基于变量和符号,也可能是:
-
Shell 的环境(env vars)如
$HOME/foobar
,或者 -
由 shell 提供,例如
~/foobar
(~
字符表示“这个用户的主目录”)
-
-
-
如果没有,shell 将检查是否知道
foobar
的含义:-
它可能是一个内置的 shell 命令。
-
它可能是一个别名,用于设置命令的宏或快捷方式。
-
-
如果没有,shell 通常会查看
$PATH
环境变量,该变量包含几个不同的位置用于检查命令:/bin
、/usr/bin
、/sbin
等。用户可以向$PATH
列表添加位置,各种软件将修改您的$PATH
:脚本语言的版本管理器、Python 的虚拟环境以及许多其他程序大量使用这种机制。Shell 会按照$PATH
变量中找到的顺序尝试这些指定的位置,以查看是否有包含名为foobar
的可执行文件。
如果 shell 仍然找不到任何内容,它将返回一个错误,例如bash: foobar: command not found:
。
另一方面,如果在任何时候 shell 确实找到名为foobar
的可执行文件,它将执行该文件并将-option1
和test.txt
(按照顺序)作为参数传递。
此时,shell 知道要使用什么程序来评估命令,并执行。在评估命令时,将任何输出打印给用户,完成 REPL 过程的第三步。现在唯一剩下的就是回到开始并重新启动过程,接受用户的另一个命令作为输入。
Shell 尽最大努力猜测用户想要运行的程序,使用我们上面概述的一般过程来解决歧义。然而,歧义可能是一个坏事,会导致误解或错误。在故障排除过程中,您经常会想要找出实际运行的命令。为此,您可以使用命令which <command>
,它将打印完整路径(或正在运行的别名或脚本),并告诉您该命令是否是 shell 内置的。在某些系统上,可能无法使用which
命令。在这些情况下,您可以改用command –v
。这是 POSIX 的等效命令,我们将在下面学习它:
bash-3.2$ which ls
/bin/ls
bash-3.2$ command -v ls
/bin/ls
POSIX 的简单定义
维基百科告诉我们,“可移植操作系统接口(POSIX)是 IEEE 计算机学会指定的一系列标准,用于在操作系统之间保持兼容性。”从实际角度来看,它试图在 Unix 系统之间定义一些共同的标准,否则这些系统可能具有完全不同的基本命令集。
POSIX 基本上是这样说的:“每个符合 POSIX 的操作系统应该有一个叫做 ls
的列表命令”;在这个例子中,“每个符合 POSIX 的操作系统应该有一种方法来检查是否存在与给定命令名匹配的可执行文件。”
如果你的脚本需要在不同的 Unix 操作系统间移植,限制自己只使用 POSIX 命令是一个好方法。然而,这仍然不能完全保证成功——许多非常流行的 Linux 发行版在多个方面偏离了 POSIX 大多数情况下,直到遇到问题时你才会意识到这些差异。
理解 POSIX 是你开始进行命令行实际操作之前需要打下的最后一块基石。到目前为止,我们已经覆盖了很多内容:
-
你已经了解了 REPL(读取-求值-输出循环),并看到这一基本过程如何映射到所有现代 shell 的工作方式。
-
我们探讨了你在使用 Linux 时将会用到的基本命令语法。
你了解了 shell 如何决定如何正确处理你的命令输入并“评估”它。你学到了许多重要的术语,你会经常遇到这些术语:shell、命令行界面、POSIX,以及一些如果现在学习了将会带来好处的术语。有了这些知识,你已经准备好从理论进入实践。在接下来的部分,我们将讨论你在执行命令时所处的 Linux 特定环境。你将学习 Linux 文件系统的基本知识,以及不同类型路径的工作方式。之后,本章的其余部分将专注于运行 Linux 命令!
基本命令行技能
要高效使用 Linux,你需要知道一些最基本的知识:系统的结构、如何在系统中查找和移动、以及如何读取和编辑文件。在本节中,我们将涵盖这些内容,并帮助你熟悉 Linux 系统的基本导航。
在本书的其余部分,我们将深入探讨每一个主题和命令,但我们希望确保你在本章结束时掌握一套最基本、能正常运作的技能。
Unix 文件系统基础
在图形用户界面中,目录(在 macOS 中称为 文件夹)通过图标表示。也许你习惯了在你的主目录中看到这些小小整齐排列的图标——桌面、文档、视频等等。双击目录图标会打开一个新窗口,显示该目录内部的内容。
当我们使用“文件系统”这个术语时,我们指的正是这样——一个由目录和文件组成的集合,用来组织系统上的所有数据。在命令行环境中,底层的概念是完全相同的,只是它看起来有所不同。
你不会看到许多窗口和图标,一切都以文本的形式呈现,目录的内容只有在你请求时才会显示。然而,文件和目录依旧像你习惯的那样工作。
在你浏览文件系统时,刚开始时似乎很难记住它,但一旦你习惯了,这通常是处理计算机的更高效方式。经过几天的工作后,大多数人都能毫不费力地在工作时保持文件系统的详细视图,并且只偶尔验证这个视图。
绝对路径 vs. 相对路径
当初学者使用 Linux 时,他们常常被绝对路径和相对路径的区别所困扰。这个简单的误解导致了浪费大量时间盯着这样的错误:
No such file or directory
由于你需要理解路径,作为你运行几乎每个 Linux 命令的前提条件,我们将首先讲解路径。
绝对路径是指从根目录开始,到文件系统中任何文件的完整路径。你可以通过它以/
开始来识别这个路径,/
引用了根目录(文件系统的最顶部或开头,包含所有其他文件和目录)。
下面是一些绝对路径的例子:
-
/home/dave/Desktop
-
/var/lib/floobkit/
-
/usr/bin/sudo
这些绝对路径就像一整套驾车路线,从已知的起点(比如你的公寓,或者在 Unix 系统中是根目录)给出逐步指引。
你可以通过它以“/
”字符开始立即识别出绝对路径。无论你在文件系统中的哪个位置,绝对路径都会起作用,因为它们是完整的、唯一的文件对象地址。
相对路径是部分路径,默认假设它是从当前目录开始的,而不是从根目录开始的。你可以通过以下特点识别绝对路径:它不以/
字符开始。
相对路径就像是使用当前位置作为起点的驾车路线。如果你迷路了,想要新的路线,你希望得到的是从当前位置出发的路线,而不是从你的家庭地址开始的路线。相对路径正是提供了这一点。
结果是,相对路径通常更方便输入:如果你已经在/home/Desktop
目录下,引用文件时用mydocument.txt
比用/home/Desktop/mydocument.txt
要更简单(即使两种方式都是有效的,取决于你在文件系统中的位置)。真正的区别出现在你切换目录时。当你从/home/Desktop
上移到/home
时,绝对路径仍然引用相同的文件,而相对路径则不再引用(此时输入mydocument.txt
会引用/home/mydocument.txt
)。
想象一下这样的部分目录结构——在我们的例子中,我们假设这是/home/dave/Desktop
的目录树列表:
Desktop
├── anotherfile
├── documents
│ └── contract.txt
├── somefile.txt
└── stuff
├── nothing
└── important
你现在坐在这个桌面目录中;换句话说,你的当前目录(你可以通过运行pwd
命令看到)是/home/dave/Desktop
。
这里有一些相对路径示例,指向该桌面目录中的文件:
-
anotherfile
-
documents/contract.txt
-
stuff/important
以下是这些相同文件的绝对路径:
-
/home/dave/Desktop/anotherfile
-
/home/dave/Desktop/documents/contract.txt
-
/home/dave/Desktop/stuff/important
你会注意到,相对路径实际上只是一个绝对路径,去掉当前工作目录的路径部分。
绝对路径与相对路径复习
回想我们的示例:
Desktop
├── anotherfile
├── documents
│ └── contract.txt
├── somefile.txt
└── stuff
├── nothing
└── important
现在假设你在一个 shell 环境中,当前工作目录是 Desktop
目录。你想列出 contract.txt
文件。你该如何引用该文件?你有两个选项:
-
ls /home/dave/Desktop/documents/contract.txt
:这是绝对路径,可以在任何地方使用。 -
ls documents/contract.txt
:这是相对于当前目录的该文件的路径。
打开终端
在 Ubuntu Linux 和 macOS 上,你可以通过打开“Terminal”应用程序进入命令行提示符。
环顾四周 – 命令行导航
作为初学者,当你打开一个 shell 时,你首先要做的就是浏览一下系统。在本节中,我们将介绍最重要的命令,用于在 shell 窗口中浏览和查看 Linux 环境。
话虽如此,让我们深入了解一些基本的 Linux 命令吧!
pwd - 打印当前工作目录
pwd
代表“打印工作目录”,当你在终端中输入它时,shell 会打印出你当前所在的目录。Unix 文件系统通常被比作一棵树,但现在你可以把它当作一个凌乱的桌面,里面有很多目录。如果每个目录都像一个房间,pwd
让你看到当前命令行环境所在的房间。
新的 shell 会话通常从你的主目录开始。如果你在 Linux 上操作,它看起来可能是这样的:
 ~ pwd
/home/dave
如果你正在运行其他版本的 Unix,它可能会稍有不同。以下是在 macOS 上你会看到的内容:
 ~ pwd
/Users/dave
无论你在文件系统中的位置如何,你仍然可以引用所有目录中的文件(请参阅本章中的 绝对路径与相对路径 部分),但有时四处移动会更容易些。我们将在后面的章节中详细介绍文件系统结构。
ls - 列出
ls
让你“列出”目录中的文件。如果你不带任何参数运行此命令,它将列出当前目录中的文件和目录。如果你传入一个目录的路径作为参数,它会尝试查看该目录中的内容并列出来:
ls /var/log
List 也可以接受参数(“标志”)。有很多标志,但两个常用的标志是 -l
(“长格式”)和 -h
(“人类可读格式”)。
ls -l -h
# same thing; you can combine flags
ls -lh
# List a specific directory
ls -lh /usr/local/
长列表将产生以下输出格式:
-rw-r--r-- 1 dcohen wheel 0 Jul 5 09:27 foobar.txt
让我们逐列查看:
-
-rw-r--r--
:文件类型(第一个字符)和权限(三个三位数,分别表示拥有用户、拥有组和系统中其他所有用户的权限)。 -
1
:对该文件的引用次数(硬链接数量)。 -
dcohen
:拥有此文件的用户。 -
wheel
:拥有此文件的组。 -
0
:文件所占用的磁盘空间(此文件为空)。-h
标志将默认的字节数输出改为“人类可读”的形式,即在适当情况下显示为兆字节或吉字节。 -
Jul 5 09:27
:文件的修改时间。 -
foobar.txt
:文件名。
这会显示需要一些我们尚未涵盖的知识(用户、组和权限)的输出。没关系——我们会在第七章,“用户和组”中讲解。
移动位置
现在你已经学习了最基本的 Linux 命令来帮助你定位自己,接下来我们来聊聊如何在命令行环境中导航到你想去的地方。
cd – 改变目录
cd
让你可以“改变目录”,前往文件系统中的任何地方。借用之前的房间隐喻,这就相当于从当前房间传送到另一个房间。
成功改变目录后,pwd
命令将显示你新的(更新后的)位置:
bash-3.2$ cd /etc/ssl
bash-3.2$ pwd
/etc/ssl
bash-3.2$ ls
README cert.pem certs misc openssl.cnf private
bash-3.2$ cd certs
bash-3.2$ pwd
/etc/ssl/certs
find – 查找文件
find
允许你搜索文件。它是少数几个不遵循长选项(例如--name
)约定的命令之一。相反,它的标志是通过单个短划线指定的。以下是一个示例:
bash-3.2$ find / -type d -name home
/home
...
上述命令会在 /
(整个系统)中搜索一个名为 home
的目录(-type d
)。请记住,当你不是以拥有超级权限的 root(管理员)身份执行此操作时,find
可能没有权限列出许多目录的内容,因此你将收到类似 find: '/root': Permission denied
的输出,除此之外还会列出找到的内容。
另一个常见的用例是根据 find
输出执行命令:
bash-3.2$ find . -exec echo {} \;
.
./foobar
这将运行 echo
命令,并将找到的文件替换为 {}
。结果输出将类似于运行 ls
命令。
如果我们希望将每个找到的文件作为参数传递给echo
,而不是为每个文件运行echo
,可以用+
代替\
。
bash-3.2$ find . -exec echo {} +;
. ./foobar
find
还有许多其他标志。具体哪些标志可用,取决于你的操作系统所提供的 find
版本。
下面是一些典型的使用案例:
-
find -iname foobar
:搜索foobar
,忽略大小写 -
find -name "foobar*"
:搜索以foobar
开头的文件 -
find -name "*foobar"
:搜索以foobar
结尾的文件
阅读文件
现在你已经学会了如何找到你要的文件,接下来我们来看看如何在命令行中实际阅读文件内容。
less – 翻阅文件
less
允许你一次阅读文件的一“页”(基于你的终端窗口大小)。
less somefile.txt
运行 less
会打开文件,并允许你逐行(上下箭头键)或逐页(空格键)滚动查看。
要在文件内搜索,输入 /
,然后输入你的搜索字符串,按 Enter 键。用 n
(下一个)和 SHIFT-n
(上一个)来导航匹配项。
要退出,输入 q
。
修改操作
现在你可以查找和读取文件,接下来我们来看看如何修改文件或创建新文件。
touch – 创建一个空文件,或者更新现有文件的修改时间
touch
创建一个文件,因此需要一个文件路径作为参数。如果你提供的路径还不存在(并且假设你有权限),将在该路径下创建一个空文件。
如果指定路径下的文件已经存在,它的访问时间和修改时间将更新为当前时间。如果你只想更新访问时间或修改时间,可以分别使用 -a
或 -m
标志。
mkdir – 创建目录
mkdir
需要一个文件路径参数,并利用它来创建(“制作”)目录:
bash-3.2$ mkdir foobar
bash-3.2$ ls
foobar
可选地,如果你想创建多个目录,可以提供额外的参数:
bash-3.2$ mkdir foo bar baz
bash-3.2$ ls
foo
bar
baz
如果你想创建多个嵌套的目录(或者你只是想确保它们都存在),可以使用 -p
标志:
bash-3.2$ mkdir -p /var/log/myapp/error
bash-3.2$ ls /var/log/myapp
error
即使 /var/log/myapp
之前不存在,使用 mkdir
和 -p
标志也会确保创建 /var/log/myapp
,然后再在其中创建 /var/log/myapp/error
。另一方面,如果你给 mkdir -p
的路径中的某个目录已经存在,-p
不会对它造成任何影响,所以可以安全地多次运行(“幂等”)。这使得 -p
标志成为脚本使用中的标准。
rmdir – 删除空目录
rmdir
删除空目录。为了使此命令生效,目录必须为空,这意味着它是一个相对安全的命令。大多数 Linux 用户最终还是使用 rm
,因为它可以完成相同的任务。
rm – 删除文件和目录
要删除一个文件,请使用 rm
命令:
rm filename
在实践中,大多数人也使用 rm
来删除目录,因为与 rmdir
不同,它可以删除非空的目录。你需要使用 -r
标志来递归地应用命令(对你正在删除的目录包含的所有目录),并使用 -f
标志来“强制”删除,而不需要每次确认每个文件和目录:
rm –rf /path/to/directory
注意
在使用 rm -rf
时要非常小心,因为 Linux 允许你删除对系统操作至关重要的目录。例如,rm -rf /
告诉 rm
你希望删除根目录,根目录包含系统上的所有内容。
一些 Linux 发行版和 Unix 操作系统通过创新的方式解决了这个问题(Ubuntu 提供了一个带有--no-preserve-root
选项的rm
命令,作为一种询问“你确定要这样做吗?”的方式,而 Solaris 故意对rm
的功能做了宽松解释,以避免删除根目录)。实际上,这些保护措施很容易绕过。在使用rm
时要小心,并且在将命令从互联网上粘贴到你的 shell 中时要特别注意!
mv – 移动或重命名文件和目录
mv
是一个聪明的命令,因为它可以使用相同的语法做两种不同的事情。它可以将文件从一个目录“移动”到另一个目录,或者——另外——它可以重命名一个文件,并将其保留在同一目录下。
首先,我们将使用touch
命令创建一个文件:
bash-3.2$ touch foobar.txt
bash-3.2$ ls
foobar.txt
然后,我们将在原地重命名文件:
bash-3.2$ mv foobar.txt foobarbaz.txt
bash-3.2$ ls
foobarbaz.txt
请注意,上述命令会覆盖任何已存在的名为foobarbaz.txt
的文件,如果存在的话,所以在重命名时要小心。
要将文件移动到新目录,我们将创建一个新目录,然后将文件移到那里:
bash-3.2$ mkdir targetdir
bash-3.2$ mv foobarbaz.txt targetdir/
bash-3.2$ ls targetdir/
foobarbaz.txt
你还可以将这些操作结合起来。如果你想将文件移动到另一个目录同时重命名它,可以这样做:
bash-3.2$ mv foobarbaz.txt targetdir/renamed.txt
bash-3.2$ ls targetdir/
renamed.txt
获取帮助
除了最基本的环境之外,大多数环境都会提供手册页(manpages),它们是你可以用来学习(或记住)如何使用你可用的命令行程序的文档。
使用man $COMMANDNAME
来获取命令的信息。例如,man ls
将打印出类似以下内容:
LS(1) General Commands Manual LS(1)
NAME
ls – list directory contents
SYNOPSIS
ls [-@ABCFGHILOPRSTUWabcdefghiklmnopqrstuvwxy1%,] [--color=when] [-D format] [file ...]
DESCRIPTION
For each operand that names a file of a type other than directory, ls displays its name as well as any requested, associated
information. For each operand that names a file of type directory, ls displays the names of files contained within that directory,
as well as any requested, associated information.
If no operands are given, the contents of the current directory are displayed. If more than one operand is given, non-directory
operands are displayed first; directory and non-directory operands are sorted separately and in lexicographical order.
The following options are available:
-@ Display extended attribute keys and sizes in long (-l) output.
-A Include directory entries whose names begin with a dot ('.') except for . and ... Automatically set for the super-user
unless -I is specified.
由于手册页面会自动打开在一个分页程序中,滚动、搜索和退出都可以使用你习惯的less
命令中的快捷键。
请记住,man
是一个古老的工具,它尽力模仿一本实际的书籍,分为不同的章节,涵盖不同的主题。在上面的示例中,ls(1)
中的(1)
表示我们正在查看的手册章节。
有时,具有相同名称的手册页可能会存在于不同的章节中。要指定某个章节,可以在命令名称前添加数字。例如,要查看与上述相同的手册,你可以运行man 1 ls
。
大多数类 Unix 操作系统的手册章节如下:
-
常用命令,这些是你通常在命令行中运行的命令
-
系统调用
-
库函数,涵盖 C 标准库
-
特殊文件(通常是设备文件,位于
/dev
目录下)和驱动程序 -
文件格式和约定。这包括配置文件
-
游戏和屏幕保护程序
-
杂项
-
系统管理命令和守护进程
所以,如果你想深入了解本书中涉及的某个主题,你很可能会从查看手册页的第 1、5 和 8 章开始。
如果你不确定要查找的手册页面名称是什么,可以使用apropos <keyword>
或man -k <keyword>
来查找。它会打印出所有包含指定关键词的手册页面列表。
Shell 自动补全
如果你在一个交互式 Shell 会话中(即,不是从脚本执行或创建 Dockerfile),你可以使用Shell 自动补全,也称为Tab 补全,通过更少的按键和更低的拼写错误概率来构建命令。
要使用 Shell 自动补全,开始输入一个文件或目录名称并按Tab键。Shell 将逐步缩小你的选择范围,显示你输入行下方的可能匹配项。当根据你输入的内容,只剩下一个选项时,Shell 会自动完成该命令或参数,你可以按Enter键执行。让我们看一个示例。
如果你在 Linux 桌面系统的主目录下,界面可能看起来像这样:
 ~ pwd
/home/dave
 ~ ls
Desktop
Documents
Downloads
Library
Movies
Music
Pictures
Public
code
go
如果你想进入Documents
目录,你可以使用cd
(切换目录)命令来实现:
 ~ cd Documents
首先,输入cd D
并按Tab键:
 ~ cd D
Desktop/ Documents/ Downloads/
你会看到,Shell 已将十个可能的选项缩小为三个。再输入一个字母并按Tab键,你将看到只有两个匹配项:
 ~ cd Do
Documents/ Downloads/
输入另一个字母c
,这将把选择范围缩小到只有一个选项,再按一次Tab键即可自动完成目录名称:
 ~ cd Documents/
一旦你完成了目录名称的自动补全,你可以像平常一样按Enter键执行命令,或者继续在该目录内进行自动补全。例如,在这里按一次Tab键将重新开始在Documents
目录内的自动补全过程,保留Documents/
前缀,并自动完成斜杠右侧的有效项。直到你有一个有效路径并按下Enter键,Shell 的当前工作目录才会改变。
这个小技巧将为你节省大量的输入时间。越早开始使用它越好!
结论
在这一章中,你学习了在命令行上高效工作之前需要了解的所有基本理论。你看到了命令行语法的实际示例,并学习了大多数命令如何接受参数的基础知识。
我们还介绍了 Shell 的概念,并演示了在你输入命令并按下Enter键后,如何查找可执行文件。令人惊讶的是,许多高级用户并不完全理解这两个概念,这会影响他们快速高效地使用命令行环境。
最后,你已经学会了在命令行系统中移动的最重要基本命令。你几乎每次操作 Linux 系统时都会使用这些命令——它们代表了任何人在进一步深入学习之前都必须掌握的绝对基础。你甚至学会了第一个省时技巧——Shell 自动补全。
如果你正在跟随并尝试在一个真实的 Linux 系统上操作(你应该这么做!),确保在进入下一章之前练习几分钟你所学到的内容。我们将在本书的其余部分建立在这些知识之上。
在 Discord 上了解更多
要加入本书的 Discord 社区 —— 在这里你可以分享反馈、向作者提问,并了解新版本发布 —— 请扫描下面的二维码:
第二章:与进程打交道
作为开发者,你已经直观地熟悉了进程。它们是你劳动的成果:在编写和调试代码之后,你的程序最终执行,转变成一个美丽的操作系统进程!
在 Linux 中,进程可以是一个长时间运行的应用程序,也可以是像 ls
这样的快速 shell 命令,或者是内核用来在系统上执行某些工作的任何任务。如果在 Linux 中有任务在进行,那就是进程在执行它。你的网页浏览器、文本编辑器、漏洞扫描器,甚至像读取文件和你目前学到的命令等操作,都会启动一个进程。
理解 Linux 的进程模型非常重要,因为它所提供的抽象——Linux 进程——是你管理进程时所有命令和工具所依赖的基础。你不再看到从开发者角度出发的细节:变量、函数和线程都被封装为“一个进程”。你现在面对的是一组不同的、外部的控制按钮和监控仪表:进程 ID、状态、资源使用情况以及我们将在本章中涵盖的所有其他进程属性。
首先,我们将深入了解进程抽象本身,然后我们将深入探讨你可以用 Linux 进程做的一些有用的、实用的事情。在讨论实际操作时,我们会暂停并详细讲解一些常见问题的来源,例如权限问题,并为你提供一些排查进程问题的启发式方法。
在本章中,你将学习以下主题:
-
什么是 Linux 进程,以及如何查看当前系统上运行的进程
-
进程具有的属性,了解在排查问题时可以收集哪些信息
-
查看和查找进程的常用命令
-
更高级的话题,实际上对于编写作为 Linux 进程执行的程序的开发者非常有用:信号与进程间通信、
/proc
虚拟文件系统、使用lsof
命令查看打开的文件句柄,以及在 Linux 中进程是如何创建的。
你还将通过一个实际的故障排查示例回顾你在本章中学到的所有内容,使用我们讨论的理论和命令。现在,让我们深入了解 Linux 进程究竟是什么。
进程基础知识
当我们提到 Linux 中的“进程”时,我们指的是操作系统内部对正在运行的程序是什么的抽象模型。Linux 需要一个适用于 所有 程序的通用抽象,它可以封装操作系统关心的内容。进程就是这种抽象,它使操作系统能够跟踪正在执行的程序的一些重要上下文信息;即:
-
内存使用
-
使用的处理器时间
-
其他系统资源使用情况(磁盘访问、网络使用)
-
进程间通信
-
程序启动的相关进程,例如触发一个 shell 命令
你可以通过运行带有aux
标志的ps
程序,获取所有系统进程的列表(至少是用户允许查看的那些进程):
图 2.1:系统进程列表
本章将重点介绍与开发人员工作最相关的进程属性。
Linux 进程由什么组成?
从操作系统的角度来看,“进程”只是一个数据结构,使得访问类似以下信息变得简单:
-
进程 ID(PID,如上
ps
输出所示)。PID 1 是 init 系统——所有其他进程的原始父进程,负责引导系统。内核启动时,会首先执行此操作。当进程创建时,它会获得下一个可用的进程 ID,按顺序排列。由于其对操作系统正常功能至关重要,init 进程无法被终止,即使是 root 用户也无法终止。不同的 Unix 操作系统使用不同的 init 系统——例如,大多数 Linux 发行版使用systemd
,而 macOS 使用launchd
,许多其他 Unix 系统使用 SysV。无论具体实现如何,我们将根据其所扮演的角色称呼此进程:“init”。注意
在容器中,进程是命名空间化的——在“真实”环境中,所有容器进程的 PID 可能是 3210,而该单一 PID 映射到多个进程(
1..n
,其中n
是容器中运行的进程数量)。你可以从外部看到这一点,但不能在容器内部看到。 -
父进程 PID(PPID)。每个进程都是由一个父进程生成的。如果父进程在子进程存活时终止,子进程会变成“孤儿”进程。孤儿进程会被重新归属于 init(PID 1)。
-
状态(STAT,如上
ps
输出所示)。man ps
会显示一个概述:-
D – 不可中断的睡眠状态(通常是 IO)
-
I – 空闲内核线程
-
R – 正在运行或可运行(在运行队列中)
-
S – 可中断的睡眠状态(等待某个事件完成)
-
T – 被作业控制信号停止
-
t – 被调试器在跟踪过程中停止
-
X – 死亡状态(不应该出现)
-
Z – 僵尸进程(已终止,但其父进程未收养)
-
-
优先级状态(“友好度”——该进程是否允许其他进程优先于它?)
-
进程所有者(
ps
输出中的 USER 字段);有效用户 ID。 -
有效组 ID(EGID),表示当前使用的组 ID。
-
进程内存空间的地址映射。
-
资源使用情况——进程正在使用的打开文件、网络端口以及其他资源(在
ps
输出中,VSZ和RSS表示内存使用情况)。
(引用:来自Unix 和 Linux 系统管理手册,第五版,第 91 页。)
让我们仔细看看几个对开发人员和偶尔的故障排除人员来说最重要的进程属性。
进程 ID(PID)
每个进程都有一个唯一的进程 ID(PID),这是在进程启动时分配给它的唯一整数。就像关系型数据库中的 ID 唯一标识每一行数据一样,Linux 操作系统通过 PID 来跟踪每个进程。
PID 是你与进程交互时最有用的标签。
有效用户 ID(EUID)和有效组 ID(EGID)
这些决定了你的进程是以哪个系统用户和组身份运行的。用户和组权限共同决定了一个进程在系统上可以执行什么操作。
正如你在 第五章《文件介绍》中将看到的那样,文件有用户和组所有权,这决定了哪些权限适用于它们。如果文件的所有权和权限本质上是一个锁,那么具有正确用户/组权限的进程就像一把钥匙,打开锁并允许访问该文件。我们将在后面深入探讨权限部分时详细讨论这一点。
环境变量
你可能在应用程序中使用过环境变量 —— 它们是操作系统环境传递给进程的需要的数据。这通常包括配置指令(LOG_DEBUG=1
)和密钥(AWS_SECRET_KEY
),每种编程语言都有某种方式从程序的上下文中读取这些变量。
例如,这个 Python 脚本从 HOME
环境变量获取用户的主目录,然后打印出来:
import os
home_dir = os.environ['HOME']
print("The home directory for this user is", home_dir)
在我的案例中,在 Linux 机器的 python3
REPL 中运行这个程序,得到如下输出:
The home directory for this user is /home/dcohen
工作目录
进程有一个“当前工作目录”,就像你的 shell(反正也是一个进程)一样。在 shell 中输入 pwd
会打印当前工作目录,每个进程也都有一个工作目录。进程的工作目录是可以变化的,所以不要过于依赖它。
这部分内容概述了你应该了解的进程属性。接下来,我们将跳出理论部分,看看一些你可以立即使用的命令,帮助你开始处理进程。
用于操作 Linux 进程的实用命令
这里是一些你最常使用的命令:
-
ps
– 显示系统上的进程;你在本章前面已经看到过这个命令的示例。标志可以修改显示的进程属性列。这个命令通常与过滤器一起使用,以控制输出的内容,比如 (ps aux | head –n 10
) 只输出前 10 行。还有一些有用的小技巧:-
ps –eLf
显示进程的线程信息 -
ps -ejH
对于直观地查看父子进程之间的关系非常有用(子进程会在父进程下缩进)
图 2.2:带有标志的 ps 命令输出示例
-
-
pgrep
– 根据名称查找进程 ID。可以使用正则表达式。
图 2.3:带标志的 pgrep
命令输出示例
-
top
– 一个交互式程序,用来轮询所有进程(默认每秒一次)并输出资源使用的排序列表(你可以配置按什么排序)。还显示系统的总资源使用情况。按 Q 或使用 Ctrl + C 退出。你将在本章稍后的部分看到这个命令的输出示例。 -
iotop
– 类似于top
,但用于磁盘 IO。对于寻找 IO 消耗大的进程非常有用。并非所有系统默认安装,但可以通过大多数包管理器获取。
图 2.4:iotop
命令输出示例
nethogs
– 类似于top
,但用于网络 IO。按进程分组网络使用情况,非常方便。可以通过大多数包管理器获取。
图 2.5:
nethogs
命令输出示例
kill
– 允许用户向进程发送信号,通常是停止进程或让它重新读取其配置文件。我们将在本章稍后解释信号和kill
命令的使用方法。
高级进程概念与工具
这标志着本章“高级”部分的开始。虽然你不需要掌握本部分的所有概念就能有效地与 Linux 进程工作,但它们非常有用。如果你有一些额外的时间,我们建议至少熟悉一下每个概念。
信号
systemctl
是如何告诉你的 Web 服务器重新读取配置文件的?你如何礼貌地要求一个进程优雅地关闭?如果一个进程故障,如何立刻杀死它,因为它已经让你的生产应用瘫痪了?
在 Unix 和 Linux 中,所有这些操作都是通过信号来完成的。信号是可以在程序之间发送的数字消息。它们是进程之间和操作系统之间进行通信的一种方式,允许进程发送和接收特定的消息。
这些消息可以用来向进程传递各种信息,例如,指示某个特定事件已经发生,或某个特定的操作或响应是必需的。
信号的实际应用
让我们看几个信号机制所带来的实际价值的例子。信号可以用于实现进程间通信;例如,一个进程可以向另一个进程发送信号,指示它已完成某项任务,另一个进程可以开始工作了。这使得进程能够协调它们的行动,像编程语言中的执行线程一样顺畅高效地合作(但没有与之相关的内存共享)。
进程信号的另一个常见应用是处理程序错误。例如,设计一个进程来捕获 SIGSEGV
信号,这表明出现了段错误。当进程接收到此信号时,它可以捕获该信号并采取措施记录错误、生成核心转储以进行调试,或者在优雅地关闭之前清理正在使用的资源。
进程信号也可以用于实现优雅的关闭。例如,当系统关闭时,可以向所有进程发送信号,给它们一个机会保存状态并清理它们正在使用的资源,方法是通过“捕获”信号。
捕获
许多信号可以被接收它们的进程“捕获”:这本质上与编程语言中捕获并处理错误的想法相同。
如果接收信号的进程有一个信号处理函数,那么该处理函数会被执行。这就是程序在不重新启动的情况下重新读取配置,并在接收到关闭信号后完成数据库写入和关闭文件句柄的方式。
kill 命令
然而,不仅仅是进程通过信号进行通信:那令人毛骨悚然(并且在技术上讲,名称不准确)的 kill
是一个允许用户向进程发送信号的程序。
kill
命令通过用户发送进程中最常见的用途之一是中断一个不再响应的进程。例如,如果一个进程卡在了一个无限循环中,可以发送一个kill
信号迫使它停止。
kill
命令允许你通过指定进程的 PID 向进程发送信号。如果你想终止的进程的 PID 是 2600
,你可以运行:
kill 2600
该命令会向进程发送信号 15(SIGTERM
,或“终止”),进程有机会捕获该信号并进行正常关闭。
注意
从包含的标准信号编号表中可以看到,kill
发送的默认信号是“终止”(信号 15),而不是“杀死”(SIGKILL
是 9)。kill
程序不仅仅用于杀死进程,还用于发送任何类型的信号。这个名字起得真让人困惑,很抱歉——这只是 Unix 和 Linux 中一些特殊之处,你会逐渐习惯的。
如果你不想发送默认的信号 15,可以用一个短横线指定你想要发送的信号;例如,要向同一进程发送 SIGHUP
信号,可以运行:
kill –1 2600
运行 man
signal
会列出可以发送的信号:
图 2.6:man signal 命令输出示例
熟悉其中一些信号是有好处的——有时甚至在工程面试中也能派上用场:
-
SIGHUP
(1) – “挂断”:许多应用程序解释为“重新读取你的配置,因为我已经做了更改”(例如 nginx)。 -
SIGINT
(2) – “中断”:通常与SIGTERM
相同——“请干净地关闭”。 -
SIGTERM
(15)– “终止”:温和地请求进程关闭。 -
SIGUSR1
(30)和SIGUSR2
(31)有时用于应用程序定义的消息传递。例如,SIGUSR1 会要求 nginx 重新打开它正在写入的日志文件,如果你刚刚旋转了这些文件,这会非常有用。 -
SIGKILL
(9)–SIGKILL
无法被进程捕获并处理。如果将此信号发送给程序,操作系统会立即杀死该程序。任何清理代码,比如写入刷新或安全关闭,都不会执行,因此这通常是最后的手段,因为它可能导致数据损坏。
如果你想更深入地探索 Linux,可以随意浏览 /proc
目录。这确实超出了基础知识,但它是一个包含每个进程文件系统子树的目录,在你读取这些文件时,进程的实时信息会被查询出来。
/proc
实际上,这些知识在故障排除时会派上用场,特别是当你已经识别出一个行为异常(或神秘)的进程,并且想知道它在实时执行什么操作时。
你可以通过浏览其/proc
子目录并随便谷歌搜索,来了解很多关于进程的信息。
本章中展示的许多工具实际上使用/proc
来收集进程信息,并且只显示其中的一部分内容。如果你想查看所有信息并自己进行筛选,/proc
就是你需要查看的地方。
lsof – 显示进程打开的文件句柄
lsof
命令显示一个进程为读取和写入而打开的所有文件。这非常有用,因为程序只需要一个小的漏洞,就可能泄露文件句柄(它已请求访问的文件的内部引用)。这可能导致资源使用问题、文件损坏以及一长串奇怪的行为。
幸运的是,获取一个进程已打开文件的列表非常简单。只需运行 lsof
并传递 –p
标志和一个 PID(通常你需要以 root 身份运行)。这将返回该进程(在本例中是 PID 1589)打开的文件列表:
~ lsof -p 1589
图 2.7:使用 lsof -p 1589
命令列出的 1589 进程打开的文件示例
上述是 nginx Web 服务器进程的输出。第一行显示了该进程的当前工作目录:在这个例子中是根目录(/
)。你还可以看到它已打开自己的二进制文件(/usr/sbin/nginx
)和 /usr/lib/
中的各种库文件。
在下面,你可能会注意到一些更有趣的文件路径:
图 2.8:1589 进程进一步打开的文件
该列表包括 nginx 正在写入的日志文件和它正在读取和写入的套接字文件(Unix、IPv4 和 IPv6)。在 Unix 和 Linux 中,网络套接字只是一种特殊类型的文件,这使得在各种用例中使用相同的核心工具集变得容易 – 与文件相关的工具在几乎所有事物都被表示为文件的环境中非常强大。
继承
除了第一个进程init
(PID 1
),所有进程都是由父进程创建的,父进程基本上会复制自己,然后“fork”(分叉)该副本。当一个进程被 fork 时,它通常会继承其父进程的权限、环境变量和其他属性。
虽然可以防止并改变这种默认行为,但这有一定的安全风险:你手动运行的软件将获得当前用户的权限(如果你使用sudo
,则可能是 root 权限)。由该进程可能创建的所有子进程——例如在安装、编译等过程中——都会继承这些权限。
想象一个用 root 权限启动的 Web 服务器进程(这样它就可以绑定到网络端口),并且它的环境变量中包含云认证密钥(这样它就能从云端抓取数据)。当这个主进程派生出一个不需要 root 权限和敏感环境变量的子进程时,将这些信息传递给子进程是一个不必要的安全风险。因此,降级权限和清除环境变量是服务启动子进程时常见的模式。
从安全角度来看,牢记这一点非常重要,以防止如密码或访问敏感文件等信息泄露的情况发生。虽然本书不会详细讲解如何避免这种情况,但如果你在编写将在 Linux 系统上运行的软件时,意识到这一点是很重要的。
回顾——故障排除示例会话
让我们看一个故障排除的示例会话。我们所知道的只是某个特定的 Linux 服务器运行非常缓慢。
首先,我们需要查看系统上发生了什么。你刚刚学会了通过运行交互式的top
命令,可以查看系统上正在运行的进程的实时视图。现在我们来试试这个。
图 2.9:top 命令输出示例
默认情况下,top
命令按 CPU 使用率对进程进行排序,因此我们可以简单地查看列出的第一个进程,找到问题进程。的确,排名最前的进程使用了一个 CPU 可用处理时间的 94%。
通过运行top
命令,我们获得了几个有用的信息:
-
问题是 CPU 使用率,而不是其他资源争用。
-
产生问题的进程是 PID 1763,正在运行的命令(在COMMAND列中列出)是
bzip2
,这是一款压缩程序。
我们确定这个bzip2
进程在这里不需要运行,于是决定停止它。通过kill
命令,我们请求终止该进程:
kill 1763
等待几秒钟后,我们检查是否有这个(或其他)bzip2
进程正在运行:
pgrep bzip2
不幸的是,我们看到相同的 PID 仍在运行。是时候认真处理了:
kill –9 1763
这会指示操作系统终止该进程,并且不会允许进程拦截(并可能忽略)信号。SIGKILL
(信号#9)直接终止进程。
现在你已经杀死了那个有问题的进程,服务器再次平稳运行,你可以开始追查那个认为在这台机器上压缩大型源目录是个好主意的开发者。
在这个例子中,我们遵循了最常见的系统故障排除模式:
-
我们查看了资源使用情况(通过
top
命令查看)。这可以是我们讨论的任何其他工具,具体取决于哪个资源被耗尽。 -
我们找到了一个 PID 来进行调查。
-
我们按照这个流程操作。在这个例子中,无需进一步调查,我们发送了一个信号,要求它关闭(15,
SIGTERM
)。
结论
在本章中,我们深入了解了 Linux 对执行程序的进程抽象。你已经看到所有进程都有的共同组件,并学习了查找和检查正在运行的进程所需的基本命令。借助这些工具,你将能够识别进程何时出现问题,更重要的是,哪个进程出现了问题。
在 Discord 上了解更多
若要加入本书的 Discord 社区——在这里你可以分享反馈,向作者提问,了解新版本的发布——请扫描下面的二维码:
第三章:使用 systemd 进行服务管理
在上一章中,你学习了 Linux 中进程的工作原理。现在是时候看看这些进程如何被包装在一个额外的抽象层中:systemd 服务。
到目前为止你看到的命令——ls
、mv
、rm
、ps
等——都在前台运行,并且附属于你的 shell 会话。你运行它们,程序完成任务后退出。然而,并非所有程序都像这样运行。
服务,也常被称为守护进程,是在后台长时间运行的进程。这些可以是数据库和 Web 服务器,也可以是常规的系统服务,如网络管理器、桌面环境等。这些长时间运行的后台服务通常通过 init 系统(如 systemd)启动和控制。
init 在这里指的是操作系统内核启动的第一个进程,而这个进程的任务是负责启动其他所有进程。
systemd 服务通过名为 systemctl
的命令行工具进行控制。它用于启动和停止服务,例如,重启一个出现故障的服务或重新加载一个配置已更改的服务。
如果你在跳读本书,尚未阅读上一章,你仍然能从本章中获得价值。现在,只需将进程理解为任何正在运行的命令、应用程序或服务。当你准备好更详细地了解进程如何工作时,你可以阅读 第二章,处理进程。
在本章中,你将学习以下内容:
-
你将用来与
systemd
服务交互的命令:systemctl
-
更深入地了解初始化系统的作用,以及 systemd 如何特别担任这一角色
-
使用
systemctl
管理服务 -
一些在容器环境中工作的提示(如 Docker 容器),这些环境通常没有我们在本章描述的那种强大的服务管理层。
注意
本章仅适用于 Linux – macOS 和 Windows(甚至其他 Unix 系统)使用不同的工具来管理进程。实际上,不同的 Linux 发行版使用不同的工具,但 systemd
是最广泛使用的。虽然概念相似,但了解现代 Linux 环境如何管理服务对开发者而言最为有用。
基础知识
Linux 服务是运行在 Linux 系统上的后台进程,用于执行特定任务。它们类似于 Windows 服务或 macOS 上的守护进程。
大多数非容器化的 Linux 环境使用 systemd
来管理服务。你将使用两个工具来与 systemd
进行交互:
-
systemctl
:控制服务(在systemd
的术语中称为“单元”) -
journalctl
:让你与系统日志互动
我们将在本章中介绍 systemctl
,而 journalctl
将在 第十六章,监控应用日志 中详细介绍。
systemd
是一个针对 Linux 的系统和服务管理器,提供了一种管理服务的标准方式。它现在被广泛用作大多数 Linux 发行版的默认初始化系统。许多 Linux 发行版以前使用过来自 Unix 的 SysV 初始化系统,许多现代 Unix 操作系统仍在使用它。还有一些,比如 Alpine 和 Gentoo Linux,使用 OpenRC 作为它们的初始化系统。然而,有许多其他的初始化系统,但绝大多数 Linux 发行版现在都使用 systemd
。使用 systemd
,服务可以被启动、停止、重启、启用(设置为开机启动)或禁用,并且可以检查它们的状态。服务通过单元文件来定义,该文件明确指定服务应如何由 systemd
管理。
要通过 systemd
管理服务,你可以使用以下基本命令(我们将在本章稍后深入讲解每个命令):
-
systemctl start <service>
:启动一个服务。 -
systemctl stop <service>
:停止一个服务。 -
systemctl restart <service>
:重启一个服务。 -
systemctl status <service>
:显示服务的当前状态。
记住,只有具有 root 权限的用户(例如,使用 sudo
)才能通过 systemd
管理系统服务。
初始化
让我们稍作绕行,定义一个你经常会看到的常用术语。在 Linux 中,init
——即“初始化”的简称——是系统启动时首先启动的进程。毫不奇怪,你可以在 PID 1 找到它。init
负责管理启动过程,并启动系统上配置为运行的所有其他进程和服务。它还会重新接管孤儿进程(即原始父进程已死亡的进程),并将它们当作自己的子进程,以确保它们的正常运行。
像 Linux 世界中的几乎所有事物一样,有几个不同的、互斥的程序可以填补这个角色。它们统称为初始化系统,这是任何可以承担这一重要引导、初始化和协调角色的软件的通用名称。如前所述,Linux 上有多个可用的初始化系统,包括System V init(SysV)、OpenRC 和 systemd
。大多数现代 Linux 系统已经切换到了 systemd
,这也是我们在这里讨论的内容。
你使用的初始化系统将决定服务的定义和管理方式,因此请记住,这里讨论的内容仅适用于 systemd
。
进程和服务
让我们谈谈进程和服务之间的微妙差别。你可以把服务看作是围绕一块软件进行包装的一层,它让这块软件作为正在运行的进程更易于管理。
服务为程序(以及由该程序启动的进程)在系统中的处理方式增加了便利功能。例如,它让你能够定义不同进程之间的依赖关系,控制启动顺序,添加进程启动时的环境变量,限制资源使用,控制权限,及许多其他有用的功能。为了让这一切更加清晰,服务还为你的程序提供了一个简单的名称供引用。我们将在稍后的第十章,配置软件中向你展示如何创建你自己的服务。
在本章的其余部分,我们将专注于管理现有的服务。
systemctl 命令
systemctl
是你用来管理系统中已定义的服务的工具。这些示例将使用foobar
服务,虽然它并不存在,但我们用它代替你可能正在管理的任何服务。
检查服务状态
systemctl status <service>
检查服务的状态。你将获得一系列对各种故障排除任务有用的数据。这是 nginx web 服务器服务输出的样子:
图 3.1:nginx web 服务器服务输出
让我们逐行剖析该命令生成的密集输出信息:
-
服务名称:在服务的单元文件中定义的服务名称。
-
负载状态:服务单元文件是否已成功加载并准备好启动。
-
活动状态:服务的当前状态——是否正在运行、处于非活动状态或已失败——以及这种状态持续了多久。
-
文档:如果安装了相关文档,这是你可以找到的主页面。
-
主 PID 和子进程:与该服务相关的主进程的进程 ID(PID),以及任何已启动的子进程的附加条目。
-
资源使用:RAM(内存)和 CPU 时间。
-
CGroup:该进程所属控制组的详细信息。
-
日志预览:来自服务输出的几行日志,帮助你了解正在发生的情况。
这些信息提供了关于服务及其状态的详细概述,对于调试问题或检查服务健康状态非常有用。
如果服务失败,输出通常会提供失败的原因,例如退出代码或错误描述。
启动服务
systemctl start foobar
这会启动服务。如果服务已经在运行,执行此命令将没有效果。
停止服务
systemctl stop foobar
这会停止服务。如果服务没有运行,那么此命令应该没有效果。
重启服务
systemctl restart foobar
这会停止并重新启动服务。等同于运行:
systemctl stop foobar
systemctl start foobar
注意
小心使用此命令:如果服务的配置文件在启动后已经更改,并且该配置文件中存在一个错误导致程序无法成功启动,那么restart
将愉快地停止正在运行的服务,然后无法重新启动它。
这种逻辑但可能不太理想的行为多年来已经让许多开发者吃过亏,因此在重启之前,要确保你的服务配置仍然有效。
许多流行的程序都有内建的配置验证,例如,对于nginx
,你可以运行:
nginx –t
测试磁盘上的配置。
重新加载服务
systemctl reload foobar
不是所有服务都支持这个子命令——是否实现取决于创建服务配置的人。如果服务有reload
选项,通常比restart
更安全。
U
通常,reload
:
-
重新检查磁盘上的配置,以确保它有效
-
重新读取配置到内存中,如果可能的话,不中断正在运行的进程
-
只有在验证配置并确保进程在停止后能够成功启动的情况下,才会重新启动该进程
就像 Linux 中的许多东西一样,这是一种约定,而不是严格执行的要求,所以你可能会遇到一些软件:
-
没有实现
reload
子命令 -
没有实现上述讨论的一些安全功能(如配置验证等)
-
执行与
reload
相关的其他操作,因为开发者或打包者认为这是一个好主意
一般来说,在更新应用程序的配置文件时,特别是在生产环境中,你应该优先使用reload
而不是restart
。
启用和禁用
systemctl enable foobar
– 配置foobar
在启动时自动启动。systemctl disable foobar
– 如果foobar
已配置为自动启动,则取消该配置并将foobar
变成手动管理的服务。
这里的关键区别是,虽然start
和stop
会立即生效——它们确保服务现在正在运行(或已停止);而enable
和disable
则是关于未来系统启动的设置。然而,它们对你运行命令时服务的“运行”状态没有任何影响。
开发者常犯的一个错误是认为enable
会启动服务,但它不会。如果你想立即启动nginx
web 服务器,并确保每次虚拟机重启时它都会自动启动,你需要运行两个命令:
systemctl start nginx
systemctl enable nginx
因此,enable
和disable
带有一个可选的标志,也可以启动(或者在disable
的情况下停止)服务。此命令等同于上述两个命令:
systemctl enable --now nginx
Docker 注释
虽然systemctl
是传统 Linux 系统中管理服务的常用工具,但由于容器具有隔离和自包含的特性,通常在 Docker 容器中不使用它。
Docker 容器理想情况下运行一个单一的进程,因此不需要复杂的启动阶段或进程管理。容器本质上就是进程,并且无法访问宿主系统的初始化系统(包括systemd
)。
虽然在 Docker 容器中访问这些命令是可能的,但通常不建议在容器内使用任何类型的服务管理系统。
Docker 容器理想情况下包含一个应用程序,并在启动时启动一个进程。为此,无需服务管理——运行中的容器就是你的服务包,Docker 容器本质上就是你的进程。
我们不推荐包含多个进程或重要内部服务管理的 Docker 设置,所以我们在这里不做详细讨论:就像家庭一样,所有快乐的 Docker 镜像在某种程度上是相似的,而每个不快乐的 Docker 配置都有其独特的不快乐。
结论
在本章中,你了解了 Linux 中服务是如何管理的,并介绍了你将用来控制它们的实际命令。我们为你提供了理解所有你在实际系统中遇到的术语所需的理论:init 是什么,systemd 在 Linux 系统上做什么,以及你需要使用哪些命令与它交互。
在下一章中,我们将展示一些与 shell 和命令历史记录交互的实用技巧,帮助你节省时间,并像你最喜欢电影中的 Unix 大师一样(当然,这也会让你在日常工作中变得更快更高效,但这么说就没有那么有趣了)。
在 Discord 上了解更多
要加入本书的 Discord 社区——你可以在这里分享反馈、向作者提问并了解新版本发布——请扫描下方二维码:
第四章:使用 Shell 历史
要熟练掌握命令行,你必须定期使用它。没有捷径可以让你快速适应,但有一些高价值的技巧你可以尽早学习,它们将节省你的时间和避免挫败感。越早将这些技巧融入肌肉记忆,效果越好。
在本章中,你将学习如何利用 Shell 历史,避免重复输入已经执行过的命令。你还将看到如何通过 Shell 配置文件自定义 Shell 的行为或外观。最后,我们将展示在命令提示符下编辑和修改命令的最有用的快捷键。总之,本章将使你在命令行上变得飞快。
我们将通过以下主题来进行讲解:
-
Shell 历史
-
使用
!
执行之前的命令 -
跳转到行首或行尾
让我们从了解 Shell 历史开始。
Shell 历史
大多数 Shell 会保存你执行过的命令历史。这意味着你可以通过按箭头键查看你执行过的每一条成功命令:使用 上箭头 键回溯一条命令,使用 下箭头 键前进一条命令。通过这种方式浏览 Shell 历史非常有用,尤其是当你发现自己频繁执行类似的命令时。
请注意,你也可以编辑找到的命令:使用 左箭头 和 右箭头 来导航到命令所在的文本行,然后直接输入来编辑命令。
编辑过的命令会被添加到 Shell 历史的末尾(它不会实际修改历史中已保存的命令)。
这些技巧结合起来,可以让你轻松地回到并重新执行或修改之前的命令。
Shell 配置文件
我们将讨论的一些技巧需要更改 Shell 配置文件。工作流程通常是:
-
在你的 Shell 配置文件中修改你想要更改的选项。
-
保存文件。
-
打开一个新的 Shell 会话以查看更改。
-
对于已存在的 Shell 会话,可以通过运行命令
source
(执行)重新加载 Shell 配置文件:source ~/path/to/config/file
。
以下是最常见 Shell 的位置:
常见的 Shell | 位置 |
---|---|
Bash | ~/.bashrc 用于交互式会话,例如在图形环境中打开新终端窗口时获得的会话。如果你在工作机器上更改配置,通常需要这个配置文件。“交互式”指的是用户在终端中直接使用 Shell,而不是脚本执行的情况(例如,由 cron 任务自动调用的脚本)。当你在某种形式的终端中,手动输入命令时,你就处于“交互式 Shell”中。~/.bash_profile 用于登录 Shell —— 这可能是本地登录,也可能是通过 SSH 登录的会话。再次强调,这是与运行脚本时的 Shell 实例的对比。 |
Zsh(Z shell) | ~/.zshrc |
历史文件
不同的 shell 将历史记录文件保存在不同的位置,大多数 shell 都可以配置以更改该位置。默认情况下,你几乎总是使用 Bash,而 Bash 默认将其历史记录文件保存在 ~/.bash_history
。
如果你不确定在哪里找到 shell 历史文件,许多 shell 提供一个名为 HISTFILE
的配置选项,其中包含历史文件的位置。
这里,我正在检查历史文件的位置,同时运行 zsh
操作系统:
% echo $HISTFILE
/home/dcohen/.zsh_history
Bash 有两个配置选项,可以防止历史记录文件无限增长,以保持其大小可控,并使历史记录搜索更快速:
-
HISTSIZE
控制在内存中保留的最大历史记录数量。 -
HISTFILESIZE
控制 shell 会话之间保存的历史文件的最大大小。
如果你想增加 Bash 保留的历史记录数量,可以在 shell 的配置文件中增加前述的设置。
为此,打开 shell 的配置文件(例如,~/.bashrc
),并通过将以下几行附加到文件末尾来设置这些变量:
export HISTSIZE=1000
export HISTFILESIZE=5000
搜索 shell 历史记录
你经常会想要找出一周(或一个月)前运行的命令。这个命令可能会在历史记录中更久远的位置,为了到达那里,按 上箭头 成百上千次无疑是一种浪费时间的做法。如果你至少对要找的命令有一些线索,交互式的 shell 历史搜索就是你需要的技巧。下面是如何搜索你的 shell 历史:
-
按 CTRL + R 来激活
reverse-i-search
(反向搜索)。 -
输入你正在寻找的命令的一部分。
-
你的 shell 会尝试将你输入的字符与命令历史记录进行匹配,并找到最接近的、最近的匹配项。
-
重复按 CTRL + R 来浏览历史记录。按 ENTER 选择一个命令,或者按 Esc 退出此模式。
-
如果你不小心跳过了想要的命令,CTRL + SHIFT + R 将会搜索向前到下一个最近的匹配项。
例外情况
这个功能根据你使用的 shell 和配置有所不同,可能会有一些例外情况。
一些 shell 会忽略那些因错误(以非零退出代码退出)而失败的命令。许多 shell 也会忽略以空格字符开头的命令——这些命令不会被添加到 shell 的历史记录中。然而,在这两种情况下,只要你立即按下 (上箭头),通常仍然可以找到该历史记录条目,而不必执行其他任何命令。
使用 !
执行之前的命令
执行之前的命令是通过感叹号来实现的。有多种方法可以使用这个技巧,下面我们来看看。
使用相同的参数重新运行命令
!
命令将执行最后一个命令并带上先前的参数。例如,!ssh
将回溯并找到你上次运行的 ssh
命令,并使用相同的参数重新执行它。你可以用它来重新运行你经常使用的命令,并保持相同的参数,例如快速重新连接到你每天连接的 SSH 服务器。
在历史命令中添加前置命令
!!
命令将执行你上次运行的命令,但会在它前面加上其他命令。这听起来可能有些奇怪,但在你不小心运行了一个需要 root 权限的命令,而没有在前面加上 sudo
时,它非常有用。
apt-get install nginx # fails with a permission error
sudo !!
# this is the command that runs:
sudo apt-get install nginx
如果前一个命令因权限不足失败,只需运行 sudo !!
就能重新执行该命令,并在开头加上 sudo
。
注意
为了安全起见,不要将此作为自动习惯:始终确保你知道为什么一个命令需要更多的权限,并问自己是否信任它,愿意让它在你的系统上做任何事情。对 sudo
的不小心使用可能会导致轻易破坏系统,或让攻击者在系统上占据一席之地。
跳到当前行的开头或结尾
在编辑时跳到行的开头并不罕见,也许是为了纠正命令的拼写或添加一个必需的参数。要做到这一点,请按 CTRL + A。
同样地,要跳到行的末尾,请使用 CTRL + E。
这两个快捷键会非常有用。
结论
在 Linux shell 环境中工作需要大量打字。即使是最小的提高你的速度和准确性,也能让你在构建和编辑命令时感到不再是基本任务拖延的永恒感,而是像经验丰富的 Unix 大师一样飞速前行。
本章分享的技巧是我们日常工作中使用的最常见且最强大的快捷键之一。将你新的命令历史搜索技能与编辑和命令修改快捷键结合使用,将大大提高你在命令行上的舒适度、效率和速度。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈,向作者提问并了解新版本——请扫描下面的二维码:
第五章:文件简介
在 Linux 中,一切都是——或者可以表示为——一个文件。文件被组织成一个文件系统,文件系统其实就是一个由文件和目录(目录只是特殊类型的文件)组成的层级结构。作为开发人员,你在 Linux 系统上几乎做的每一件事都需要了解文件:编写和复制源代码、构建 Docker 镜像、应用日志、配置依赖关系等。
在这一章中,我们将详细介绍 Linux 中的文件。你将了解纯文本文件和二进制文件之间的区别,这两种文件内容类型是你最常接触的。我们会向你展示这些文件如何在 Linux 中组织成一个文件系统“树”,然后再深入介绍你需要用到的命令,帮助你创建、修改、移动和编辑文件。接着,我们将通过介绍最常用的命令行文本编辑器,完成文件编辑的基本知识讲解。
然而,在本章中,我们不仅仅停留在基础知识上。Linux 文件是一个值得深入了解的主题,掌握一些高级知识有时会带来实际的好处(有时甚至是直接的经济回报)。毕竟,“处理文件”是你在 Linux 上作为开发人员最常做的事情之一:编写和读取源代码和配置文件、查找特定的文件内容、复制和移动日志文件等等。你在这些基础操作上越高效,作为一个全面的开发人员,你就越能避免不断在 Google 上查找基本的 Linux 命令,或者在和同事的故障排除 Zoom 通话中尴尬地卡在命令行文本编辑器里。
首先,我们将讨论如何在文件系统树中搜索文件,并在单个文件中查找特定内容或模式。接下来,我们将讨论你可能会遇到的特殊文件和替代文件系统,以及你需要了解的有效工作方法。
到最后,你将了解:
-
你可能遇到的各种文件类型,以及它们的用途
-
你需要处理的最重要的文件数据类型
-
Linux 文件系统和你将使用的相关命令
-
文件编辑基础
-
一些常见问题及如何避免它们
本章内容丰富,是你学习 Linux 其他技能的基础之一。确保你在继续之前理解每一部分——你不必在第一次阅读时就记住所有内容,但尽量在自己的 Linux 环境中尽可能多地获得实践经验。掌握这些知识,对于你在解决现实问题或面试时将有实际的回报。
Linux 中的文件:绝对基础
为了更好地讲解 Linux 中文件的更大主题,让我们先讲一些你可能已经有直觉的绝对基础:纯文本文件和二进制文件。我们还将讨论在将 Windows 文件移动到 Unix 系统,或反之时,可能会遇到的一个实际错误。
纯文本文件
你会遇到的最简单的文本文件形式之一就是强大的纯文本文件。虽然它们在历史上是 ASCII 文件,但现在通常是 UTF-8 编码的。你可能会遇到其他文件编码,但这种情况很少见,因为它们通常被认为是过时的。
什么是二进制文件?
Unix 不像许多其他操作系统那样区分二进制文件和文本文件。所有文件都可以通过管道流传输、编辑和附加。文件就是文件。当文件被设置为可执行时,Unix 会尽力执行它,要么成功(如ELF格式文件,可能是今天最广泛使用的可执行格式),要么失败——例如,尝试执行一个图像或音频文件时。
这个简单的机制开辟了一些令人惊讶的可能性。例如,执行文件可以通过压缩工具,然后通过网络隧道(如 SSH)传输,最后解压并写回到文件中——这一切都在一个命令中完成,而无需任何临时文件。
然而,这也意味着,你应该小心避免创建一个情况,例如,网站用户上传或修改的随机文件(包括日志文件!)有任何可能被执行的机会。这可能导致严重的安全问题。
行结束符
虽然 Unix 文件,尤其是文本文件,和其他操作系统上的文件功能相似,但值得一提的是,Windows(和 DOS)等系统使用不同的行结束符字符,这可能在许多使用这些文本文件的程序中导致错误。虽然这种情况只会发生在将一种系统上创建的文件复制到另一种系统时(例如,从 Linux 迁移到 DOS),但值得了解。
不同的行结束符的原因是历史性的,许多工具(例如 Git 和各种文本编辑器)会自动为你处理这种差异。然而,在极少数情况下,你可能需要手动转换文件。有一些著名的命令,如dos2unix
,可以做到这一点,但这些命令通常需要手动安装在大多数类 Unix 操作系统上。
然而,仍然有一些方法可以使用更传统的工具来转换它们。
-
使用
sed
:sed 's/^M$//' original_dos_file > unix_file
-
使用
tr
:tr -d '\r' < original_dos_file > unix_file
-
使用
perl
就地替换:perl -pi -e 's/\r\n/\n/g' original_file
现在我们已经讨论了理解类 Unix 系统中文件所需的基本概念,让我们来谈谈这些文件实际上存在的上下文:Linux 文件系统。
文件系统树
文件系统层次结构标准(FHS)描述了类 Unix 系统的常规目录布局。Linux 遵循这一标准,实际上使其成为“Linux 的官方文件夹结构”。FHS 是一个标准化的树形结构,其中每个文件和目录都源自根目录(一个名为“/
”的目录)。这个层次结构至关重要:虽然有一个位置供最终用户自由创建自己的目录结构,但/
(根目录)中的每个子目录都有其特定的用途。
这个文件系统层次结构的基本布局并不难学习,通过现在投入几分钟,你会对文件的位置产生直觉——无论它们是应用程序二进制文件、日志、数据文件,还是你的代码需要访问的外部设备。换句话说,它既有助于开发,也有助于故障排除:当你知道文件“应该”在哪里时,你就能减少在事故发生时迷茫和不确定该在哪里寻找的时间。此外,这些知识对于编写自己的脚本和进行高级开发者预期的轻量级系统管理任务是必需的。
这里列出的是你经常会看到或需要自己使用的一些重要文件系统位置:
-
/etc
:系统和软件配置文件存放在这里,按多个子目录进行组织。 -
/bin
和/sbin
:系统二进制文件存放在这里。不要随意更改这些文件。 -
/usr/bin
和/usr/local/bin
:你的已安装软件和你自己的二进制文件存放在这里,系统中的任何人都可以看到并执行它们。 -
/var/log
和/var/lib
:/var
包含可变数据,这些数据在系统运行时容易发生变化,例如应用日志(/var/log
)和动态库(/var/lib
)、文件及其他运行中的应用状态。 -
/var/lib/systemd
:文件系统中包含systemd
配置的多个位置之一。 -
/etc/systemd/system
:这是放置自定义系统单元文件的好地方,如果你正在创建服务的话。 -
/dev
:用于表示硬件设备的特殊文件系统。 -
/proc
:一个特殊的文件系统,用于查询或更改系统状态。
基本的文件系统操作
现在是时候深入了解你作为开发者每天都会用到的基础 Unix 命令了。这些命令将使你能够完成任何系统中需要执行的一系列基本命令行任务。一旦你学会并练习了本章中的命令,你将能够做一些事情,比如:
-
实时查看你的应用日志。
-
修复一个损坏的配置文件,让你的应用正常运行。
-
在本地 macOS 开发机器上的 Git 仓库中从一个目录切换到另一个目录。
让我们从列出一个目录开始。确保你已登录到一个 Linux 或 Unix 系统(Ubuntu 或 macOS 均可),并打开终端应用程序,准备跟随操作。
ls
列出文件或目录。此命令类似于图形用户界面中的“打开文件夹”。它列出所给目录的内容。默认情况下,它使用你当前的目录:
/home/steve# ls
my_document.txt
在这个例子中,我的 shell 当前的位置是/home/steve
目录,它包含一个文件(my_document.txt
)。
你可以通过传递目录路径作为参数,要求ls
列出系统上的任何目录:
/home/steve# ls /var/log/
alternatives.log apt bootstrap.log btmp dpkg.log faillog lastlog wtmp
为了获得更整齐的输出,你可能想添加-l
选项。这将给你一个“长列表”,意味着每行显示一个文件或目录,并附带额外的信息。
# ls -l /var/log/
total 296
-rw-r--r-- 1 root root 4686 Jun 24 02:31 alternatives.log
drwxr-xr-x 2 root root 4096 Jun 24 02:31 apt
-rw-r--r-- 1 root root 64547 Jun 24 02:06 bootstrap.log
-rw-rw---- 1 root utmp 0 Jun 24 02:06 btmp
-rw-r--r-- 1 root root 177139 Jun 24 02:31 dpkg.log
-rw-r--r-- 1 root root 32032 Oct 28 14:26 faillog
-rw-rw-r-- 1 root utmp 296296 Oct 28 14:26 lastlog
-rw-rw-r-- 1 root utmp 0 Jun 24 02:06 wtmp
简而言之,ls
命令就是你在 Unix 文件系统上“查看周围”的方式。
pwd
“打印工作目录”的缩写。这显示了你在文件系统中的“位置”,是你当前 shell 会话的上下文。如果我作为steve
用户登录到 Linux 系统,并且在我的主目录下,我可以期待pwd
打印出类似这样的内容:
pwd
/home/steve
cd
更改当前工作目录。使用此命令后,你运行的命令将从新更改的文件系统位置的角度执行。
这是一个示例目录:
Desktop
├── anotherfile
├── documents
│ └── contract.txt
├── somefile.txt
└── stuff
├── nothing
└── important
如果你坐在Desktop
目录下,但然后用cd documents
切换到documents
目录,你使用ls
命令时,会从新的位置看到不同的列表。让我们看看实际操作:
/home/steve/Desktop# ls
anotherfile documents somefile.txt stuff
/home/steve/Desktop# cd documents/
/home/steve/Desktop/documents# ls
contract.txt
现在我们可以查看我们的周围环境(ls
)、在文件系统中移动(cd
),并且知道我们在哪个位置(pwd
),让我们开始通过创建和修改文件来实际影响文件系统。
touch
这个操作写作touch filepath
。
根据你提供的文件路径是否已存在,touch
命令将执行以下两种操作之一:
-
如果路径下的文件尚不存在,
touch
将创建它:→ /tmp touch filepath → /tmp ls -l filepath -rw-r--r-- 1 dcohen wheel 0 Aug 7 16:02 filepath
-
如果路径下的文件确实存在,
touch
将更新该文件的访问和修改时间:→ /tmp touch filepath → /tmp ls -l filepath -rw-r--r-- 1 dcohen wheel 0 Aug 7 16:03 filepath
注意,唯一改变的是在长列表显示中的修改时间。
less
less
是一个被称为“分页器”的程序——它允许你一次查看文件内容的一个屏幕(页面):
less /etc/hosts
它是交互式的——一旦你开始用它查看文件,你可以:
-
使用鼠标滚轮或箭头键逐行向上或向下滚动。
-
使用SPACE键滚动整页。
-
使用/(输入搜索模式)RETURN进行搜索。
-
转到下一个匹配项:n。
-
使用q退出程序。
练习使用它一两分钟,你就能掌握。
tail
tail
用于查看文件的最后几行。
tail /some/file
-f
(跟随)选项对于实时流式传输日志到终端非常有用:
tail -f /var/log/some.log
使用q退出 tail。
mv
mv
(移动)用于移动和重命名文件。
移动
假设你有一个名为somefile.txt
的文件:
→ Desktop ls -alh somefile.txt
-rw-r--r-- 1 dcohen wheel 0B Aug 7 11:02 somefile.txt
只要你和文件处于同一个目录,以下是如何将它移动到/var/log
目录,而不重命名它:
mv somefile.txt /var/log/
重命名
现在你想把那个文件重命名为foobar
:
mv /var/log/somefile /var/log/foobar
就是这样!
cp
要复制文件和目录,请使用cp
命令:
cp file destination
将名为file
的文件复制到目标文件路径destination
。最常用的选项是-r
,或者–recursive
;如果你复制的是一个目录,它将复制其中的所有内容。
cp -r /home/dave /storage/userbackups/
mkdir
使用以下命令创建一个新的空目录,命名为directoryname
:
mkdir directoryname
一个有用的选项是-p
,它允许你在一个命令中创建嵌套目录。例如,如果你想创建一个包含名为school
的目录的Documents
目录,而school
目录中又包含一个名为reports
的目录,你可以运行以下命令:
mkdir -p Documents/school/reports
rm
rm
删除(删除)文件和目录:
-
rm filename
删除名为filename
的文件。 -
rm -r directoryname
将删除名为directoryname
的目录,以及其中的所有文件和目录,递归地。
删除空目录有一个单独的命令,名为rmdir
,但它通常只在脚本中使用,开发人员小心地限制不小心删除的范围。
编辑文件
无论是更新配置文件、创建新的 Linux 服务,还是在故障排除期间做笔记,你在 Linux 上的工作偶尔会要求你在命令行上编辑文件。我们将在第六章《命令行编辑文件》中详细介绍命令行文件编辑,但在这里我们会给你一个非常简短的概述。
如果你只能使用命令行环境,可能会使用一些 CLI 文本编辑器:
-
nano:几乎总是已安装或可用;易于使用
-
vi:几乎到处都已安装;需要一点时间适应
-
vim:在所有地方都容易安装;比
vi
功能更全面
如果这些编辑器没有安装,你可以通过你的包管理器安装它们。例如,如果你使用的是 Ubuntu Linux,可以使用类似sudo apt-get install nano
的命令(或将nano
替换为vim
)。我们将在第九章《管理已安装的软件》中深入探讨包管理命令。不管你选择哪个编辑器,你只需在命令行中输入[$EDITOR filename]
来编辑文件;例如:
vi filename
vim /some/file
nano /another/file
-
如果文件存在,你将能够在编辑器中编辑它。
-
如果不存在,但目录存在,你将在编辑器中第一次保存时,在该路径下创建一个新文件。
-
如果目录不存在,你可能仍然能够编辑该文件,但编辑器在没有一些额外步骤的情况下无法将其写入文件系统。
在下一章,第六章《命令行编辑文件》中,我们将深入探讨在 Linux 命令行上编辑文件的实际技能。如果你在完成本章之前绝对需要编辑文件,只需输入nano /path/to/the/file
并按照屏幕上的备忘单保存并退出。与此同时,让我们了解一下你作为 Linux 开发者会遇到的各种文件类型。
文件类型
我们已经讨论了“常规”文件,比如纯文本文件或图像文件中的二进制数据以及可执行程序。但是,在 Linux 中,还有几种其他类型的文件,你需要知道如何识别并与之操作。无论你是在寻找你刚刚插入计算机的 USB 闪存盘或键盘,创建指向文件的链接,还是检查一个 Web 进程打开的网络套接字,你都需要了解这些 所有 文件类型。
下面是所有 Linux 文件类型及其用途:
-
常规文件:这是最常见的文件类型,包含文本或二进制数据。作为软件工程师,你会在几乎每个编程任务中遇到常规文件,无论是编写代码、编辑配置文件还是执行程序。在长列表中,你可能会看到的典型示例是像这样的源代码文件:
-rw-r--r-- 1 dave dave 210 Jan 04 09:30 main.c
-
目录:目录是用于组织其他文件和目录的特殊文件。如果你曾经使用过 Windows 或 macOS(在这些系统中,它们被称为“文件夹”),那么你已经熟悉了目录;它们包含其他文件和目录。在长列表中,像
/etc
这样的目录将显示为:drwxr-xr-x 5 root root 4096 Jan 04 09:21 /etc
-
块特殊文件:这种特殊文件类型提供缓冲访问硬件设备,这使得它们特别适用于像硬盘这样的设备,因为数据是以大块、固定大小的块进行访问的。除非是在挂载文件系统时,你很少会直接使用这些文件。一个示例可能是一个硬盘分区,显示为:
brw-rw---- 1 root disk 8, 2 Jan 19 11:00 sda2
这表示一个块设备,具有所有者和组的读写权限。
-
字符特殊文件:与块文件类似,字符文件提供无缓冲的、原始的硬件设备访问,但它们是为数据不是以块方式组织的设备设计的,比如键盘或鼠标。你通常不需要关心这些文件,尽管在工作中你可能偶尔会用到它们(例如,
/dev/urandom
、/dev/null
或/dev/zero
)。像终端这样的字符设备在长列表中可能会显示为:crw-rw-rw- 1 root tty 5, 1 Jan 19 22:00 /dev/tty1
-
FIFO 特殊文件(“命名管道”):命名管道,不要与 shell 中经常使用的匿名管道混淆,用于进程间通信。你几乎不需要处理这些文件,尽管在 第十一章《管道和重定向》中,你将使用它们的匿名版本来成为 Unix 大师。你很少会遇到这些文件,但有一个示例是命名管道文件,它可能看起来像这样:
prw-r--r-- 1 user user 0 Jan 21 10:00 mynamedpipe
-
链接:链接是一种指向其他文件的快捷方式。链接有两种类型——硬链接和符号链接(软链接)。你几乎不需要处理硬链接,但你可能会使用符号链接来创建指向常用文件的方便路径,或者确保多个路径指向同一个文件。我们将在下面详细介绍这些内容。符号链接可能显示为:
lrwxrwxrwx 1 user user 7 Jan 21 10:30 versions/latest -> bin/app-3.1
这个示例表示一个名为
latest
的链接,它指向名为app-3.1
的文件。 -
套接字:Unix 套接字用于进程间通信(IPC),类似于管道文件。在故障排除需要相互通信的服务时,你可能会遇到套接字文件(“为什么 nginx 无法访问我的应用服务器?”)。例如,一个用于 nginx 和
php-fpm
之间通信的套接字文件,以便 WordPress 应用程序能够运行,可能如下所示:srwxrwx--- 1 root socket 0 Jan 23 11:31 /run/wordpress.sock
这个列表涵盖了你可能遇到的额外的特殊文件类型,并且给你提供了一些关于你为何以及如何在实际使用中遇到这些文件类型的直觉。为了帮助你建立实用的技能,我们应该特别深入探讨几种类型。让我们从体验最常见的这些特殊文件类型——链接开始。
符号链接
符号链接,通常称为 symlink 或软链接,是一种文件类型,用作指向另一个文件或目录的引用。与硬链接不同,符号链接可以跨不同的文件系统指向一个文件或目录,并且它维护与所引用的文件或目录不同的 inode。
你可以使用以下基本语法创建符号链接:
ln -s document.txt /path/to/create/link
ln
(小写字母 l)是“链接”命令。
例如,如果你在当前目录中有一个名为file1.txt
的文件,并且你想创建一个指向它的符号链接,命名为link1
,你可以使用以下命令:
ln -s file1.txt link1
现在,如果你使用ls -l
列出目录的详细信息,你会看到link1
作为指向file1.txt
的链接列出:
ls -l
total 0
-rw-r--r-- 1 root root 0 Oct 28 16:08 file1.txt
lrwxrwxrwx 1 root root 9 Oct 29 17:20 link1 -> file1.txt
例如,当你通过使用cat link1
查看link1
的内容时,系统会自动取消引用该链接并显示file1.txt
的内容。如果file1.txt
被移动、删除或重命名,符号链接不会自动更新,并且会指向一个不存在的文件(即断开的链接)。
符号链接特别适用于创建快捷方式、组织文件和目录,并维护灵活和逻辑的文件系统结构。
硬链接
硬链接是同一文件系统上现有文件的额外名称,实际上充当了别名。原始文件和硬链接共享相同的 inode,这意味着对一个的更改会反映到另一个上。与符号链接不同,硬链接不能跨文件系统边界或链接到目录。如果原始文件被删除,硬链接仍然会保持数据。要创建名为link1
的硬链接,指向名为file1.txt
的文件,你可以使用以下命令:
ln file1.txt link1.
file
命令
file
命令是一个工具,可以让你检查文件的类型。file
命令的基本用法很简单:输入file
后跟文件名。例如:
file mysecret.txt
可能输出mysecret.txt: ASCII text
,表示mysecret.txt
是一个纯文本文件。
如果你有一个二进制文件,比如一个名为mybinary
的已编译程序,运行file mybinary
可能会输出类似mybinary: ELF 64-bit LSB executable
的内容,表示program
是一个二进制可执行文件。
对于目录,例如/home/user
,运行file /home/user
通常会返回/home/user: directory
,表示/home/user
是一个目录。
file
命令是一个强大的工具,可以快速了解你正在处理的文件类型,尤其是在处理未知或不熟悉的文件时。
如果你想探索,可以使用file
命令检查以下文件:
-
file /bin/sh
-
file /dev/zero
-
file /dev/urandom
-
file /dev/sda1
-
file ~/.bashrc
-
file /bin/ls
-
file /home
-
file /proc/1/cwd
高级文件操作
当你在类 Unix 操作系统中处理文件时,你通常希望对它们或它们的内容执行某些操作,但不直接在编辑器中修改它们。例如,你可能想要:
-
搜索文件,看看它是否包含你正在寻找的内容。
-
识别在特定时间被修改的一批文件。
-
安全地将文件移动到另一个系统,而不是仅仅在本地机器上使用
mv
进行复制。
你甚至可能想将这三者结合成一个单一的操作!这种类型的知识在故障排除(在日志中搜索特定的请求 ID 或错误代码)、开发(查找最近修改的源代码文件)或测试(将更新的应用程序源代码复制到测试系统)时非常有用。
这里快速介绍这些文件操作,帮助你了解将用于完成这些操作的工具和命令。
使用 grep 搜索文件内容
文本匹配传统上使用grep
完成。在个人或工作笔记本上,你可能希望安装ag
或rg
,它们是更适合程序员的、更快速的版本(例如,sudo apt-get install silversearcher-ag
),但在生产系统上,你将始终使用grep
。
在文件path/to/file
中搜索模式search_pattern
:
grep "search_pattern" path/to/file
当然,你可以像这样搜索字符串字面量,但grep
之所以强大,是因为它允许你使用正则表达式(regex)来搜索模式。以下命令将返回以startswith
开头的行:
grep ^startswith /some/file
这个命令将返回以endswith
结尾的行:
grep endswith$ /some/file
正则表达式非常有用,每个开发者和 Linux 用户都应该熟悉其基础知识。
你还可以使用grep
递归地搜索一个目录——也就是说,搜索它包含的所有文件中的所有目录:
root@c7f1417df8d2:/tmp# grep -r -i "hello world" /tmp
/tmp/secret/dontlook.key:hello world
/tmp/hi.txt:hello world
/tmp/hi.txt:HeLlO WoRlD! You found me!
但如果你不想查找文件内部的字符串——如果你想找到特定的文件本身呢?
使用 find 查找文件
find
可以帮助你根据名称、修改时间或其他属性查找文件和目录。它本质上是文件系统树的广度优先搜索,非常适用于以下任务:
-
查找在过去一天内创建或修改的所有应用程序日志文件。
-
识别所有以
_test.go
结尾的源代码测试文件。 -
定位所有被实习生程序员遗留下来的
php.ini
文件,以便删除它们。
在以下示例中,/search/path
是你想要搜索的文件系统部分。如果你想要搜索当前目录及其所有子目录,可以使用点号字符(.
),例如,find . -name 'file.txt'
:
-
按扩展名查找文件:
find /search/path -name '*.ext'
-
查找匹配多个路径/名称模式的文件:
find /search/path -path '**/path/**/*.ext' -or -name '*pattern*'
-
查找匹配给定名称的目录,不区分大小写:
find /search/path -type d -iname '*lib*'
-
查找匹配给定模式的文件,排除特定路径:
find /search/path -name '*.py' -not -path '*/site-packages/*'
-
查找匹配给定大小范围的文件:
find /search/path -size +500k -size –10M
使用 rsync 在本地和远程主机之间复制文件
rsync
是一个非常有用的工具,用于在主机之间复制文件和目录。它的工作方式和cp
命令一样,不同之处在于它可以在一个或两个主机是远程主机时工作。
rsync
本质上是cp
(用于复制数据)和ssh
(用于安全加密传输)的结合体。如果你不熟悉ssh
,你需要先了解它的工作原理(并设置自己的 SSH 密钥和访问权限),然后再尝试使用rsync
命令。
这里有一些示例调用,感谢tldr
项目:
-
将文件从本地主机传输到远程主机:
rsync path/to/local_file remote_host:path/to/remote_directory
-
将文件从远程主机传输到本地主机:
rsync remote_host:path/to/remote_file path/to/local_directory
-
使用[
a
]归档模式(保留属性)和压缩([z
]压缩)模式,带[v
]详细信息和[h
]人类可读的[P
]进度:rsync -azvhP path/to/local_directory remote_host:path/to/remote_directory
最后的这个例子是我用过一百次的命令,用于快速、自动化备份。
结合使用 find、grep 和 rsync
我们将在第十一章,管道和重定向中详细了解如何使用|
字符来组合命令,但这里给你一个快速预览。
举个例子,如果你想将刚才看到的例子结合起来,例如,备份上周内修改的所有/tmp
目录中的文件,那只需要一个巧妙的命令:
find /tmp -type f -mtime -7 -exec grep -l "hello world" {} \; | xargs -I _ backupscript.sh _ backup@backupserver.local:/backups_
首先,我们运行find
命令,查找修改时间在 7 天之内的文件。我们使用 find 的-exec
标志来执行一个带有-l
标志的grep
命令,该命令只会返回匹配文件的文件名。然后,我们将这些文件名传递给xargs
命令,xargs
会对从前一个命令接收到的每一行输入执行一个动作。在这个例子中,动作是对每个匹配文件运行一个虚拟的备份脚本,并指定一个虚拟的目标路径,这个路径是用户可能希望将文件备份到的位置。
如果我们有与上面grep
部分相同的文件,这个看起来有些复杂的命令会为你执行两个命令:
backupscript.sh /tmp/secret/dontlook.key backup@backupserver.local:/backups/tmp/secret/dontlook.key
backupscript.sh /tmp/hi.txt backup@backupserver.local:/backups/tmp/hi.txt
它正在做我们想要的事情:仅对包含我们关心的“hello world”内容且在过去 7 天内修改过的两个文件运行备份脚本。
虽然像这样的命令可能需要几分钟时间(以及一些谷歌搜索)来编写,但从长远来看,它可能为你节省数小时的工作。这就是命令行环境的强大之处,结合了小巧且专注的 Unix 工具,你可以根据需要将它们组合起来。
你将在第十一章,管道和重定向中进一步了解 Unix 管道和xargs
,但我们在这里给你举这个例子,是因为你需要提前了解如何将这些简单的命令组合在一起,以便在学习过程中使用。
真实世界中的高级文件系统知识
你现在已经了解了各种 Linux 文件类型,并且有了一些与最常见文件类型打交道的经验。现在,让我们来看看一些不太常见的文件系统知识,它们在你使用 Linux 系统时会派上用场。
当你在以下场景中会遇到这些内容:
-
排查你的第一个 Docker 应用程序,该应用程序已经挂载了存储卷。
-
开发与工业控制器、摄像头或其他外部硬件通信的应用程序。
-
编写需要访问随机数的应用程序代码,用于安全地生成密码或 API 令牌。你将会遇到的特殊文件类型之一是块设备,它们是类似磁盘的设备,数据以块的形式被提取和读取。
经典的磁盘设备是块设备,你通常会在以下位置找到它们附加到你的文件系统上:
-
/dev/hdX
-
/dev/sdX
-
/dev/nvmeN
其中X
和N
是各自磁盘的字母或数字索引,比如/dev/sda
或/dev/nvme0
。分区看起来就像磁盘,但在后面会附加一个额外的数字或字符,比如/dev/sda0
,表示第一个磁盘上的第一个分区。
注意,即使操作系统检测到新的硬盘并将其(以及任何检测到的分区)附加到这些位置,你仍然需要手动“挂载”驱动器上的文件系统,使用mount
命令。这对于开发者来说并不是特别常见的操作,因此我们就不展开了。
还有一些特殊的“软件设备”。这些包括/dev/null
,你可能看到过将输出通过管道传递到它那里,如somecommand > /dev/null
,以及/dev/random
和/dev/urandom
,它们为你提供随机字节。这也是你选择的编程语言可能会从中获取其加密安全的随机数的地方。
另一个目录是/proc
,这是一个由 Plan 9 操作系统推广的文件系统,但在 Unix 早期就有了它的构想。顾名思义,它是用来将进程表示为文件的。/proc
包含以进程 ID 命名的目录,目录下有可以用来读取进程状态的文件。尤其在 Linux 中,它扩展了各种接口,包括配置内核驱动程序、读取硬件信息和传感器输出,甚至与 BIOS 和 UEFI 交互。
FUSE:与 Unix 文件系统更有趣的互动
正如你刚刚看到的,Unix 中许多东西都可以被解释为文件。其理念是常见的编辑文件,因此能够与文件交互的命令和编程语言提供了一个被广泛理解的接口。FUSE,即用户空间文件系统,是一个允许任何人实现新的 Unix 文件系统的 API,而不必成为内核程序员。换句话说,因为许多东西可以与文件对话,所以能够“伪造”Unix 文件 API,对于那些不是你期望的普通本地存储数据的东西,是非常有用的。如果这听起来有点疯狂,请看看人们使用 FUSE 写的一些东西。FUSE 已被用来实现许多经典的文件系统驱动程序,例如 NTFS,因此你可以在 Linux 机器上读取你的旧 Windows 文件系统。然而,由于 FUSE 的灵活性和易用性,也有一些非常疯狂的文件系统是这样实现的:
-
例如,
sshfs
允许你在通过 SSH 访问的另一台机器上本地挂载一个目录。 -
其他 FUSE 文件系统允许你将远程云存储(如亚马逊的 S3)挂载为本地目录。
-
一些更加不为人知的文件系统允许你将 Wikipedia 挂载为文件目录,或者将诸如 IRC 和像天气 API 这样的服务表示为文件系统。
FUSE 如此有用,以至于它已经进入了除 Linux 之外的许多类 Unix 操作系统,并且现在甚至在 Windows 上也可用。了解它是值得的,不仅因为它是 Unix 文件抽象的一种新颖应用,而且在处理存储在某处没有经典 API 的信息时,它可以非常有用。你可能会使用的任何编程语言都有一个标准库,让你能够在 Unix 文件系统上操作文件,而 FUSE 则是为几乎任何类型的信息创建这种接口的一种方式。
结论
这一章节是对 Linux 上文件和文件系统基础及一些高级内容的密集探讨。你了解了纯文本文件和二进制文件的区别,探索了 Linux 文件系统树的布局,并学习了所有处理文件所需的基本命令。如果你做得对,你也在自己的 Linux 环境中花了一些时间,练习了我们在这里展示的重要的命令行文件编辑技能。
在介绍了基础知识之后,我们进入了你将需要的最关键的中级和高级主题。你学会了如何查找文件并在其中搜索内容,我们还让你尝试了特殊文件和文件系统。
所有这些加在一起,让你具备了解决实际问题所需的最重要的技能和知识。希望你在这场风风火火的旅程中玩得开心!
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本的发布——请扫描下方的二维码:
第六章:在命令行上编辑文件
在命令行上编辑文本常常是生产系统的硬性要求,因为这些系统通常缺乏图形用户界面。然而,即使在没有这些系统的情况下,熟练掌握命令行文本编辑也有很多好处——实际上,即使你有图形化文本编辑器或集成开发环境(IDEs)可用。
例如,许多功能齐全的文本编辑器和 IDE 都支持本章中你将学到的模式,这意味着你获得的速度和效率是可以转移到其他工具上的。事实上,你可以在所有种类的工作中使用本章中学到的快捷键,从快速查找和替换文本到在长长的 shell 命令中更正拼写错误。
你甚至可能会发现你最喜欢的工具(有时通过插件)内置了类似的快捷键;例如,通过几次 Google 搜索,你就能发现支持 vim 键盘快捷键的电子邮件客户端、浏览器插件和 Web 应用程序,而这些快捷键你将在本章后面学到。
学会以这些简约、纯文本界面所要求的高效模式进行思考,可以帮助你找到更高效的方式完成你每天必须做的事情,避免你在可以通过几次按键就完成任务时,浪费时间一遍又一遍地点击图形菜单或向导。
注意
许多极其简约(或高度安全)的环境也倾向于将文本编辑器从生产镜像中移除,尽管这并不能提高安全性(cat
、echo
、mv
和输入/输出重定向足以在紧急情况下临时充当一个可用的文本编辑器)。你——就像世界各地的黑客一样——很可能会在你的一生中在许多 Docker 容器中安装 nano 或 vim。
在本章中,你将学习两款文本编辑器的基础知识:我们认为最容易入门的一款(nano),以及我们认为对你职业生涯最有长期投资价值的一款(vim)。你将了解 Linux 中命令行文本编辑的基本背景,深入学习 nano 和 vim,最后,学习如何避免最常见的编辑错误。我们还将展示如何调整你的 shell 以便在可能的情况下自动使用你首选的编辑器。
Nano
Nano 是一个小巧且易于使用的命令行文本编辑器。Nano 的一个特点——你甚至可以称它为主要特点——是它会在你愉快地在终端中编辑文本时,在屏幕底部突出显示键盘快捷键备忘单。这在你感到压力山大且不习惯在命令行中编辑文本时尤其有用。
Nano 在紧急情况下很好用,但你不会在更多的简约环境中找到它(例如 Docker 容器或生产虚拟机)。需要注意的是,nano 还倾向于自动创建备份文件(~yourfile.txt
),从而可能污染文件系统。
安装 nano
在你可能使用的所有流行 Linux 发行版中,nano 的包名是nano
——使用你偏好的操作系统的包管理器来安装它(在这里,我们是在 Ubuntu 上安装它):
apt-get install nano
Nano 备忘单
你可以在这里找到一个官方的、最新的 nano 备忘单:www.nano-editor.org/dist/latest/cheatsheet.html
以下章节中列出了最有用的命令。
文件处理
-
Ctrl+S:保存当前文件
-
Ctrl+O:提供写入文件的选项(“另存为”)
-
Ctrl+R:将文件插入到当前文件中
-
Ctrl+X:关闭缓冲区,退出 nano
编辑
-
Ctrl+K:将当前行剪切到剪切缓冲区
-
Ctrl+U:粘贴剪切缓冲区中的内容
-
Alt+3:注释/取消注释一行/区域
-
Alt+U:撤销上一步操作
-
Alt+E:重做上一步撤销的操作
查找与替换
-
Ctrl+Q:开始向后查找
-
Ctrl+W:开始向前查找
-
Alt+Q:查找上一个出现的位置
-
Alt+W:查找下一个出现的位置
-
Alt+R:开始替换操作
Vi(m)
Vi(通常被称为 ex-vi 或 nvi)是一个命令行文本编辑器。Vim(vi iMproved)是一个扩展版本,很多人将它作为整个 IDE 使用。vi 和 vim 共享相同的基本命令和键盘绑定,所以只要你学会了基础,无论登录什么样的古老或现代系统,你都能应对自如。
温馨提示:vim 很复杂,学习曲线较陡。可能需要几周的业余时间来学习和实验,才能感到熟练——这就像是设置第一个 Linux 网页服务器或编写第一个 500 行的程序一样。
学习 vim 的一个奇妙之处在于,你可以在笔记本电脑上本地使用它,或者在没有图形界面的远程服务器上使用,它们的编辑体验是相同的——都非常高效美观。为了有效使用它,心态的转变和对其词汇(我们将要介绍的命令和模式)的理解非常重要。
像学习其他任何技能一样,一些专注的练习和持续使用编辑器是建立理解并感到舒适的关键。刚开始可能会遇到一些困难和困惑,这很正常。请不要让这些影响你的学习进程!
Vim 是一个模式编辑器,这意味着相同的按键在不同的“模式”下执行不同的操作。例如,当你处于插入模式时,你按下的键将直接写入你正在编辑的文件(或缓冲区)——就像你在使用 IDE 或 Microsoft Word 一样。然而,在普通模式下,按下相同的字母键会执行与之绑定的命令。一旦你适应了这一点——模式编辑——剩下的 vi/vim 内容就是不断的练习。
例如,如果你启动 vim 并连续输入两个小写的i,第一个i将进入插入模式,而第二个i将实际在编辑窗口(缓冲区)中写入i字符。如果现在听起来很混乱,没关系。即使你从不打算将 vim 作为常规 IDE 使用,到本章结束时,你对其基础知识也会感到更加熟悉。
注意
值得一提的是,在撰写本书时,另一个类似 vim 的编辑器 nvim(neovim)开始逐渐流行起来。大部分适用于 vim 的内容也同样适用于 nvim,所以不必担心从 vim 转到 neovim。它们主要的区别在于插件开发,因此如果你决定以后从 vim 转到 neovim,你不会失去任何东西。
Vi/vim 命令
这里有一些基本的 vi(m)命令 – 在使用这些命令前,请先按下Escape键确保你处于普通模式。
模式
-
v – 进入可视模式。这个功能只存在于 vim(而不是 vi),初学者可能会过度使用,因为它对从其他编辑器过来的人来说更加熟悉。
-
ESC — 退出当前模式并进入普通模式,可以执行命令。
命令模式
可以通过在普通模式中按下Escape键,然后键入冒号(:
)进入命令模式。为了清晰起见,下面的命令中包含了冒号。
助手
-
:set number
– 显示行号。 -
:set paste
– 如果你想粘贴内容到 vim 中且不希望其影响缩进,这很有帮助。你可以用:set nopaste
再次禁用它。
退出
-
:q
– 退出 -
:q!
– 不保存退出(强制退出) -
:w
– 写入(保存)文件 -
:wq
– 写入并退出 -
:wqa
– 仅适用于 vim;在多个窗格打开时有帮助,比如插件在侧边打开文件浏览器时
普通模式
普通模式是你启动 vim 时的模式,在输入任何内容之前都是如此。你可以随时通过按下Escape键返回普通模式。
导航
-
k – 向上移动
-
j – 向下移动
-
l – 向右移动
-
h – 向左移动
另外,也可以使用箭头键,但考虑使用 vi 快捷键会更有帮助,而不是像普通编辑器一样操作 vi(m)。我们发现在练习时坚持使用 vi 的移动键会更有效。
-
w – 移动到下一个单词
-
b – 移动到当前单词或前一个单词的开头
-
^ 或 0 – 移动到行首
-
$ – 移动到行尾
-
gg – 移动到文件开头
-
G – 移动到文件末尾
编辑
-
i – 进入插入模式(写入实际文本)。I 在行首插入。
-
a – 插入文本,在光标后添加。A 在当前行的末尾添加。
-
o – 打开新行(O 在当前行之前打开新行)
-
/ – 搜索模式(支持正则表达式;使用ENTER进行搜索,n和SHIFT+n循环前进或后退搜索结果)。
-
dd – 删除(并剪切)当前行。
-
y – 复制选中的文本(yank)
-
yy – 复制当前行。
-
p – 将文本粘贴到光标后。
-
u – 撤销上一次更改。
-
CTRL+R – 重做。
-
nX – 其中n是数字,X是命令,将执行X n次。例如,3dd将删除三行。
学习 vi(m)的技巧
在几周的正常编辑任务中,你可以变得相当熟悉 vim。尽管如此,刚开始时并不容易。以下是我们使旅程更顺畅的建议。
使用 vimtutor
Vim 自带了一个内置教程。如果你想开始使用 vim,这可能是你首先要做的事情。只需在命令行中运行vimtutor
来打开 vim 和教程。
通过助记符来思考
在使用 vi(m)编辑文件时,通常会用你上面看到的命令来“构建句子”。例如,d2w
表示“删除两个单词”。虽然我们尽力在上面的命令列表中提到合适的单词,但不同的人有不同的思维方式,所以不要害怕建立你自己的词汇。
避免使用箭头键
避免使用箭头键,并考虑禁用此功能。这可以避免你把 vim 当作另一个编辑器来使用,并减少你适应其标准键绑定所需的时间。别担心;虽然刚开始时可能会觉得奇怪,但你会在几次使用后习惯基本的 vim 键绑定。
避免使用鼠标
尽管 vim 可以用鼠标进行可视化选择,但最好抵制这个诱惑,并继续训练你的键盘快捷键的工作记忆。否则,你会在两者之间反复切换,在关键时刻(比如凌晨三点在没有鼠标输入的远程服务器上排除故障时)会感到不适应。
不要使用 gvim
尽管 gvim(图形化/GUI vim)在某些情况下很有用,但在没有合适终端的情况下,使用其图形快捷键并不是一个好主意。vi(m)的好处在于,当没有图形环境时,它可以通过键盘高效地进行文本操作——就像你在读完这本书后将要排查的许多 Linux 服务器一样!
避免从大量配置或插件开始
一个典型的初学者错误是从别人的 vim 配置开始。虽然它们一开始看起来很有用,但高度自定义的 vim 设置会在你刚开始学习基本概念时阻碍你。大量的配置并不会神奇地让你变得更高效,尤其是在刚开始的时候。一旦你变得更自信,你会发现自己开始编写自己的配置。
另一个需要避免的事情是过度使用插件,尤其是在刚开始时。插件偶尔会导致一些问题,这可能会成为负担并引发更多问题。刚开始使用 vim 时,解决插件相关问题不是你想要处理的事情。第三方配置文件和插件确实非常有用,但它们也可能成为一种依赖:当你突然进入一个不同于开发环境的环境时,你将无法依赖那些炫酷的东西。如果你已经过于依赖定制化的工作流程,即使是基本的编辑也可能变得困难且令人沮丧,尤其是在压力下。
更为明智的做法是从一个简洁的配置开始,只添加你完全理解的部分(并且确保你确实需要)。你将更多的时间用于实际在项目中使用编辑器,因为这能帮助你记住最重要的 vim 快捷键并保持在工作记忆中。虽然需要一些时间,但最终你会不假思索地使用它们,而是通过你为自己编制的助记符来思考。
这里有一个简洁的 vim 配置示例,适合初学者使用。你可以随意修改,或者挑选看起来有用的部分。
将以下内容添加到你的 $HOME/.vimrc
中:
"This breaks compatibility with vi, saying that we want to use the benefits of vim
set nocompatible
" Enable syntax highlighting
syntax on
" Increase the command history to be very big
set history=10000
" Indent based on the previous line
set autoindent
" Make it so searches wrap around at the end of the file
set wrapscan
" Show the current mode in the command line
set showmode
" Displays partial commands in the last line
set showcmd
" Highlight searches
set hlsearch
" Use case insensitive search
set ignorecase
" Don't use case insensitive search use when using capital letters
set smartcase
" Display the cursor position at the bottom
set ruler
在其他软件中的 Vim 绑定
如果你开始喜欢并习惯 vim 的使用方式,值得一提的是,许多文本编辑器和 IDE 都有选项和插件可以切换到 vim 输入模式。甚至有些网页浏览器也提供了 vim 风格的输入!
如果你对 vim 章节中的内容感兴趣或想要复习,可以在这里找到一个视频教程,涵盖了本章中一些最重要的内容(甚至包括一些额外的 vim 功能!):www.youtube.com/watch?v=ggSyF1SVFr4
编辑一个你没有权限修改的文件
无论你使用哪个编辑器,有时你需要编辑一个当前用户没有写权限的文件。例如,如果你是普通用户,想编辑 /etc/hosts
—— 这是一个由 root 拥有且仅 root 可写的文件 —— 你需要成为 root 或使用 sudo
命令。更多详情请参见第七章,用户和组。
虽然像 sudo $EDITOR /etc/hosts
这样的命令可以用来以 root 用户编辑文件,但更好的方法是使用 sudoedit
以 root 身份执行编辑命令:
-
sudoedit /etc/hosts
-
EDITOR=nano sudoedit /etc/hosts
-
EDITOR=vi sudoedit /etc/hosts
第一个示例将使用你在 EDITOR
环境变量中设置的编辑器,而其他两个命令则会将 EDITOR
环境变量作为命令的一部分传递(或覆盖)。
设置你偏好的编辑器
Linux 以及所有类 Unix 系统都允许你通过EDITOR
环境变量设置首选编辑器。大多数命令行软件在执行某些任务时(例如git
提交时或visudo
编辑 sudoers 文件时),会使用此变量来确定打开哪个编辑器。你可以将此EDITOR
变量设置为任何你喜欢的编辑器的路径,甚至是图形界面的(前提是你的系统安装了图形用户界面):
bash-3.2$ echo $EDITOR
nano
bash-3.2$ export EDITOR=vim
请注意,上述交互式 shell 命令仅在当前 shell 会话关闭之前有效;要将此设置持久化到 Bash shell 中,我会将其添加到我的~/.bashrc
文件中。有关更多详细信息,请参阅第四章,使用 Shell 历史。
结论
在本章中,你学会了如何在命令行上编辑文本文件。首先,我们介绍了最简单的入门方式(nano),然后我们展示了如何逐步转向一种技能集,这种技能集将在你整个职业生涯中产生回报:vi/vim 及其键绑定,在各种软件中都有广泛支持。
使用本章的速查表开始命令行编辑,但要知道,在练习一两天后,你将准备好学习 vim 中的其他快捷方式和命令。最好通过 vimtutor、在线速查表和 YouTube 视频结合学习。我们也非常喜欢 Drew Neil 的书籍《实用 Vim》,第二版。
当你在工作时,熟练掌握命令行文本编辑是看起来和感觉像专业人士的最可靠方法之一。不要忽视这一技能集!
在 Discord 上了解更多信息
要加入本书的 Discord 社区 – 在这里你可以分享反馈,向作者提问,了解新版本发布信息 – 请扫描下面的 QR 码:
第七章:用户和组
在本章中,我们将研究 Linux 用来管理资源和维护安全的两个构建块:用户和组。在学习了基础知识并介绍了一个特殊的用户 root
后,我们将展示 Linux 用户组概念是如何在用户抽象之上添加一个方便的层次。
一旦我们涵盖了必要的理论,你将直接进入实际命令的讲解,学习如何创建和修改用户与组。而且,若在面试中遇到类似问题,你将能够亲眼看到 一个 Linux 用户究竟是什么构成的(提示:其实它仅由三行纯文本组成)。
在本章结束时,你将:
-
理解用户是什么以及他们的用途
-
理解 root 用户与普通用户之间的区别,以及如何在需要时切换它们
-
了解如何创建和修改用户和组
-
深入了解用户元数据,看看一个 Linux 用户究竟是什么构成的
什么是用户?
在 Unix 系统的上下文中,用户只是一个可以在系统上执行操作的命名实体。用户可以启动和拥有进程、拥有文件和目录,并对它们拥有各种权限,可以被允许或禁止在系统上执行某些操作或使用资源。实际上,用户就是你登录时使用的身份,进程以该身份运行,或文件的所有者。
“用户”一词显然是指具有用户账户、密码等的真实人的隐喻。但是,实际上,大多数在系统中的“用户”并不代表特定的人类。他们是机器账户,目的是为了安全或组织上的需求,将进程和文件等资源进行分组。
但是,比账户是否打算由人工操作员交互使用更为重要的区别是:用户实际上分为两种类型,在我们开始讲解实际的用户管理技能之前,必须先讨论这个区别。
Root 与其他用户
这个世界有时会很严苛,有时运行某些命令是危险的。例如,fdisk
可能会清除磁盘的分区或以其他方式修改硬件。iptables
可以打开一个网络端口,允许攻击者利用漏洞。即使是使用一个无害的 echo
命令将值发送到文件系统的错误位置,也可能会以微妙且可怕的方式改变操作系统的配置。
为了防止这种情况,运行命令行界面的类 Unix 环境中有一些内建的防护措施。每个 Unix 系统中都有一个“超级用户”叫做 root
。因此,基本的安全模型如下:
-
首先是
root
。这个用户相当于其他系统中的系统管理员,是具有最高权限的用户。root
几乎可以做任何事。 -
然后是其他所有人。非 root 用户的权限是有限的——他们不能启动可能影响整个系统的进程或编辑文件,但他们可以启动自己的(无特权)应用程序并编辑自己的文件。
为了防止出现问题,只有root
用户才能执行改变系统重要部分的命令。因为即使是看似无害的命令,如果使用正确的参数,也可能造成潜在的破坏,所以你可能需要 root 权限来编辑一个文本文件。
sudo
由于每次你想做一些可能对系统有危险的操作时都必须以不同的用户身份登录会很麻烦,因此有了sudo
命令。在命令前加上sudo
(代表“替代用户并执行”),可以让你以 root 用户身份执行该命令。当该命令执行完并退出后,下一个命令会再次作为你的普通(非 root)用户来解释。
你可以通过运行两个命令亲自观察这种行为。首先,运行whoami
命令,这是一个打印当前用户的命令:
whoami
在这种情况下,我以“dave
”用户登录,所以这个命令会输出:
dave
现在,在那个命令前加上“sudo
”:
sudo whoami
即使你仍然以非 root 用户登录,有效的用户 ID 在单个命令执行期间已经改变,因为使用了sudo
:
root
让我们看一个更实际的例子,假设我们想以root
身份运行一个单独的操作,但之后继续以普通用户身份运行其他命令:
sudo systemctl start nginx
<go back to doing regular-user stuff>
第一个命令启动了nginx
web 服务器(假设已安装 nginx 包),这是只有 root 才能执行的操作。之后的任何命令都会再次作为普通用户执行。
这是一个常见的工作流,确保系统安全——你大部分时间作为普通用户工作,无法在单个命令中破坏整个系统。当你需要 root 权限时,只需在需要的命令前加上root。这是一个很好的心理屏障,防止在系统中不小心破坏东西。
你会看到这种模式在系统中用于各种可能有危险的操作,例如编辑系统级配置文件、创建用户家目录之外的目录(本章后面会涉及),等等:
-
sudo mkdir /var/log/foobar
-
sudo vim /etc/hosts
-
sudo mount /dev/sdb1
如果你计划以 root 身份运行多个命令(或者你正在故障排除某些以 root 身份运行的程序,或者模拟cloud-init
脚本执行的环境),你可以使用sudo
来获得一个长时间存在的 root shell 会话:
sudo -i
这将为你提供一个作为 root 用户的交互式 shell 会话。小心使用!没有任何东西能阻止你因一个错误或输入错误的命令而破坏系统。
虽然 sudo 默认将当前用户替换为 root 用户,但你也可以通过–u
选项将其更改为其他用户。例如:
sudo –u myuser vim /home/myuser/.bashrc
这将以用户myuser
的身份在 vim 中打开/home/myuser/.bashrc
。
哪个用户(或用户组)被允许执行哪些操作,可以在/etc/sudo.conf
中定义。你永远不应该直接编辑这个文件;应该使用visudo
命令来修改该文件。
什么是组?
组是一个额外的原语,允许一组用户共享权限。组常用于获得权限集或配置文件的功能。例如,在 Linux 中,通常有一个叫做sudoers
的组,而在 macOS 中,你会遇到一个叫做wheel
的组。根据惯例,系统中属于sudoers
或wheel
组的用户被允许使用sudo
以 root 身份执行命令。这与在 Windows 中将用户添加到Administrators
组功能上是相同的。
你可以推断出,如果组对于管理谁可以运行sudo
命令很有用,那么它们也可能对于将用户分组并管理其他类型的权限非常有用。
小项目:用户和组管理
例如,假设我们希望允许公司中的每个软件开发人员都能读取某个特定文件——我们称它为document.txt
。我们可以简单地创建一个developers
组,并将所有开发人员用户添加到该组。
然后,在设置document.txt
的所有权和权限时,我们可以引用developers
组,而不是试图跟踪每一个可能是该组成员的单个用户。
创建用户
在安装了adduser
命令的 Linux 系统上,你可以使用它交互式地创建一个名为dave
的用户。如果没有,你通常会看到名为useradd
的包(有关安装包的更多详细信息,请参见第九章,管理已安装的软件)。
只使用用户名作为唯一参数运行命令将为你提供一个向导式的用户创建过程。请注意,我们在这里使用了sudo
,因为只有root
用户可以添加或删除用户:
**$ sudo adduser steve**
Adding user `steve' ...
Adding new group `steve' (1000) ...
Adding new user `steve' (1000) with group `steve' ...
Creating home directory `/home/steve' ...
Copying files from `/etc/skel' ...
**New password:**
**Retype new password:**
passwd: password updated successfully
Changing the user information for steve
Enter the new value, or press ENTER for the default
**Full Name []: Steve**
Room Number []:
Work Phone []:
Home Phone []:
Other []:
**Is the information correct? [Y/n] y**
我们已经将需要用户互动的部分加粗——即设置密码、全名以及确认我们要在系统中创建该用户。
这种方式适合添加一两个用户,但如果你正在处理一个 Linux 测试服务器,需要为你 300 个最大客户创建单独的帐户呢?那时你会想要使用非交互式的useradd
命令,它允许你将用户属性作为参数传递给一个命令。这使得用户变更变得容易脚本化(请参见本章稍后的脚本化说明):
useradd --home-dir /home/dave --create-home --shell /bin/zsh -g dave -G sudoers dave
该命令还会:
-
设置并创建用户的主目录(
--home-dir
和--create-home
)。 -
设置自定义的 shell(
--shell
) -
使用
-g
选项将用户的主组设置为dave
(虽然这也可以是像employees
这样的组)。 -
将附加的用户组成员身份添加到
sudoers
组(你可以在此传递多个以逗号分隔的组名)。
就是这样——如果命令成功退出,你的新用户已经创建!
但我们还没有完成——这个用户将要在新的、绝密的 tutorialinux
应用程序上工作,所以让我们为这个项目创建一个组,并将新用户添加到其中。
创建一个组
要创建一个名为 tutorialinux
的新组,你可以使用 groupadd
命令:
groupadd tutorialinux
这会在系统上创建一个新组,并向 /etc/group
配置文件中添加一行,该文件记录了 Unix 系统上所有存在的组。你可以通过在该文件中“grep”(搜索)组名来验证该组是否已创建:
# grep tutorialinux /etc/group
tutorialinux:x:1001:
你可以看到,名称为 tutorialinux
的组现在已经存在,其 组 ID (GID) 为 1001。
我们不打算深入探讨这里的 x
字符的含义;只需要知道该文件由每个组的一行组成,每行以冒号分隔的值。你只需关心组名(第一列)、组 ID(第三列)和成员(最后一列,在本例中为空)。
修改 Linux 用户
就像 useradd
允许你在创建用户时自由设置用户元数据一样,usermod
和 gpasswd
允许你修改现有用户的所有方面。让我们将之前创建的 dave
用户添加到新的 tutorialinux
组中,这样他就可以处理只有组成员才能看到或修改的项目文件。
将 Linux 用户添加到组
要更改用户的主组:sudo usermod -g groupname username
然而,这并不是我们想要的:dave
用户应该继续在同名的 dave
组中;我们只是希望 dave
也能成为 tutorialinux
组的成员。要将用户添加到一个组而不将其设置为该用户的主组,可以使用 -aG
选项(“添加到附加的 组”):
sudo usermod –aG tutorialinux dave
如果你再次检查 /etc/group
,你会看到 dave
用户现在是三个组的成员:dave
、sudoers
和 tutorialinux
:
grep dave /etc/group
sudoers:x:27:dave
dave:x:1000:
tutorialinux:x:1001:dave
使用你在上一章中学到的命令修改文件的所有权和权限后,你现在可以控制 tutorialinux
组所有成员对某些文件和目录的访问权限。
现在,当特定用户完成 tutorialinux
项目工作后,你可以清理并撤销他们的访问权限,而无需修改单独的文件和目录权限。
从组中删除用户
要将用户从组中移除,我们可以使用 gpasswd
命令,如下所示:
gpasswd –delete username groupname
删除 Linux 用户
要完全删除一个 Linux 用户,使用 userdel
命令:
userdel -r account_name
如果你希望保留该用户的主目录,请省略 (-r
/ --remove
) 标志。
删除一个 Linux 组
对于不再需要的组,还有一个 groupdel
命令:
groupdel groupname
高级内容:用户究竟是什么?
用户和组是 Unix 和 Linux 中非常清楚地展示了一些非常棒的特性的地方:这里几乎没有什么魔法。
一个 Linux 用户实际上就是一个 用户 ID(UID),它是用户的简单数字表示(一个无符号的 32 位整数)。root
用户的 UID 是 0。所有其他用户的 UID 都大于 0。组也是如此。
这些信息并不是存储在某个秘密位置、某种二进制格式中,或者某个只有操作系统能够操作的专有数据结构里:用户和组是定义在纯文本文件中的,这些文件通常是通过我们在这里介绍的几个简单命令进行修改的。
这种简单性和缺乏神秘感意味着普通人(例如,一个慌乱的开发者,记忆仅能模糊回忆本章内容)可以快速了解正在运行的系统中用户和组的状态,解决可能由于主机环境准备不当导致的应用错误,特别是缺少必要应用用户的情况。这在开发者面试中的“系统工程”部分也很有帮助。
因此,为了加深你对这些内容如何工作的直观理解,下面是一些关于创建和管理用户与组时,系统底层运作的有用信息。
用户元数据 / 属性
仅通过数字定义的用户并不是特别有用——没有一些附加的元数据来增强其功能。例如,我在 Linux 或 macOS 机器上用于日常工作的账户,假设它的 UID 为 502
,可能还会有:
-
一个友好的登录名(
dave
) -
它自己的组(
dave
组) -
各种组成员身份(
staff
、developer
、wheel
) -
一个登录 shell(
bash
、zsh
等) -
一个主目录(在 macOS 上是
/Users/dave/
,在 Linux 上是/home/dave/
)
如果你感到好奇,可以通过运行 id
命令来获取当前用户的信息:
# id
uid=0(root) gid=0(root) groups=0(root)
默认情况下,几个文件定义并包含所有这些额外的用户信息:
-
/etc/passwd
包含一个用户名、UID、GID、主目录和登录 shell,每个用户一行,以冒号分隔:root@localhost:~# cat /etc/passwd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync
-
/etc/shadow
包含用户的哈希加盐密码;该文件仅对 root 用户可读:root@localhost:~# cat /etc/shadow root:$6$SPevRPxD94AYwtmF$IOp9k15dnaN8FW8RUpDDQlifLPp9pJ3btgJcMfI QEs1kT.ZNjDfX66XBOcPOBZzkRcGOb3Rwq6qTsDQ0jiZNh/:19251:0:99999:7::: daemon:*:19251:0:99999:7::: bin:*:19251:0:99999:7::: sys:*:19251:0:99999:7::: sync:*:19251:0:99999:7:::
-
/etc/group
– 与/etc/passwd
相似,但它是为组而非用户提供的。你在本章前面已经看到并使用过这个文件。
可选的主目录,如 /home/dave
# /etc/passwd username:password:UID:GID:comment:home:shell
警告:虽然了解这些文件的内容很有意义,但你绝对不应该手动编辑这些文件。请使用我们在前面章节中提到的工具来创建、删除或修改系统用户和组。
希望这个快速的理论概述——更准确地说,是构成用户和组的静态纯文本文件——能够让你有所启发。我们不仅希望确保你对这个过程有直观的了解,而且我们希望这一节强调 这个过程其实非常简单。这里并没有什么魔法!下次在排查应用程序无法启动,或者用户没有权限查看某个具有特定组所有权权限的文件时,你可以更有信心地确认自己没有遗漏什么。
关于脚本化的说明
之前,我们提到过偏好使用像useradd
这样的可自动化工具,而不是像adduser
这样的交互式向导工具——即便这些自动化工具稍微复杂一些或者不容易学习。或者你可能会问:“为什么不直接使用图形工具,而是这些难记的 CLI 命令呢?”
本书在讲解过程中,我们希望教会你的一件事是:通常优先使用非交互式命令。
因为这些命令在运行时不依赖于实时的用户输入,它们是可以脚本化的:创建一百个用户几乎和创建一个一样简单。这在处理实际问题时特别有用,比如构建 Docker 镜像、反复准备生产环境,或者为你的云实例编写cloud-init
设置脚本。
作为开发者,这一点应该深有体会:自动化让事情变得更具可重复性、更安全且更快速。通过学习非交互式命令,你能够将该命令作为更大自动化流程的一部分,而不是需要人工干预、容易出错且耗时的步骤。
结论
你刚刚学习了 Linux 如何利用用户和组的抽象来管理和控制系统上的进程、文件和其他资源。同样重要的是,你学习了在真实系统上创建和管理用户和组所需的基本命令。你了解了root
用户与系统上其他普通用户之间的重要区别。
然后,我们通过一个实际练习,带你创建一个用户,添加一个组到系统,修改该用户,最后清理你创建的所有资源。
最后,我们超越了日常命令,向你展示了背后并没有什么魔法:它们只是定义用户和组的 Unix 系统中的纯文本文件。这是好事;无论你是:
-
创建一个 Docker 镜像以便以特定的非 root 用户身份运行你的应用程序。
-
为你的数据科学团队设置一个长期运行的云实例,带有登录和共享组。
-
尽量减少在本地测试环境中犯错的影响范围。
-
解决基于用户和组的错误,例如,在一个需要 root 用户权限才能打开机密文件或执行系统特权操作的 Web 应用程序上。
在下一章,我们将利用所有这些知识深入探讨 Unix 安全模型是如何运作的,通过查看所有权和权限来实现。
在 Discord 上了解更多
要加入本书的 Discord 社区——你可以在这里分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:
第八章:所有权和权限
在本章中,你将学习如何将用户和组与所有权和权限结合起来,从而构建基本的 Linux 安全模型。这个基本的组合用于控制对 Linux 系统上几乎所有内容的访问——进程、文件、网络套接字、设备等。
首先,你将了解从长格式列表中获得的所有重要文件信息(自然地,重点是权限)。接着,我们将讲解你在生产 Linux 系统上可能遇到的常见权限,最后,我们将展示所有用于设置和修改文件权限的 Linux 命令。我们将共同完成以下内容:
-
解读长格式列表的输出
-
学习文件属性
-
理解所有权和权限的工作原理
-
理解常见的难点,“八进制”权限格式
-
学习更改所有权和权限的实用命令
解读长格式列表
让我们通过长格式列出这个话题。
有时候,当你在系统中导航时,仅看到文件和目录名称并不足够。如果你想要更多关于你看到的文件的信息,可以使用ls
命令,并加上它的“长格式”选项:ls -l
。
这是在系统的/lib
目录中运行时的输出示例。打开终端并键入ls -l /lib/
:
# ls -l /lib/
total 56
drwxr-xr-x 10 root root 4096 Mar 8 02:12 aarch64-linux-gnu
drwxr-xr-x 5 root root 4096 Mar 8 02:12 apt
drwxr-xr-x 3 root root 4096 Mar 8 02:08 dpkg
drwxr-xr-x 2 root root 4096 Mar 8 02:12 init
lrwxrwxrwx 1 root root 39 Jul 6 2022 ld-linux-aarch64.so.1 -> aarch64-linux-gnu/ld-linux-aarch64.so.1
drwxr-xr-x 3 root root 4096 Mar 4 2022 locale
drwxr-xr-x 3 root root 4096 Mar 8 02:12 lsb
drwxr-xr-x 3 root root 4096 Aug 29 2021 mime
-rw-r--r-- 1 root root 386 Feb 16 2023 os-release
drwxr-xr-x 2 root root 4096 Mar 8 02:12 sysctl.d
drwxr-xr-x 3 root root 4096 Apr 18 2022 systemd
drwxr-xr-x 16 root root 4096 Jan 17 2022 terminfo
drwxr-xr-x 2 root root 4096 Mar 8 02:12 tmpfiles.d
drwxr-xr-x 3 root root 4096 Mar 8 02:12 udev
drwxr-xr-x 3 root root 4096 Mar 8 02:12 usrmerge
我们在这里看到了很多有趣的信息——让我们逐字段地查看一下。
文件属性
第一个字段显示文件的属性:文件类型和权限。换句话说,这个字段告诉我们我们正在查看哪种类型的文件,以及它的文件权限是什么。默认情况下,这些信息以符号模式列出,与此相对的是数字模式,你可以通过-n
选项查看该模式。
文件类型
-rw-r--r-- 1 root root 386 Feb 16 2023 os-release
这里的第一个字符表示文件类型。在上面的列表中,-
字符表示普通文件。以l
开头的行表示符号链接,这只是一个特殊文件,没有自己的内容,仅指向文件系统上的另一个位置。你可以将其理解为 Windows 快捷方式或 macOS 文件别名。
其他常见的文件类型有d
,表示目录,或者c
,表示你正在查看一个字符文件——你主要会在/dev
目录下找到后者,它代表硬件输入设备,比如键盘。有关文件类型的更多信息,请参见第五章的文件系统部分。
权限
**-rw-r--r--** 1 root root 386 Feb 16 2023 os-release
这些是“权限位”,它决定了系统中哪些用户和组可以读取、写入和执行此文件。我们将在本章的权限部分深入探讨这个问题。
硬链接数
-rw-r--r-- **1** root root 386 Feb 16 2023 os-release
下一个字段显示硬链接的数量。硬链接是将文件名与实际文件链接的特殊指针。因此,在大多数情况下,文件的硬链接数量为 1。与指向文件路径的符号链接(“symlink”)不同,硬链接指向实际文件。如果你移动了符号链接所指向的文件,符号链接会失效;而硬链接即使文件被移动、重命名或其他方式修改,仍然会指向该文件。
你可能注意到,虽然大多数文件只有一个指向它们的链接,但目录在这一列中显示的链接数却差异巨大。这是因为该目录中的每个文件和子目录都会创建另一个链接引用它。即使是一个空目录,起初也有两个链接:“.”(表示“当前目录”)和“..”(表示“上级目录”)。
用户所有权
-rw-r--r-- 1 **root** root 386 Feb 16 2023 os-release
第三个字段显示了哪个用户拥有该文件;在我们的示例中,所有文件都属于root
。虽然你在示例中看到了用户名,但如果你以数字模式运行ls
命令——ls -ln
——则会看到一个数字的用户 ID,而不是这里显示的友好的用户名。
组所有权
-rw-r--r-- 1 root **root** 386 Feb 16 2023 os-release
接下来的字段显示文件的所属组,在上面的示例中,这个值也是root
。
文件大小
-rw-r--r-- 1 root root **386** Feb 16 2023 os-release
正如你可能猜到的,接下来的字段显示了文件大小。如果没有指定额外的标志,它会以字节为单位显示。为了让其更易读,可以使用-h
(“人类可读”)标志。
如果你留意过,你会发现所有目录似乎都有相同的文件大小,4096:
drwxr-xr-x 1 root root 4096 Apr 18 2022 systemd
文件可以占用任意大小的空间,但目录的存储是按文件系统块分配的。由于大多数文件系统的最小块大小为 4096,因此目录的大小通常会报告为 4096。
这方面的内容其实还有更深的细节,但我们认为这些信息在你作为软件工程师的日常工作中并不够有用,因此不需要在这里包含。如果你仍然好奇并想深入了解,可以开始阅读关于“Linux inode”的相关内容。
修改时间
-rw-r--r-- 1 root root 386 **Feb 16 2023** os-release
接下来,我们看到的是文件的修改时间戳——即文件最后一次修改的时间。
文件名
-rw-r--r-- 1 root root 386 Feb 16 2023 **os-release**
最后,我们看到的是文件名,这也是你在做常规列出时唯一能看到的内容,而不是长格式列出(ls
而非ls -l
)。通常这只是一个普通的文件或目录名称,除非是符号链接(symlink),在这种情况下,你会看到符号链接的名称和它所指向的文件路径。
所有权
要更改文件或目录的所有者,可以使用chown
(更改所有者)命令。语法为chown user:group path
,其中user
是所有者的用户名,group
是组名,path
是文件或目录的完整路径或相对路径。
你可以省略冒号和组,只更改拥有者用户的权限,而保持组权限不变。当然,试图更改文件权限的用户需要具有相应的权限,因此在大多数情况下,这条命令会以root
用户身份运行。
chown command:
bash-3.2$ ls -l mysecret.txt
-rw-r--r-- 1 root staff 0 Apr 12 15:39 mysecret.txt
bash-3.2$ sudo chown dave mysecret.txt
bash-3.2$ ls -l mysecret.txt
-rw-r--r-- 1 dave staff 0 Apr 12 15:39 mysecret.txt
权限
这是我们之前ls -l
命令列出的一个文件。我已调整权限,使这个例子更加具有说明性:
rwxr-xr-x 1 root root 386 Aug 2 13:14 os-release
具体来说,看看权限位:
rwxr-xr-x
它们这里被显示为三组三位。想象它们被分成三组,这样会更容易理解:
rwx r-x r-x
这些三位组中的每一组表示对特定用户集的读取(r
),写入(w
)和执行(x
)权限,这取决于该文件的用户和组所有权信息。如果看到-
字符代替字母,表示该操作(对于适用的用户集)是不被允许的。让我们更详细地看一下:
-
前三个位代表文件所有者的权限。在这个例子中,文件所有者(
root
)可以读取、写入和执行该文件——rwx
。 -
第二组三个位代表文件的组所有者权限,在这个例子中,组所有者也是
root
。这里的权限是r-x
,即读取和执行(不允许写入!)。但是因为root
也是文件的拥有者,所以这些(更宽松的)权限优先;root
可以对该文件进行写操作。你常看到类似这样的权限,是因为文件必须设置组所有者,如果你不希望文件与其他组共享,可以使用文件拥有者的组。在文件上,组权限通常比拥有者权限更为严格。 -
最后三个位代表所有其他系统用户对该文件的权限(“世界”)。这通常是最严格的权限设置,因为大多数文件不需要与所有者以外的任何人共享(有时也包括单独的组所有者)。在这个例子中,我们看到的是一个共享库文件,需要让所有系统用户都能访问,因此权限是
r-x
(读取和执行,不允许写入)。
数字/八进制
“读取”,“写入”和“执行”是使权限易于理解的术语,但在 Linux 和 Unix 中,权限还有另一种重要的表示方式:八进制。
作为开发人员,你可能对非十进制数制比较熟悉——八进制仅仅是一个基于 8 的系统(与我们通常使用的基于 10 的十进制系统相对,也不同于计算机使用的基于 2 的二进制系统)。
由于每个三位权限组合只有八种可能的状态,八进制是一个非常高效的表示它们的系统。
想象我们的 9 位,依然分为 3 位一组。每一组 3 位权限块可以表示一个八进制数字,而表示一个八进制数字需要三个位。这样,我们的 9 位刚好能表示三个八进制数字——一个用于用户权限,一个用于组权限,一个用于其他/世界权限。
八进制 | 二进制 | 含义 |
---|---|---|
0 | 0 | 无 权限(— ) |
1 | 1 | 执行 权限(–x ) |
2 | 10 | 写 权限(-w- ) |
3 | 11 | 写和执行 权限(-wx ) |
4 | 100 | 读 权限(r– ) |
5 | 101 | 读和执行 权限(r-x ) |
6 | 110 | 读写 权限(rw- ) |
7 | 111 | 读、写和执行 权限(rwx ) |
表 8.1:八进制权限
你会注意到,这样布局是为了让八进制加法能够工作——将“读”(4)和“执行”(1)加在一起得到“读和执行”(5)。
这可能看起来很奇怪且随意(而且确实是!),但你很快就能掌握它。你在工作中大多数情况下会使用 7(所有)、6(读/写)、5(读/执行)、4(读)和 0(无权限)。
常见权限
你会看到的最常见权限是:
-
-rw-r--r--
(644):所有者可以读写;其他人只能读取。 -
-rwxr-xr-x
(755):所有者可以做任何事情;其他人可以读取/执行文件。这是可执行文件(如脚本和二进制文件)和目录的默认权限。 -
-rw------
(600):只有所有者可以读写,其他人无法对其进行任何操作。这个权限通常用于密钥、包含密码的文件和其他敏感信息。例如,SSH 不会使用对组或世界可读的密钥,直到你更改其权限以使其变为私密。
更改所有权(chown)和权限(chmod)
你将使用两个命令来更改文件的所有权和权限:chown
和 chmod
。
Chown
chown
(更改所有者)用于更改文件的所有者和组。使用方式如下:
chown [OPTION]... [OWNER][:[GROUP]] FILE...
例如,假设我们有这个文件:
$ ls -lh testfile
-rw-r--r-- 1 dave dave 10 Aug 14 16:18 testfile
更改所有者
让我们将所有者更改为 chris
(假设系统中有一个 chris
用户):
$ chown chris testfile
$ ls -lh testfile
-rw-r--r-- 1 chris dave 10 Aug 14 16:18 testfile
更改所有者和组
我们已经更改了所有者,但如果我们还想更改组,也可以运行:
$ chown chris:staff testfile
$ ls -lh testfile
-rw-r--r-- 1 chris staff 10 Aug 14 16:18 testfile
递归地更改所有者和组
一个常见的任务是更改某个目录下所有文件的所有者和组。你可以使用 -R
或 --recursive
选项来做到这一点:
$ chown -R dave:staff /home/dave
这将递归地设置 /home/dave/
及其内部每个文件和目录的所有权。
Chmod
chmod
(更改模式)用于更改文件的权限。你可以在这里使用常规权限或八进制权限:
chmod [OPTION]... MODE[,MODE]... FILE...
chmod [OPTION]... OCTAL-MODE FILE...
你的选项可以用以下格式给出:ugo{+,-}rwx
-
ugo
(用户、组、其他——如果没有指定,假设是所有三个)。 -
+
用于添加权限,-
用于移除权限。 -
rwx
(读、写、执行——这些字母的任何或所有组合)。
例如,要为拥有文件的用户添加执行权限:
$ chmod u+x testfile
$ /tmp ls -lh testfile
-rwxr--r-- 1 dave dave 10 Aug 14 16:18 testfile
要为组和所有其他用户添加写入和执行权限:
$ chmod go+wx testfile
$ /tmp ls -lh testfile
-rwxrwxrwx 1 dave dave 10 Aug 14 16:18 testfile
哎呀,我们其实是想移除其他用户的所有权限:
$ chmod o-rwx testfile
$ /tmp ls -lh testfile
-rwxrwx--- 1 dave dave 10 Aug 14 16:18 testfile
权限的八进制格式可以像你预期的那样设置:
$ chmod 744 testfile
$ /tmp ls -lh testfile
-rwxr--r-- 1 dave dave 10 Aug 14 16:18 testfile
事实上,让我们将这个文件设置为只读:
$ chmod 400 testfile
$ /tmp ls -lh testfile
-r-------- 1 dave dave 10 Aug 14 16:18 testfile
使用引用
chown
和 chmod
都允许我们使用 --reference
参数,通过该参数我们可以传递一个文件,从该文件复制所有权或权限。
结论
在本章中,我们涵盖了你需要知道的解决最常见 Linux 权限问题的所有内容:你学习了如何查看文件的权限,以及如何修改它们。更重要的是,我们向你展示了如何推理权限问题,以及它们如何与 Linux 用户和组相关联,这也是许多人容易犯错的地方。
确保你已经牢牢掌握本章的内容;在你的职业生涯中,解决问题的大部分时间都将围绕文件所有权和权限问题展开。幸运的是,大多数这类问题源于对基本概念的误解,而你现在已经不再困惑。去吧,去解决问题吧!
在 Discord 上了解更多
要加入本书的 Discord 社区——你可以在这里分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第九章:管理已安装的软件
在各种 Linux 或 Unix 环境中工作时,你需要添加或移除软件。通常通过包管理器完成,尽管在紧急情况下,你可能需要使用其他方法。
你很可能熟悉在编程环境中管理库的工具——npm
、gem
、pip
、go get
、maven
、gradle
等。这些包管理器都遵循与 Linux 和 Unix 中的包管理器相同的原理。
软件包管理器抽象了构成软件的众多配置和二进制文件,让你可以使用一个整洁的“包”来进行操作。如果你来自 Windows(.exe
或 .msi
安装程序)或 macOS(.dmg
安装程序),这应该很熟悉。
此外,大多数 Linux 包管理器在过程中增加了一层安全性,通过:
-
使用安全传输(TLS)下载。
-
使用包本身的加密签名,以证明作者至少是他们所声称的那样(无论你是否信任他们)。
各种 Linux 发行版还开创了可搜索的软件包仓库的理念,帮助用户找到可以下载的软件,这一理念也启发了如今的 Apple 和 Microsoft “应用商店”。
在本章中,我们将涵盖以下内容:
-
什么是包管理器
-
你最常见的包管理器
-
你需要执行的最重要的包管理操作和各个命令,跨包管理器“翻译”——这就是你在实践中需要知道的 90%内容。
-
一种常见的下载并执行自定义安装脚本的过程。
-
快速的实际介绍,教你如何自己构建和安装软件。
如果你正在寻找 Docker 中安装软件的说明,请查阅第十五章,使用 Docker 容器化应用程序。
首先:作为开发者,当你想做一些常见的事情时,你会使用包管理命令:
-
安装新的软件包,例如,应用程序期望在其执行环境中可用的依赖项。
-
检查已安装的软件包(例如,“nginx web 服务器是否已经安装在此系统上?”)。
-
更新当前安装的软件包集合,确保你拥有所有软件的最新版本。这通常用于修复已发现的漏洞,或确保你拥有软件的所有最新功能。
-
移除一个包——即从系统中卸载它。
让我们深入了解如何实现这些实际目标,并查看你将要使用的实际命令。
使用软件包
在我们深入实际命令之前,你应该了解,你将使用的确切命令会根据你使用的 Unix 版本(或 Linux 发行版)有所不同。不同的 Linux 发行版使用不同的包管理器,尽管它们的语法稍有差异,恰好足以让人感到烦恼,但它们的工作方式几乎完全相同。常见的包管理器包括:
-
homebrew
(macOS) -
apt
(可靠地出现在 Ubuntu 和基于 Debian 的系统中,即使是没有安装 aptitude 的最小系统) -
pacman
(Arch) -
apk
(Alpine)
在接下来的实践部分中,我们将介绍我们想要实现的高级目标(例如“安装特定软件包”),然后展示使用我们刚才提到的流行包管理器来完成该任务的确切命令。
更新本地仓库状态缓存
在安装或删除软件包之前,你需要确保本地软件包缓存(你系统中记录的可用软件包信息)是最新的。
例如,如果你正在尝试安装 nginx
网页服务器,而上次更新本地软件包缓存的时间已经过去一个月,那么你可能会不小心安装一个过时的版本,而不是这周的新版本。
要更新缓存,找到下面列表中的包管理器并运行相应的命令:
包管理器 | 命令 |
---|---|
homebrew |
brew update |
apt |
apt update |
pacman |
pacman -Sy |
apk |
apk update |
本地可用软件包缓存将被更新,更新完成后你就可以开始激动人心的工作——查找和安装软件包。
搜索软件包
并非所有软件包的名称都与它们包含的软件相同。Firefox 可能在 firefox
包中提供,但如果你尝试安装 ag
(包名:silversearcher-ag
),你可能会失望。使用以下包管理器命令搜索你考虑安装的软件包的描述:
包管理器 | 命令 |
---|---|
homebrew |
brew search $PACKAGENAME |
apt |
apt-cache search $PACKAGENAME |
pacman |
pacman -Ss $PACKAGENAME |
apk |
apk search $PACKAGENAME |
这是一个确认你所期望内容是否正确的好方法,但它也可以用来扩展你的搜索范围,寻找与问题相关的通用软件。例如,在 Ubuntu 上,我可以使用 apt-cache search grep
来搜索类似 grep 的工具。任何包含 grep 的软件包名称或描述都会显示出来。
安装软件包
最终的高潮!现在我们的仓库缓存已更新,并且我们已经明确知道要安装哪个软件包,让我们运行 install
命令:
包管理器 | 命令 |
---|---|
homebrew |
brew install $PACKAGENAME |
apt |
apt install $PACKAGENAME |
pacman |
pacman -Sy $PACKAGENAME |
apk |
apk add $PACKAGENAME |
如果你的包管理器提示确认,回应提示后,你的包就会被安装(同时也会安装它依赖的其他包)。
由于这些命令在安装包之前会提示确认,它们可能会阻塞脚本。当你使用脚本自动化任务并且需要安装包时,确保阅读相应包管理器的手册,了解如何以非交互式的方式安装包。这通常通过环境变量或命令的额外参数来完成。
升级所有有可用更新的包
在长期运行的系统上,你可能需要偶尔将已安装的包升级到最新版本。这可以修复已知的漏洞,获取最新的功能,并防止不同系统因配置时间的不同而导致状态不一致:
包管理器 | 命令 |
---|---|
homebrew |
brew upgrade |
apt |
apt dist-upgrade |
pacman |
pacman -Syu |
apk |
apk upgrade |
这些命令也会提示确认,因此如果你在脚本中使用它们,同样的建议是:可以添加一个选项让它们不再交互式。例如,apt –y dist-upgrade
将不会等待手动确认,而是直接执行升级。
移除一个包(以及它的所有依赖,前提是其他包不再需要它们)
有时候你可能想要卸载一个包:也许你只是试用它,或者你的应用需求发生了变化,亦或是它被认为存在漏洞且没有修复方案。所有包管理器都有用于移除已安装包的命令:
包管理器 | 命令 |
---|---|
homebrew |
brew remove $PACKAGENAME |
apt |
apt remove $PACKAGENAME |
pacman |
pacman -Rs $PACKAGENAME |
apk |
apk del $PACKAGENAME |
在移除包之前或之后,你可能需要验证该包是否已经安装。让我们现在来看一下如何操作。
查询已安装的包
如果你需要列出当前系统上所有已安装的包,你可以通过一个命令完成:
包管理器 | 命令 |
---|---|
homebrew |
brew list |
apt |
dpkg –l |
pacman |
pacman -Qi |
apk |
apk info |
因为这个列表通常有数百或数千个包,所以它可能有些笨重。通过将输出传递给像 grep
这样的搜索命令来缩小范围,找出你所需要的内容:
dpkg -l | grep silversearcher
ii silversearcher-ag 2.2.0+git20200805-1 arm64 very fast grep-like program, alternative to ack-grep
如果你不明白我们是如何使用管道将 dpkg
的输出传递给 grep
命令的,参见第一章,命令行工作原理,了解如何使用管道字符(|)将命令串联起来。我们还将在即将到来的第十一章,管道与重定向中深入探讨这一机制。
既然我们已经介绍了你在 90% 时间内使用的基本包管理命令,现在是时候展示一些当你想安装的软件没有现成包时你会使用的模式。
注意事项 – curl | bash
有时你找不到需要的软件的预构建包。没关系!许多在线资源——即使是像 macOS 上的 homebrew 这样值得信赖且流行的资源——也推荐类似这样的命令行安装过程:
curl $SOMEURL | bash
这使用 curl
命令从网络上下载内容,然后将该内容作为输入(|
,管道符号,我们在第一章《命令行工作原理》中讲过)来运行 Bash。当你这样做时,你实际上是在运行一个网络上的脚本,而不是一个本地文件。这可以是安装软件的一个非常方便的方法,但请务必确保它来自一个可信的来源。
我们建议始终至少查看脚本源代码,你可以通过访问该命令的脚本网址(在下面的示例中表示为$SOMEURL
)在浏览器中查看,或者将单个 curl $URL | bash
命令拆分成多个命令,以便你可以:
-
下载脚本。
-
在本地文本编辑器中阅读脚本,确认它没有做任何恶意操作,必要时编辑脚本以满足你的需求。
-
运行脚本,前提是你已经验证过它只会执行你想要它执行的操作。
要将类似 curl $URL | bash
的模式拆分成多个命令,你需要执行以下步骤:
# Download the installer and name the resulting file installer.sh
curl $SOMEURL -o installer.sh
# Read and optionally modify the script using a text editor like vim
vim installer.sh
# Make the script executable and run it
chmod +x installer.sh
./installer.sh
通过将这一步骤拆分成多个步骤,而不是直接下载一个不可信的脚本并立即运行,我们给自己留出了时间来审查我们即将执行的代码并确认它是安全的。最终结果是一样的(安装脚本会运行),但这种方法使我们有更多的控制权,并且需要更少的盲目信任。
还有另一种在系统上安装软件的方法,即使没有现成的安装脚本可用。
从源代码编译第三方软件
这是在系统上安装软件最手动、最传统的方法——手动编译和安装!它没有像包管理器那样的许多优点,比如速度、可重复性、管理已安装软件的简便性以及对安装的二进制软件的加密验证。
但在紧急情况下,它仍然是安装软件最可靠的方式,除了你作为开发者已经熟悉的基本软件工具(编译器、链接器和 make 脚本),没有其他真正的外部依赖。
当你遇到以下情况时,你可能会手动编译和安装软件:
-
包管理器中没有预打包版本的软件。例如,如果你使用的是轻量级容器发行版(如 Alpine),可能在包管理器中找不到所需的软件。在这种情况下,你可以从源代码编译自己的二进制文件,并将其添加到容器镜像中。
-
你需要将自己的(或其他自定义的)软件添加到 Docker 容器中。
-
当你需要一个软件的绝对最新、前沿版本,而包管理器尚未提供时,你会选择这种方式。这种情况通常出现在进展较慢的项目中,这些项目不会及时提供新的软件包,或者在需要紧急修复关键漏洞之前,需要立即发布热补丁。立即,在热补丁通过打包流程之前。
这个过程涉及几个步骤,具体步骤会因软件而异。
通常情况下,这包括:
-
使用
curl
或wget
下载压缩软件存档。 -
运行
tar zxf downloadname
或unzip downloadname
解压刚刚下载的源代码目录。 -
切换到你下载的源代码目录,并阅读包含的
README
文件。这里会告诉你构建软件所需的确切步骤,以及与我们描述的规范有所偏差的任何内容。 -
运行
./configure
,然后是make
,再接着是sudo make install
来构建并安装二进制文件。
与其他安装软件的方式一样,无论是手动还是通过包管理器,记住 configure
和 make
都会按设计执行任意代码。这意味着以 root 身份运行 make install
将导致所有此类任意代码以 root
身份运行。这应该让你担心。确保验证软件源代码的可信度,并从可信任的来源下载。
示例:编译和安装 htop
为了演示这一过程,我们将下载、编译和安装 htop
,这是一个小巧而非常实用的系统监视工具(类似于内置的 top
命令,但功能更强大)。需要说明的是,几乎所有 Linux 发行版的包管理器都可以轻松获取到它,但我们将假装它是一个难以获取的自定义程序,不通过包管理器广泛分发。
我们正在使用的系统是 Ubuntu 22.04 Linux 服务器,如果你想跟随操作而不必自行解决问题,请使用该系统。
首先,我们在这里的官方 GitHub 仓库检查最新版本发布情况:github.com/htop-dev/htop/releases
—— 在撰写本文时,最新版本是 3.2.2。
现在,你应该为这次构建创建一个目录,以保持整洁 —— 我建议在 /tmp
目录中创建一个,这个目录保存临时文件,在系统启动时会清空其内容:
mkdir /tmp/htopbuild && cd /tmp/htopbuild
这样一来,一旦构建完成,我们可以删除所有文件,避免系统堆积旧构建的垃圾文件。现在我们准备好开始了。
安装前提条件
首先,我们需要安装基本的 C 开发工具链(编译器、链接器、make 及其他工具——你在 Linux 上编译 C 代码所需的所有东西)。在 Ubuntu 上,可以通过安装一个元包——一个多个其他软件包的别名——build-essential
来实现:
sudo apt install build-essential
我们还将安装其他一些工具:wget
用于从网络下载文件,ncurses
开发库,htop
用它来提供响应式的命令行界面:
sudo apt install wget libncurses-dev
下载、验证并解压源代码
首先,我们将下载源代码并验证其加密签名,以确保它是由开发者的密钥签署的正版发布:
wget https://github.com/htop-dev/htop/releases/download/3.2.2/htop-3.2.2.tar.xz
这为我们提供了压缩的源代码目录,我们将把它编译成二进制文件。
现在让我们通过检查签名来确保我们有一个开发者批准的发布版本,签名就是源代码的sha256
哈希值。
下载包含此版本预期哈希值的文件并将其打印到终端:
wget https://github.com/htop-dev/htop/releases/download/3.2.2/htop-3.2.2.tar.xz.sha256
cat htop-3.2.2.tar.xz.sha256
如果你和我们在这个示例中使用的是相同版本,你会看到这个哈希值:
bac9e9ab7198256b8802d2e3b327a54804dc2a19b77a5f103645b11c12473dc8 htop-3.2.2.tar.xz
现在,使用sha256sum
工具对我们下载的源代码进行哈希处理,验证哈希值是否匹配:
sha256sum htop-3.2.2.tar.xz
bac9e9ab7198256b8802d2e3b327a54804dc2a19b77a5f103645b11c12473dc8 htop-3.2.2.tar.xz
太好了!我们现在知道我们拥有的软件与我们想要下载的官方版本是相同的。接下来,让我们解压源代码目录并进入其中:
tar xf htop-3.2.2.tar.xz
cd htop-3.2.2
如果你有兴趣,现在是时候阅读Readme
文件(关于程序的一般信息)和INSTALL
文件(关于如何构建和安装程序的说明)。
现在我们准备好开始配置和编译这个软件了!
配置并编译 htop
在源代码目录内部,现在是时候运行./configure
脚本了。这个脚本确保我们安装了编译所需的依赖项(共享库、工具等),并为接下来的编译做好配置:
./configure
这将在脚本运行时产生输出,检查各种依赖项,并确保你的环境满足编译所需的所有条件。
如果这个脚本产生错误,请仔细阅读:通常它会清楚地告诉你问题所在——可能是缺少库或操作系统设置有问题。在修复它报告的问题后,重新运行它。当它成功运行完毕后,你就可以开始编译htop
二进制文件了:
make
这将在编译脚本运行时产生大量输出。如果你对 makefile 完全陌生,它们是一个非常有用的自动化工具,开发者广泛使用。这里有一个很棒的教程:makefiletutorial.com/
一旦编译完成,我们就可以安装刚才创建的htop
二进制文件(它会在主源目录中,名为htop
)。通常,有一种自动化的方式可以完成此操作:
sudo make install
需要使用sudo
,因为你正在将编译好的二进制文件移动到一个受保护(root 拥有)的目录。之后,你可以通过输入以下命令来验证htop
是否已经安装并正常运行:
htop
你应该会看到一个美观的基于终端的图形用户界面(得益于ncurses
库),显示系统当前的 CPU 负载、内存使用情况和进程列表。
对于没有提供完整功能install
命令的程序,你可以依赖 Linux 没有什么魔法的事实,直接通过将二进制文件移动到/user/local/bin/
目录下来安装它,这里是本地编译的二进制文件的存放位置:
mv htop /usr/local/bin/
通过看到这个过程有多么简单,你现在已经掌握了所有必要的知识,能够继续进行编译!
结论
在本章中,你学习了如何管理已安装的 Linux 环境中的软件基础知识。首先,我们看到了如何通过你最有可能遇到的包管理器轻松完成这项工作。虽然这种方法应该能满足你 90%的需求,但接着你还了解了针对最后 10%的情况所需要采用的程序——谨慎筛选,接着使用自定义安装脚本或手动编译和安装。
希望你跟随了实际的编译示例,并试用了htop
系统监视器。幸运的是,htop
在各大包管理器中都有提供——它是一个非常有用的工具,许多系统管理员在长期运行的生产系统中觉得它不可或缺。
你现在应该对有效使用许多 Unix 和 Linux 系统(无论是在开发还是生产环境中)所需的高层次概念和实用命令感到熟悉。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第十章:配置软件
迟早,每个人都需要在 Linux 系统上配置软件。虽然有许多方法可以做到这一点,但幸运的是,你可以遵循一个通用模式来获得你想要的结果。在 Linux 上,这尤其常见;因为 Linux 中的许多标准工具遵循“小而尖锐的工具”哲学,趋向于大量的小而强大的程序,通过支持广泛的配置提供灵活性。
在本章中,你将了解设计良好的程序通常使用的配置层级结构。无论你是需要查看手册页面找到某个命令的命令行参数,还是想设置一个适用于你在 shell 中运行的所有命令的环境变量,你将看到如何操作。
接下来,我们将向你展示几乎所有 Unix 软件使用的通用配置层级结构,这样你就能始终知道该检查哪里,如果程序的行为与你根据配置期望的不一致。
最后,你将看到这种配置如何转化为通过 systemd
管理的程序,systemd
是 Linux 上最流行的服务管理工具。
总结来说,本章将涵盖以下主题:
-
配置层级结构
-
命令行参数
-
环境变量
-
配置文件
-
Docker 中的配置
配置层级结构
在 Linux 上运行程序时,你首先要做的事情之一就是根据你的具体需求调整它们。事实上,你已经做到了这一点:通过向 ls
、grep
等命令传递参数,你已经改变了这些程序的行为。
你可能对这一过程有直观的理解,因为你一生都在使用软件。例如,你可能会觉得传递命令行参数来覆盖程序默认值是很自然的:ls -l
给出的输出不同于 ls
的默认输出。
现在让我们更严格地探讨一下这个直觉,并看看能否为在 Unix 环境中配置一般工作方式绘制一些启发式的规则。大多数标准 Unix 命令行程序遵循的规范之一是特定的配置层级结构,其中较早的值会被后面的值覆盖。如果你编写过需要用户配置的软件,可能已经创建过类似这样的优先级层级:
-
将可配置的值设置为内置默认值。
-
检查通过配置文件传递的值,覆盖这些默认值。
-
检查环境变量(人们可能会称这些为
env vars
),覆盖配置文件和早期的值。 -
检查命令行界面参数(你可能会听到这些被称为 CLI 参数),并根据需要更新值,覆盖早期的值。
每个后续级别都更接近运行软件的用户,因此每个后续级别都会优先于之前的级别。
例如,如果你的软件在配置文件和程序启动时传递的 CLI 参数中检测到冲突的值,它应该优先选择命令行参数中的值。换句话说,离程序调用更近的值会“覆盖”(在遮蔽或替换的意义上)离执行更远的值。CLI 参数值会替代配置文件中的值,因为配置文件离调用点比传递给程序的参数更远。这应该是直观的:如果软件忽略了你的命令行标志,偏向使用程序默认设置,那你是不能依赖这个软件的。ls -l
不应该和 ls
给你相同的输出。
大多数 Linux 上的软件都遵循这个层次结构,当有多种方式来配置该软件时。请记住,并非所有软件都会使用我们在这里展示的所有配置路径,也并非所有软件都会严格遵循这个配置顺序。
让我们再看一下这个层次结构,这一次将它与具体的nginx Web 服务器程序的实际例子联系起来。你可能会在职业生涯中某个时刻使用 nginx,因为它是世界上最流行的 Web 服务器之一,广泛用于动态 Web 应用程序的前端。让我们看看我们刚才讨论的每个优先级层次是如何映射到实际的 nginx 配置的:
-
内置默认值:启动后,
nginx
执行的用户的硬编码默认值是nobody
。 -
全局配置文件 可以为所有 nginx 进程更改这个设置,因此通常会在
/etc/nginx/nginx.conf
找到一个全局 nginx 配置文件,其中包含 “user www;
” 的值,指示 nginx 以www
用户身份运行。 -
用户级别配置文件 通常是“点文件”(以
.
开头的文件,这使得它们不会出现在常规ls
列表中),位于用户的/home
目录中。例如,/home/dave/.bashrc
是存放用户特定 bash 配置的地方。nginx
是一个长期运行的进程,通常不会以常规 Linux 用户身份运行,但它确实有类似的东西:各个站点通常会在它们自己的独立配置文件中配置,通常位于/etc/nginx/conf.d/yourwebsite.conf
。这些文件通常会继承来自上一层的全局配置值。 -
环境变量。
nginx
从名为TZ
的环境变量获取时区信息。 -
命令行参数是在软件运行时指定的,无论是手动运行还是自动运行(例如通过 cron 或单元文件)。在调试问题时,务必查看这些可能的外部命令行参数来源——它们往往是导致程序行为和配置文件不一致的常见原因。
nginx
接受各种命令行参数来修改其行为:从覆盖它将使用的配置文件,到完全阻止其作为 Web 服务器运行,转而通知已运行的 nginx 进程停止或重新加载。
现在你已经看到了这一配置层级如何与 Linux 上你运行的所有程序及你可能为其编写的所有程序进行交互的理论和实践方面,让我们一步步地深入了解这个配置层级,仔细看看每一层。我们将从最直接、最强大的配置形式开始,它会覆盖所有其他方式:在调用程序的时刻传递命令行参数。
命令行参数
你已经熟悉了配置程序的最常见方式:使用命令行参数。这些参数在程序被作为 Shell 命令调用时配置该程序。
要查找程序的有效命令行参数,可以从你命令的 man(手册)页面开始。除了最简化的系统外,Unix 软件通常会随附手册页面,记录大多数程序,解释可用的标志,并且—通常在末尾—列出其他配置方法,比如配置文件。
让我们来看看 find
命令的手册页面开头内容:
man find
FIND(1) General Commands Manual FIND(1)
NAME
find – walk a file hierarchy
SYNOPSIS
find [-H | -L | -P] [-EXdsx] [-f path] path ... [expression]
find [-H | -L | -P] [-EXdsx] -f path [path ...] [expression]
DESCRIPTION
The find utility recursively descends the directory tree for each path listed, evaluating an expression (composed of the "primaries" and "operands" listed
below) in terms of each file in the tree.
The options are as follows:
-E Interpret regular expressions followed by -regex and -iregex primaries as extended (modern) regular expressions rather than basic regular expressions
(BRE's). The re_format(7) manual page fully describes both formats.
-H Cause the file information and file type (see stat(2)) returned for each symbolic link specified on the command line to be those of the file
referenced by the link, not the link itself. If the referenced file does not exist, the file information and type will be for the link itself. File
information of all symbolic links not on the command line is that of the link itself.
你可以看到,这份手册页面大部分内容都记录了运行find
时可以使用的各种命令行参数。自从第一章以来,你已经使用了大量命令行参数,因此这些内容应该都很熟悉。
让我们来看下一种稍微更远一点的配置方式:环境变量。
环境变量
虽然命令行参数非常强大,但它只适用于包含它的单次程序调用。当你输入ls -l
时,只有这一次ls
命令会显示长格式输出。但如果你希望某个配置值在多次命令调用中保持有效呢?举个例子,如果你在编写一个脚本,它将在不同的地方安装包,你希望设置一个配置选项一次,而不是每次运行安装包命令时都反复添加它作为命令行参数。这时,环境变量就派上用场了。
作为一名开发人员,您可能已经知道环境变量:类似于其他编程语言中的变量,环境变量是 shell 中的值。它们与命令行参数不同,因为它们作用于更高的层级。环境变量为您提供了更多的灵活性:一旦在 shell 中设置了一个配置变量,它就适用于该 shell 会话中的所有程序调用。设置一次后,任何查找该环境变量的程序每次运行时都会遵守它,直到变量改变或您结束 shell 会话。
注意
我们将在第十二章《使用 Shell 脚本自动化任务》中深入讨论环境变量,但本节涵盖了基本内容。
大多数标准的 Unix 环境使用环境变量作为指定许多不同程序相关常见配置的一种方式,而不仅仅是一个。例如,环境变量可以跟踪用户的 /home
目录在哪里($HOME
),当前工作目录在哪里($PWD
),默认使用哪个 shell($SHELL
),在哪里查找与通过命令行接口(CLI)接收到的命令对应的可执行文件($PATH
)等等。
现在可以随时检查它们;您可以通过 echo
命令打印出来查看特定环境变量的值:
$ echo $SHELL
/bin/zsh
或者,您可以使用 env
命令查看当前设置的所有环境变量:
$ env
...
# many lines of output, one for each of your environment variables ...
要在当前 shell 中设置环境变量,只需使用 =
进行赋值(确保等号两侧没有空格):
MYVAR=fruitloops
您已为当前 shell 设置了它:
$ echo $MYVAR
fruitloops
要使此变量在您启动的任何子 shell 中持久存在(例如,在运行脚本时),请使用 export
内建命令:
export MYVAR=fruitloops
您将在第十二章《使用 Shell 脚本自动化任务》中学到更多,但上述命令是您需要将环境变量配置传递给大多数与之交互的程序的全部内容。
回到 find
示例:如果您向下滚动到 find
手册页中的足够远的地方(我们在上一节中查看了该手册页),您会看到一个标题为 ENVIRONMENT
的部分:
ENVIRONMENT
The LANG, LC_ALL, LC_COLLATE, LC_CTYPE, LC_MESSAGES and LC_TIME environment variables affect the execution of the find utility as described in environ(7).
这是另一层配置——这些配置指令不是在运行时作为命令参数传递,而是可以从 shell 环境变量中读取。
为什么程序应该将环境变量与命令行参数区别对待?让我们想一想:命令行参数 –H
非常具体,因为它是在命令调用层面定义的。因此,它仅适用于当前正在运行的命令。
另一方面,环境变量则不那么具体。它们在 shell 层面上定义,因此可以被从该 shell 运行的所有命令访问。
让我们继续向上走配置层级:如果某个值在运行时没有通过命令行参数或在启动程序的 shell 会话中作为环境变量设置,那么配置来自哪里呢?
配置文件
程序寻找配置文件的下一个地方是在其配置文件中。程序寻找配置的地方可能会有所不同,但有一些标准的位置是可以查找的。
系统级配置在/etc/
首先,/etc/
目录是一个不错的起点。你在第五章,介绍文件中见过这个目录。/etc/programname
——其中programname
是你想配置的程序的名字——是软件存放系统范围配置的常见目录。对于许多程序来说,这已经足够了。例如,nginx Web 服务器是一个系统级程序:不同的用户通常不会在同一台机器上运行自己的 Web 服务器实例,所以只需要一个系统范围的配置。
话虽如此,大型或复杂程序的配置仍然可以在/etc/programname
目录中拆分。Nginx 就是一个很好的例子;它的主配置文件位于/etc/nginx.conf
,额外的配置文件则从/etc/nginx/conf.d/
目录中的附加文件中加载。
用户级配置在~/.config
对于有显著每用户配置的程序——比如文本编辑器、开发工具、游戏等——~/.config
目录是用来存放这些配置的。回想一下第一章,命令行的工作原理,~
是“当前用户的主目录”的简写,而以点字符(“.
”)开头的目录,除非传递-a
标志,否则在ls
输出中会被省略。~/.config
目录是 XDG 基础目录标准的一部分,你可以在这里获取概述:wiki.archlinux.org/title/XDG_Base_Directory
。
作为一个例子,我的neovim
配置与其他开发者的配置有显著不同,但系统上的单个 neovim 二进制文件可以支持数百名开发者在同一台机器上同时工作,因为每个开发者启动 neovim 时都会使用保存在~/.config/nvim/
中的用户特定配置文件。这很好!
你可以想象,如果只有一个系统范围的地方来配置这个程序在/etc/
目录下会引发怎样的混乱——每个开发者都必须在运行 neovim 编辑器之前设置无数的环境变量,或者通过无数的命令行标志来调用编辑器命令。
现在你已经了解了 Unix 程序的经典配置来源,让我们来看一下一个 Linux 特有的复杂问题:如何管理通过环境文件和 CLI 参数配置的程序,这些程序通过systemd
进行控制。
systemd 单元
在大多数 Linux 发行版中——除了 Docker 容器——systemd
负责管理所有服务。我们已经在本书中讨论了systemd
的基础知识(参见第三章《使用 systemd 管理服务》),在这一节中,我们将快速了解systemd
如何管理程序的配置。
首先,快速回顾一下,以防第三章看起来有些遥远:在一个由 systemd 管理的 Linux 环境中,服务被打包进systemd
单元文件中,这些文件封装并控制实际的可执行二进制文件、其参数、启动、重启和停止单元的命令等。
如我们已经讨论过,systemd 单元类型有很多种,但我们在这里关注的是service
单元类型。
我们已经讨论过,单元文件可以存在于多个不同的目录中,具体取决于它们的用途,但你自己的自定义 systemd 单元通常会存放在/etc/systemd/system
中。
为了理解 systemd 单元如何影响我们在本章中讨论的配置层次结构,让我们通过编写一个名为yourprogram
的虚拟程序的 systemd 单元,来创建一个由 systemd 管理的服务。
创建你自己的服务
作为开发者,你可能需要将你编写的程序包装成一个服务,这样比手动(交互式)调用程序更容易管理。这本身是非常有用的,但在本章中,我们深入探讨了 systemd 单元为你提供的额外控制,如何以及在哪里配置你的程序。让我们通过将二进制文件与systemd
单元文件结合,来走一遍创建服务的过程。
首先,确保你已经将可执行文件复制到一个默认的 $PATH
目录中:/usr/local/bin/yourprogram
。为了最大化效果,可以使用一个手动编译的程序,如前面第九章《管理已安装软件》中创建的htop
二进制文件,并将假设的yourprogram
替换为htop
。
现在,在/etc/systemd/system/yourprogram.service
路径下创建以下systemd
单元文件:
[Unit]
Description=Your program description.
After=network-online.target
[Service]
Type=exec
ExecStart=/usr/local/bin/yourprogram -clioption=1 –clioption2
EnvironmentFile=-/etc/yourprogram/prod_defaults
Restart=on-failure
[Install]
WantedBy=multi-user.target
你能在这个单元文件中找到与配置相关的两行吗?
你可以看到,ExecStart
行指定了当有人启动这个 systemd 服务时,程序是如何被调用的。我们使用 systemd 单元文件将命令行参数传递给程序,确保每次有人启动服务时,程序都会以我们希望的选项运行。每当有人运行systemctl start yourprogram
时,我们已确保yourprogram
会被调用,带有-clioption=1
和-clioption2
。
其次,EnvironmentFile
行指定了systemd
检查的文件路径,在该文件中,它可以找到与此程序相关的环境变量。这些变量将由systemd
用来运行二进制文件的 shell 解析,它应包含类似于以下的 shell 变量赋值:
# yourprogram environment variables
ENV=production
DB_HOST=localhost
DB_PORT=5432
让 systemd 重新读取它的配置文件,确保它能看到你定义的新的服务Unit
:
$ sudo systemd daemon-reload
现在,你可以像管理任何其他systemd
服务一样管理这个服务:
systemctl start yourprogram
systemctl status yourprogram
systemctl stop yourprogram
systemctl enable yourprogram
systemctl disable yourprogram
您知道每次启动该服务时,位于/etc/yourprogram/prod_defaults
的环境文件将用于源环境变量,而ExecStart
行将传递您指定的命令行选项。
我们在这里展示了一个非常简单的服务,旨在帮助您理解如何使用 systemd 来控制程序配置,但这里有许多其他配置指令可以传递。如果您手头有更复杂的服务,花些时间阅读 systemd
单元文档(www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#%5BUnit%5D%20Section%20Options
)。
快速提示:Docker 中的配置
本章早些时候提到过,Docker 在配置方面通常是一个例外。因为 Docker 容器是一个更为简化的环境,它们没有传统 Unix 系统中那种大量的额外二进制文件、服务和配置文件。但由于现在许多软件开发者创建的软件运行在容器中,而不是传统的完整操作系统环境中,我们希望在这里介绍一些基础知识,确保您对 Docker 容器中的配置有基本的直觉。我们将在第十五章《使用 Docker 容器化应用程序》中深入探讨 Docker 容器。
在容器环境中——无论是 Docker 还是其他容器运行时——您所面临的环境要小得多。安装的程序和工具非常少,init
被极大简化为代替systemd
,文件系统也被大大缩减,没有我们在这里提到的许多目录。
配置层级的原则依然适用。大多数容器化应用程序期望从以下位置获取配置:
-
容器文件系统上的某个配置文件,通常是容器调度器在容器启动之前动态创建的
-
环境变量,由容器调度器或启动它的操作员传递
-
命令行参数
即使这是一个简化版的配置层级结构,您会注意到它基本上与我们在传统非容器 Linux 系统中探索的结构完全相同。
我们将在第十五章《使用 Docker 容器化应用程序》中更深入地探讨容器。
结论
本章为您概述了 Linux 配置层级结构以及它如何应用于您每天使用(和编写)的程序。您了解了命令行参数、环境变量,以及所有其他在更大层级结构中为程序提供配置的内容。
如果你跟着做,你甚至创建了一个 systemd 服务来包装一个程序,并允许你以更统一的方式管理其配置。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈,向作者提问,并了解新版本——请扫描下方的二维码:
第十一章:管道和重定向
在本章中,你将学习如何利用现有的最强大的计算概念之一:管道!管道可以用来连接命令,构建复杂的定制化流程,完成特定的任务。在本章结束时,你将能够理解(或编写)像这样的内容:
history | awk '{print $2}' | sort | uniq -c | sort -rn | head -n 10
如果你有兴趣,这会打印出你最常用的十大 shell 命令列表;在我的机器上,它会输出以下内容:
1000 git
115 ls
102 go
83 gpo (an alias I've set up for pushing a local git branch to the origin)
68 make
65 cd
59 docker
42 vagrant
35 GOOS=linux
30 echo
要真正理解管道,你需要首先了解文件描述符和输入/输出重定向,这也是我们将要开始的内容。本章的一些信息可能比较密集,慢慢来,尝试所有示例,确保你理解每一个概念。现在投入的时间学习这些概念,将在你的职业生涯中为你节省大量时间。
在本章中,我们将涵盖以下主题:
-
文件描述符
-
使用管道(
|
)将命令连接在一起 -
你需要了解的 CLI 工具
-
实用的管道模式
-
检查文件描述符
文件描述符
你可能熟悉文件句柄(也称为文件描述符),这来自你在软件工程方面的经验。如果不熟悉,我们建议你查阅《第五章,介绍文件》。简而言之,如果你的程序需要读取或写入操作系统上的文件,打开该文件会给你一个“文件句柄”——它是该文件对象的指针或引用。
因为操作系统会调度对系统资源(如文件)的所有访问,它会追踪你的程序当前正在引用哪些文件句柄或描述符。
但即便是一个进程没有触碰操作系统上的任何文件,它也会打开一些文件句柄。在类似 Unix 的操作系统中,每个进程至少有三个文件描述符:
-
stdin
:标准输入 - 或,fd 0
(“文件描述符零”) -
stdout
:标准输出 - 或,fd 1
(“文件描述符一”) -
stderr
:标准错误 - 或,fd 2
(“文件描述符二”)
这三个文件描述符作为进程的标准通信通道,因此,它们对于系统上创建的每个进程来说,顺序都是一样的。第一个始终指向一个用于读取输入的文件。第二个指向一个用于写出输出的文件。第三个则指向一个用于接收错误输出的文件。
可选地,在前面提到的三个标准文件描述符之后,可能会有任意数量的其他文件描述符/句柄,具体取决于程序的操作。你的进程可能会有:
-
它正在操作的文件
-
它正在读取或写入的套接字(考虑到 Unix 或 TCP 套接字用于网络通信时的写入)
-
设备,如键盘或磁盘,它需要使用的设备
这些文件描述符引用了什么?
现在,你从一个进程的角度,已经知道了这些文件描述符的作用:
-
0
(STDIN
):从这里获取输入 -
1
(STDOUT
):将常规输出放到这里 -
2
(STDERR
):将错误输出放到这里
但是,如果我们跳出单个进程来看,这些文件描述符到底指向哪些文件呢?输入来自哪里,输出和错误又写入哪里?
让我们以 Bash shell 进程为例:默认情况下,它从你的终端(在文件系统中表示为一个文件)获取输入(STDIN)。Bash 将输出和错误打印到相同的终端。实际上,你的整个 shell 会话都是通过对一个文件进行读写操作来进行的。在下一章使用 Shell 脚本自动化任务中,你将学到更多关于 Bash 的知识。
让我们更详细地看看这种输入和输出重定向。
输入输出重定向(或者说,玩转文件描述符以获得乐趣和收益)
这些知识在实际开发任务中非常有用:每当你想避免输入大量内容而改为从文件中获取,或者当你想记录程序输出时,都会用到这种技巧,还有许多其他情况。当你创建一个进程时,可以控制它的三个标准文件描述符指向哪里,从而产生强大的效果。
输入重定向:<
<
(小于)符号让你控制一个进程的输入来源。例如,你通常是通过键盘逐个命令给 Bash 提供输入。让我们尝试从文件给 Bash 提供输入吧!
假设我有一个名为commands.txt
的文件,内容如下(我在这里用cat
来打印我的示例文件):
# cat commands.txt
pwd
echo "hello there, friends"
echo $SHELL
cd /tmp
pwd
这些都是 Bash 认为有效的 Shell 命令,所以我要启动一个新的 Bash 进程,并将此文件作为标准输入使用:
# bash < commands.txt
/tmp/gopsinspect
hello there, friends
/bin/bash
/tmp
Bash 不会提示我输入并等待我输入,而是一次读取并执行一行:它从文件中读取输入,直到遇到换行符(\n
)为止,就像你按下RETURN键一样,它执行命令。
在这个例子中,程序的标准输出仍然回到我们的终端,我们可以读取它。现在让我们来改变它。
输出重定向:>
我们想将STDOUT
(文件描述符1
)重定向到一个文件,而不是终端,将每个命令的输出记录下来,而不是实时地打印到终端:
# bash < commands.txt > output.log
注意,现在终端中没有可见的输出——因为>
字符将输出重定向到了output.log
。使用cat
打印出日志文件,确认它包含了预期的输出:
# cat output.log
/tmp/gopsinspect
hello there, friends
/bin/bash
/tmp
有趣的是,你会注意到,由于文件描述符1
是标准输出,写>
和写1>
是一样的。你很少看到使用1
,因为通常会假设标准输出被重定向了。换句话说:
date > mydate.log
is equivalent to writing
date 1> mydate.log
使用>>
追加输出而不覆盖
在之前的例子中,我们通过重定向命令输出到>
创建了一个日志文件。如果你多次运行这个例子,你会注意到日志文件一点也没有增长。每次用> filename
重定向输出到文件时,文件中的任何内容都会被覆盖。
为了避免这种情况——就像处理一个长期存在的日志文件,这个文件收集来自多个进程或命令的输出——可以使用>>
(追加)。这样,每次运行时,它会将内容追加到输出文件中,而不是覆盖文件的整个内容。
我们将在后面的章节中更详细地介绍 Bash 脚本,但现在,这里有一个简单的脚本,它每秒将当前时间的时间戳写入日志文件:
while true; do
date >> /tmp/date.log
sleep 1
done
在这个示例脚本中,我们创建了一个无限循环(while true; do [ ... ] done),它运行date
命令。它将此命令的输出重定向到/tmp/date.log
文件中,使用>>
,这会将输出追加到文件中(>
每次都会覆盖文件)。然后,脚本会休眠一秒钟,再从头开始。
运行一次date
命令会产生以下输出:
→ ~ date
Sat Jan 6 16:39:37 EST 2024
另一方面,运行这个脚本最初不会看到任何可见的输出,因为输出被重定向到文件中。下面是我将这个小脚本粘贴到终端中,运行一会儿,按Ctrl + C终止它,然后打印出它创建的文件内容时的情况:
→ ~ while true; do
date >> /tmp/date.log
sleep 1
done
^C%
→ ~ cat /tmp/date.log
Sat Jan 6 16:44:01 EST 2024
Sat Jan 6 16:44:02 EST 2024
Sat Jan 6 16:44:03 EST 2024
[ ... ]
Sat Jan 6 16:44:08 EST 2024
在所有日常情况下,你都会使用这种简单的输出重定向,比如为快速调试脚本创建一个临时的日志文件。
错误重定向使用 2>
许多命令行程序都有大量预期的输出,同时也会偶尔输出错误——想想一个find
命令,它会遇到“权限拒绝”的错误,因为你没有权限查看某些目录。
尽管这些错误是微小且可以预见的,但你不希望它们与其他内容混在一起,污染你的输出。当你不是交互式地使用命令行工具,而是编写小脚本或更大的程序来处理你运行的命令的输出时,这一点尤其重要。
你已经看到如何重定向标准输入(fd 0
)和标准输出(fd 1
)。现在我们来看看如何使用2>
(重定向文件描述符 2)语法来重定向标准错误(fd 2
):
find /etc/ -name php.ini > /tmp/phpinis.log 2>/dev/null
这个命令在/etc
目录树内查找任何名为php.ini
的文件。它找到的文件(find
的STDOUT
)会写入/tmp/phpinis.log
,而遇到的任何错误都会通过将其发送到一个特殊的文件/dev/null
来忽略。
提示
/dev/null
是一个特殊的类文件对象,当你尝试从中读取时,它返回零,写入任何内容时会被忽略——它作为一种垃圾桶,用来存放工程师希望静默或忽略的输出。你将在脚本中经常看到它的使用。
现在你已经了解了输入和输出的重定向,我们来看一下管道,它将这两个概念结合在一起:它们将一个命令的输出重定向到另一个命令的输入。
使用管道(|)将命令连接在一起
你已经学会了如何将三个标准文件描述符重定向到不同的位置,并了解了为什么这样做通常很有用。但如果你不仅仅是将输入输出重定向到不同的文件,而是想将 多个程序 连接在一起怎么办?
在命令行中,你可以使用管道符(|
)将一个程序的输出连接到另一个程序的输入。这是一个非常强大的范式,在 Unix 和 Linux 中被广泛使用来创建自定义的排序、过滤和处理命令:
echo -e "some text \n treasure found \n some more text" | grep treasure
如果你将这段代码粘贴到你的 shell 中,你会看到打印出 treasure found
。下面是发生了什么:
-
第一个命令
echo
执行并生成你看到的输出(这些输出被双引号包围,换行符使其成为一个三行字符串)。 -
管道符将该输出(文件描述符 1)流向下一个命令(文件描述符 0)的输入,即
grep
。grep
的输入现在与前一个命令的输出连接在一起。 -
grep
命令依次查看每一行以换行符分隔的内容,并在第二行找到treasure
的匹配项。grep
将第二行打印到其标准输出中。
多重管道命令
这是你在本章开头看到的—相当极端—示例:
history | awk '{print $2}' | sort | uniq -c | sort -rn | head -n 10 > /tmp/top10commands
在这个复杂的命令中,每个管道只是将前一个命令的输出(STDOUT
)作为输入(STDIN
)传递给下一个命令。
将一个命令的输出传递到另一个命令的输入中,就是实现这些流程的方式,它能在不编写任何自定义软件的情况下,过滤和排序这些命令之间流动的数据。仅仅因为没有名为 top10commands
的程序,并不意味着你不能快速利用现有的标准命令来拼凑一个类似的程序。
阅读(并构建)复杂的多管道命令
无论你遇到的管道连接命令看起来多么复杂或神奇,它们都是通过相同的方式构建的:一次一个命令。无论你是在尝试理解像这样的复杂命令序列,还是自己创建一个,过程都是一样的:
-
先看第一个命令,确保你基本理解它的作用。如果你不熟悉它,可以查看 man 页面或其他文档。
-
运行命令并检查其输出。
-
添加管道符及其后面的命令。
-
从 步骤 1 开始重复,直到你完成所有命令。
你会看到,即使是最复杂的 shell/pipe 命令怪物,当你应用这个过程时也变得可以管理。始终记住,你所处理的只是一个数据流,它通过命令之间的管道流动,在这个过程中被塑形、修改、过滤、重定向和转换。
我们将在 第十二章,《使用 Shell 脚本自动化任务》中进一步讨论这个话题,但请尽量尊重其他需要阅读你代码的程序员:将你的语句限制为两到三个管道,并使用有意义的变量来存储中间结果,以便于阅读,如果你的内存限制允许的话。
现在你已经看到文件描述符的原始操作如何作为易于使用的输入和输出重定向暴露出来,让我们来看一些依赖于 Unix 内置可组合性的实际程序组合的示例。
你需要了解的 CLI 工具
在我们跳入章节开始时看到的那些复杂组合之前,先让我们看一些最常用的 Unix 辅助工具,这些工具用于过滤、排序和组合你将在命令行上创建的数据流。
cut
cut
接收一个分隔符(-d
)并根据该分隔符拆分输入,类似于许多编程语言中的 String.Split()
或 String.Fields()
。然后,你可以使用 -f
选择要输出的字段(列表元素),例如,f1
表示第一个字段。
如果你输入多行给 cut
,它会在所有行上重复相同的操作:
echo "this is a space-delimited line" | cut -d " " -f4
space-delimited
你也可以看到使用不同分隔符的 cut
是如何工作的;在以下示例中,我们按连字符而不是空格进行切割:
→ ~ echo "this is a space-delimited line" | cut -d "-" -f1
this is a space
→ ~ echo "this is a space-delimited line" | cut -d "-" -f2
delimited line
你可以看到这也改变了可用字段的数量——在这种情况下是两个,因为文本中只有一个连字符。尝试使用 –f4
打印第四个字段,如前面的示例所示,将只返回一个空行。
要获取所有用户名中包含 root 字符串的 macOS 用户的友好名称,可以使用以下命令:
# grep root /etc/passwd | cut -d ":" -f5
System Administrator
System Services
CVMS Root
sort
sort
执行逐行排序,可以是字母顺序或数字顺序。
使用 -r
进行反向排序通常在处理数字数据(-n
)时很有用。你通常会一起使用 -rn
(参见本章的实践管道模式中的 Top X 部分)。
-h
标志对于排序许多其他命令的可读输出非常有用,像这样:
# du -h | sort -rh
1.6M .
1.3M ./.git
1.2M ./.git/objects
60K ./.git/hooks
28K ./.git/objects/d8
uniq
删除重复行。此命令需要排序数据才能按预期工作,否则它只会检查每行是否为前一行的重复:
# cat /tmp/uniq
one
two
one
one
one
seven
one
默认行为;可能不是你想要的:
# uniq /tmp/uniq
one
two
one
seven
one
uniq
会跳过相邻的重复项,但如果它们被其他文本分隔开,仍然会保留它们。现在,使用已排序的数据,效果如下:
# sort /tmp/uniq | uniq
one
seven
two
计数
uniq
还提供了一个有用的“计数”选项,通过 –c
来访问。关于输入必须排序的警告值得在这里重申——例如,文件包含以下内容:
arch
alpine
arch
arch
运行 uniq -c
后将产生以下输出:
$ uniq -c /tmp/sort1.txt
1 arch
1 alpine
2 arch
这不是大多数用户期望的结果:文件中有 3 次出现 arch
,但 uniq
显示两个分别的计数。为了得到你期望的行为(uniq
应该返回没有重复行的输出),输入必须是已排序的。
这对初学者来说可能有些麻烦,但这正符合 Unix 的哲学:工具应该是小巧且锋利的,不应该重复彼此的功能。如果你编写一个排序工具,它应该只负责排序;如果你编写一个去重工具,它可以依赖于另一个工具的排序功能,以确保极其保守(且一致)的内存使用。
在这里,我们在使用uniq
之前进行排序,这样可以得到我们预期的输出:
$ sort /tmp/sort1.txt | uniq -c
1 alpine
3 arch
你会注意到,这个排序是按升序进行的,而这并不是你想要的,对于开头部分看到的命令的 top-X 列表。为了解决这个问题,我们对这个编号列表进行“反向数字”排序(-rn
)(因为每行现在都以数字开头,感谢uniq -c
,这变得很容易)。下面是一个在包含更多重复项的文件上执行的例子:
$ sort /tmp/sortme.txt | uniq -c | sort -rn
6 ubuntu
4 alpine
3 gentoo
2 yellow dog
2 arch
1 suse
1 mandrake
wc
使用这个命令,你可以测量单词、行、字符和字节的输入计数。你还可以使用-w
来计数字符串中用空格分隔的单词:
# echo "foo bar baz" | wc -w
3
行计数在以下格式中非常常见:
# wc -l < /etc/passwd
123
head
Head
返回流或文件的前几行——默认情况下是 10 行。使用-n
指定你想要的行数:
# head -n 2 /etc/passwd
##
# User Database
tail
这与head
相反:它返回文件或流的末尾几行。它像head
一样接受-n
参数。
tail
也可以用于交互式地跟踪日志文件,即使该文件正在被写入新数据。你会在故障排除时经常看到它这样使用:
tail -f /var/log/ngnix/access.log
tee
有时候,标准输入的一个副本是不够的。tee
将标准输入复制到标准输出的同时,还将其复制到一个文件中。作为软件开发人员,我特别喜欢在两种情况下使用tee
。
首先,为了调试和记录日志:当我运行生成输出的脚本或程序时,tee
可以同时将输出显示在屏幕上,并将其记录到文件中以供后续分析。我们这里使用的是echo
命令,但你可能会在第一个管道前调用自己的程序:
# echo "Hello" | tee /tmp/greetings.txt
Hello
# cat /tmp/greetings.txt
Hello
tee
的第二个用途是从管道中复制数据,像我们在本章中学习构建的管道。你可以使用tee
在管道的任何点拦截数据流,并保存/检查中间结果而不干扰管道的运行。
这是之前提到的“前 10 个命令”的例子,但是在将结果限制为前 10 个之前,插入了tee
。这样会在我们截断结果之前将完整的结果保存在临时文件中:
history | awk '{print $2}' | sort | uniq -c | sort -rn | tee /tmp/all_commands.txt | head -n 10
如果你想查看所有命令,而不仅仅是前 10 个,你可以使用cat
或less
来查看/tmp/all_commands.txt
文件。
awk
awk
通常用于处理数据的列,但它实际上是一种完整的语言。
例如,你可以通过以下方式提取每行的第二列:
# echo "two columns" | awk '{print $2}'
columns
sed
sed
是一个流编辑器,具有许多选项。最常用的是用于在流或文件中进行字符替换。
假设我们有一个这样的文件:
# cat /tmp/sensitive.txt
Nopasswords
not_a_password_either
sillypasswordtimes
password
ok this works
如果我们只想编辑包含password
的那一行,且不修改其他内容:
sed 's/^password$/REDACTED/' /tmp/sensitive.txt
nopasswords
not_a_password_either
sillypasswordtimes
REDACTED
ok this works
这个例子使用的是一个文件,而不是来自其他命令的输入流。默认情况下,这不会修改原始文件。如果你确实想修改输入文件,可以使用-i
(就地)选项。
现在你已经了解了管道符并且看到了常见的命令行工具,接下来我们将把这些基础知识结合起来,学习几种实用的模式,帮助你使日常的命令行工作变得更轻松。
实用的管道模式
如前所述,较长的多管道命令是逐步构建的——一次一个命令。然而,有一些常用的模式,你会经常看到它们被重复使用。
“前 X 名”,并附上计数
这个模式根据出现的次数对输入进行排序,按降序排列。你在本章的原始示例中看到了这个,它展示了从 Bash 历史文件中提取的最常用的 shell 命令。
下面是这个模式的示例:
some_input | sort | uniq -c | sort -rn | head -n 3
我们可以注意到这个模式的一些细节:
-
输入按字母顺序排序,然后通过
uniq -c
进行处理,uniq -c
需要已排序的输入才能正常工作。 -
uniq -c
消除重复项,但会添加一个计数(-c
),表示每个条目出现的次数。 -
sort
会再次运行,这次是按逆数字(-r
和-n
)排序,按输入中的唯一计数排序,并输出逆序(从高到低)的排序结果。 -
head
会获取排名靠前的部分,并将其缩减为三行(-n 3
),展示原始输入中的前三个字符串,以及它们出现的频率。
当你需要了解最常见的浏览器用户代理访问你的网站、那些试图探测并利用你网站的最恶劣的 IP 地址,或者任何其他排序和排名列表有用的情况时,这个模式会非常有用。
curl | bash
curl | bash
模式是 Linux 中常用的快捷方式,用于直接从互联网下载并执行脚本。这个方法结合了两个强大的命令行工具:curl
,用于从 URL 获取内容;以及 bash
,用于执行下载的脚本。这个模式节省了大量时间,使开发人员可以快速部署应用程序或运行脚本,而无需手动下载然后执行它们。
例如,让我们使用这个模式安装 Pi-hole 广告拦截 DNS 服务器:
curl -sSL https://install.pi-hole.net | bash
让我们一步步地分解这个模式:
-
curl -sSL https://install.pi-hole.net
: 该命令会获取 Pi-hole 安装脚本,该脚本托管在此 URL 上。我们传递了两个选项:-
-sS
: 静默模式会从服务器获取原始响应,但如果发生错误,则会显示错误信息。 -
-L
: 跟随重定向。
-
-
|
: 管道符将前一个命令(curl
)的输出作为输入传递给下一个命令(bash
)。 -
bash
: 执行通过curl
获取的脚本。
这是一个非常有用的模式,适用于自动化如代码部署或本地环境安装/配置等任务。然而,特别需要小心的是,下载并执行的脚本不能是恶意的。从互联网随便运行脚本是极其不推荐的做法。
curl | sudo | bash 的安全性考虑
每当你信任第三方在你的机器上运行代码时,你就已经在安全性和便利性之间做出了权衡。从这个角度来看,使用curl | sudo | bash
通过托管在受信任服务器上的脚本安装软件,与使用包管理器并没有太大区别。大多数包管理器(除了nix
)的安全设计也不算特别出色,但它们通常会提供合理的安全功能。你在执行curl | sudo | bash
安装脚本时,实际上放弃了所有这些安全特性:
-
没有可以进行校验和和加密签名的包来确保你获得了正确和官方的版本。
-
下载文件时没有任何限制或强制措施,你也无法知道这些服务器的安全性:你无法识别是否有被破坏的服务器托管恶意安装脚本。
-
这些脚本本身就是在你的机器上以 root 用户身份运行的代码,因此它们可以做任何你能在机器上做的事情,无论好坏。公平地说,许多流行的包管理器也存在这个问题。
基于以上所有原因,请注意我们的警告:将curl
命令拆分成单独的步骤,并在运行sudo
bash
执行之前仔细阅读下载的安装脚本。需要注意的主要事项有:
-
确保你下载脚本的服务器/域名是值得信赖的;它应该是一个有声誉的开发者网站或受信任的第三方代码托管平台。
-
确保使用 HTTPS 下载
curl
(即 URL 应该以https://
开头)。 -
仔细阅读脚本,查看它运行了哪些命令,以及从哪里拉取了额外的代码或可执行文件。如果它下载了额外的脚本或可执行文件,也要查看这些内容。
我们已经确定,curl | sudo | bash
并不是一种特别安全的软件安装方法。如果你像大多数人一样,某一天禁不住诱惑,使用这种安装方法来安装某个特定的软件(例如 macOS 上的homebrew
),遵循这些指南可以帮助你在一定程度上提高安全性。
现在我们来看一个常见的操作模式:使用grep
进行过滤和搜索。
使用grep
进行过滤和搜索
当你运行产生大量输出的命令时,通常最好将输出过滤到仅需要的内容。最常见的工具是grep
,你可以将其视为一个高度可配置的文本搜索或字符串匹配功能。以下是过滤操作可能的示例。
假设你需要找到一个 Linux 进程的工作目录。lsof
工具可以实现这个功能:
➜ ~ lsof -p 3243 | grep cwd
vagrant 3243 dcohen cwd DIR 1,4 192 51689680 /Users/dcohen/code/my_vagrant_testenv
这里是发生情况的简要说明:
-
我正在使用
lsof
获取一个特定进程(PID 3243)的打开文件句柄列表。 -
我将结果(
|
)传递给grep
工具,并用它在结果中搜索字符串cwd
。只有一行结果包含字符串cwd
,因此grep
只会将该行输出到终端。
该模式在输入数据量很大时特别有用,但你只需要从中筛选出可以通过特定字符串识别的子集。grep
作用于输入文本的每一行,因此在提取像以下数据时非常有用:
-
包含你正在跟踪的 IP 地址的日志行
-
在管道数据流中出现的用户名
-
匹配某个模式的行(
grep
支持正则表达式,并且可以接受字符串模式,除了字面搜索字符串之外)
grep
是一个功能强大且广泛使用的工具,你几乎每天都会用到它。有关更多信息,请通过输入 man grep
查看 grep
的手册页。
你在本书中已经看到过 grep
在文件中的使用(例如,grep searchstring hello.txt
),但它也是管道命令中的一个宝贵过滤组件。现在让我们来看一个实际的例子。
使用 grep 和 tail 进行日志监控
当你查看生产日志时,试图找出问题所在,通常你只想查看包含特定关键字或搜索字符串的日志行。为了实现这一点,你可以运行类似这样的命令:
tail -f /var/log/webapp/too_many_logs.log | grep "yourSearchRegex"
该模式持续监控日志文件中的新条目,条目的内容符合“yourSearchRegex”正则表达式,从而让你仅看到当前任务需要的日志。
使用 find 和 xargs 执行批量文件操作
xargs
是一个强大的工具,它赋予你迭代的能力(换句话说,就是一个“for”循环),并且可以在单个命令中使用。默认情况下,xargs
会接受每个(空格、制表符、换行符和文件结尾符分隔的)输入块,并使用该块作为输入执行指定的程序。例如,如果你需要在由某个 find
查询返回的特定文件中搜索文件内容,可以运行以下命令:
find . -type f -name "*\.txt" | xargs grep "search_term"
此命令会查找所有以 .txt
结尾的文件,然后使用 xargs
对每个文件分别应用 grep
命令。此模式非常适合一次搜索或修改多个文件。请注意,xargs
是一个强大且庞大的工具,能够做许多事情(包括将字符串插入到它执行的命令中)。我们无法在这里涵盖所有内容,如果你遇到这种情况,请阅读手册页并在互联网上查找示例,看看这种功能如何帮你解决问题。
对数据分析进行排序、去重和反向数字排序
这是一个有用的模式,在本章开头你曾看到过应用,我用它来过滤大量命令历史记录,获取“在该系统上执行次数最多的前 X 条命令”列表。核心模式是这样的:
(input stream) | sort | uniq –c | sort -rn
这种模式非常适合分析数据,它会对输入流中的数据进行排序,去重并统计唯一的出现次数,然后执行反向数字排序,最终输出去重后的数据,并将最常见的行排在前面。
这通常通过 | head -n $NUMBER
截断,以仅获取前 $NUMBER
条结果:
history | awk '{print $2}' | sort | uniq -c | sort -rn | head -n 10
在这里,我们使用 history 获取整个 Shell 命令历史记录。这样会给出一系列像这样的行:
12 brew install --cask emacs
我们只关心顶级命令(在这个例子中是 brew
),所以我们使用 awk
来获取第二列。
然后我们对数据进行排序,使得相同命令的重复项在流中紧挨着。接着,我们用 uniq
去除这些重复项,并为每个剩余的命令添加出现次数的计数。接下来,我们再次排序,这次使用 -rn
进行反向数字排序,这样就实现了“前 X 名”的效果。最后,我们用 head 取出前 10 行。
这会打印出你最常用的 10 个 Shell 命令列表;在我的机器上,它会输出:
1000 git
115 ls
102 go
83 gpo (an alias I've set up for pushing a local git branch to the origin)
68 make
65 cd
59 docker
42 vagrant
35 GOOS=linux
30 echo
awk 和 sort 用于数据重新格式化和基于字段的处理
awk
不仅是一个程序,它还是一种流处理语言。如果你在 Unix 系统上处理数据流,那么花几天时间学习基础知识可以为你未来的职业节省数周时间。也就是说,使用 $#
语法引用每行数据流中的空白分隔列是一个很好的起点。
让我们看一个示例,假设有如下数据流:
Foo bar baz
Some data is nice
当 awk 解释器看到 $1
时,它将其解释为“第一列”,在这种情况下,第 1 行是 Foo
,第 2 行是 Some
。$2
是第二列(bar
,data
),依此类推。当处理稍微复杂一些的数据时,这是一个非常常用的功能,比简单的 cut
命令更有效:
cat file.txt | awk '{print $2, $1}'
这将产生类似如下的输出:
bar Foo
data Some
在这个例子中,它会将每个文件的第 2 列打印在第 1 列之前,并忽略每行中的其他数据。这通常用于基于特定字段重新格式化和整理数据。
sed 和 tee 用于编辑和备份
sed 代表 Stream EDitor,用于在你想要转换数据流时使用。当你在文本编辑器中进行查找/替换符号时,你每天都会执行这项操作十次。以下命令本质上是该功能的命令行版本:它将 file.txt
中所有的 old
替换为字符串 new
,并将结果流写入新文件 file.txt.changed
。这样做不会修改原始的 file.txt
file
:
sed 's/old/new/g' file.txt | tee file.txt.changed
虽然编辑文件内容是此概念的简单演示,但 sed
对于转换数据流非常有用,因为它可以在一个命令的输出和下一个命令的输入之间快速传递数据流:
(input stream) | sed 's/old/new/g' | (next command)
ps、grep、awk、xargs 和 kill 用于进程管理
虽然 pgrep
是一个很好的工具,可以向所有匹配模式的进程发送信号,但有时它在你的系统上不可用。你可以通过以下一组管道命令组合来拼凑出类似的功能(并且能更具体地指定你想要定位的目标,而不仅仅是名称):
ps aux | grep "process_name" | awk '{print $2}' | xargs kill
ps
为你提供一个运行中进程的列表,grep
过滤出只包含你正在搜索的模式的进程。awk
获取每一行的第二列(进程 ID),然后将所有匹配的行传递给 xargs
(我们的类 for 循环),它在每个 PID 上执行 kill
。这会向每个匹配的进程发送 SIGTERM
信号,并(希望)将其停止。
tar 和 gzip 用于备份和压缩
尽管许多工具有让你同时执行归档和压缩的标志,但将归档和压缩连接起来也是一种有意义的使用案例。这为你提供了额外的灵活性,可以添加更多的链式命令。例如,如果你想添加加密,那只需再加一个管道命令:
tar cvf - /path/to/directory | gzip > backup.tar.gz
这将创建一个目录的压缩归档,通常用于文件备份和存储。你可以看到使用这种模式的更大的命令:
ssh user@mysql-server "mysqldump --add-drop-table database_name | gzip -9c" | gzip –d | mysql
这是一个特别有趣的示例,它通过 SSH 登录到数据库服务器,导出数据库,压缩该数据流,通过 SSH 将其传回本地机器,再次解压缩,最后将其导入本地 MySQL 服务器。
你的目标不一定是写出像这个(或你在这里看到的其他一些)那么复杂的命令,但如果你知道如何在关键时刻拼凑出这样的命令,它可以帮助你在开发过程中脱离一些极其紧迫的困境。我们希望这一部分能展示出,理解 Unix 系统为你提供的输入输出重定向原语——通过 <
、>
、>>
、|
以及一般的文件描述符——实际上是一种超级能力。明智地使用它。
高级:检查文件描述符
在 Linux 上,你可以轻松地 查看 一个进程的文件描述符指向哪里。我们将使用稍微神奇的 /proc
虚拟文件系统来完成这一操作。
Procfs(proc
虚拟文件系统)是一个仅限 Linux 的抽象,它将内核和进程状态表示为文件。这些文件中的数据来自操作系统内核,并且只在你读取时存在。仅列出 /proc
目录,你就能看到很多文件;以下是一些更为重要的文件,摘自 Arch Linux 的维基:
/proc/cpuinfo - information about CPU
/proc/meminfo - information about the physical memory
/proc/vmstats - information about the virtual memory
/proc/mounts - information about the mounts
/proc/filesystems - information about filesystems that have been compiled into the kernel and whose kernel modules are currently loaded
/proc/uptime - current system uptime
/proc/cmdline - kernel command line
关于文件描述符,我们最感兴趣的是上面列出的清单中没有显示的内容:/proc
包含一个针对每个正在运行的进程的目录,目录名是每个 进程 ID(PID)的名称。
在一个进程的/proc
目录中,该进程的文件描述符会以符号链接的形式表示,存放在名为fd
的目录中。当你对这个/proc/$PID/fd
目录进行长列表显示时,你会发现l
是长列表中的第一个字符,它表示一个特殊的link
文件,正如你在第五章《文件介绍》中回顾到的那样。
从实际角度看,/proc/1/
是init
进程的 proc 目录,你可以通过对/proc/1/fd
进行长列表显示,查看 init 的文件描述符。
让我们看看我机器上运行的交互式 Bash shell 进程的文件描述符,ps aux | grep bash
告诉我它的 PID 是 9:
root@server:/# ls -alh /proc/9/fd
total 0
dr-x------ 2 root root 0 Sep 1 19:16 .
dr-xr-xr-x 9 root root 0 Sep 1 19:16 ..
lrwx------ 1 root root 64 Sep 1 19:16 0 -> /dev/pts/1
lrwx------ 1 root root 64 Sep 1 19:16 1 -> /dev/pts/1
lrwx------ 1 root root 64 Sep 1 19:16 2 -> /dev/pts/1
lrwx------ 1 root root 64 Sep 5 00:46 255 -> /dev/pts/1
你会注意到这是一个交互式 shell 会话:它的标准输入来自一个虚拟终端(/dev/pts/1
),其标准错误和输出也回到这个终端。这是正确的。
让我们看看像 vim 这样的文本编辑器,它的行为与终端非常相似——输入和输出都是通过终端进行的。然而,这里有一个附加的复杂性,那就是文本编辑器通常会保持一个或多个文件处于打开写入状态。这是什么样子的呢?
在这个例子中,我正在运行 vim 文本编辑器,并编辑/tmp
目录中的一个文件。让我们找到 vim 的进程 ID,这样我们就知道应该查看哪个/proc
目录:
root@server:/# ps aux | grep vim
root 453 0.0 0.1 17232 9216 pts/1 S+ 15:57 0:00 vim /tmp/hello.txt
root 458 0.0 0.0 2884 1536 pts/0 S+ 15:58 0:00 grep --color=auto vim
就是它;进程 453。不要被grep
命令误导,后者也在命令参数中包括了vim
。现在我们已经得到了 PID,接下来看看 vim 的文件描述符:
root@server:/# ls -l /proc/453/fd
total 0
lrwx------ 1 root root 64 Jan 7 15:58 0 -> /dev/pts/1
lrwx------ 1 root root 64 Jan 7 15:58 1 -> /dev/pts/1
lrwx------ 1 root root 64 Jan 7 15:58 2 -> /dev/pts/1
lrwx------ 1 root root 64 Jan 7 15:58 3 -> /tmp/.hello.txt.swp
我们可以看到,stdin(0
)、stdout(1
)和 stderr(2
)都指向一个终端设备,就像 shell 一样。我们还看到编辑器打开了一个文件,文件描述符3
链接到 vim 正在编辑的文件。当一个进程打开额外的文件时,会创建新的文件描述符,你可以在这里查看它们。
除了本身很有趣外,这对于调试程序行为异常时(比如程序出现 bug 时),或者你在追踪一个可能有恶意行为的程序时非常有用。如果你花点时间学习它,procfs
会变得非常有趣和有用:只需要输入man proc
开始,或者阅读 Arch Linux Wiki 页面,了解更温和的介绍:wiki.archlinux.org/title/Procfs
。
结论
在本章中,我们将前面所学的所有技能和理论整合起来,解锁 Unix 和 Linux 系统最强大的功能之一:通过管道和输入/输出重定向将数据流经多个命令。
我们从展示操作系统如何暴露像文件描述符这样的原始操作开始,然后开始研究输入输出重定向的实际应用。接着,我们讲解了管道,这是 Linux 和其他 Unix 操作系统中最有用的功能之一。在介绍必要的理论并展示了一些实用示例后,我们深入探讨了人们用来切割和处理通过管道传输的数据流的最常见辅助工具。最后,我们向你展示了人们在实际工作中使用的一些最常见和最有用的模式和程序组合。
本章的内容是你将在日常工作中遇到并使用的许多高级命令行操作的基础。你已经接触到了一些基本理论、工具和模式,这将使你能够轻松地开始构建用于常见开发、故障排除和自动化用例的自定义命令。
要提升你的技能,将本章中所学应用到日常工作中!将其作为参考来尝试不同的模式,并持续学习新的工具和命令,将它们加入到自己的定制工作流中,用来过滤或处理命令行上的数据。你很快就会像个高手一样。
在 Discord 上了解更多
要加入本书的 Discord 社区——你可以在这里分享反馈、向作者提问,并了解新版本的发布——请扫描下面的二维码:
第十二章:使用 Shell 脚本自动化任务
有时候,你会发现自己一遍又一遍地重复相同的几条命令,可能只是稍微有些变化。你会感到沮丧,然后说:“算了,我来写个脚本吧。”作为一个 CLI 大师,你做了如下操作:
-
运行
tail -n 20 ~/.bash_history > myscript.sh
来创建一个文件,包含你最后运行的 20 条 Bash 命令。 -
然后运行
bash myscript.sh
来执行脚本。
虽然这不是推荐的做法(我们将在本章中讨论这个问题),但它是创建和运行 Bash 脚本的一种完全有效的方法。
本章是一个 Bash 脚本速成课程。像任何编程速成课程一样,除非你实际跟着做,自己敲代码并在自己的 Linux 环境中运行,否则它完全没有用。除了向你展示被认为是现代且最佳实践的 Bash 语法子集外,我们还会分享多年来积累的经验,指出常见的陷阱和尖锐的边缘。
虽然 Bash 不是我们最喜欢的语言,但有时它正是解决你面对的问题的最佳工具。我们也会尽量帮助你理解这一点。
在本章中,我们将涵盖以下内容:
-
Bash 脚本基础
-
Bash 与其他 Shell 的对比
-
Shebang 和可执行文本文件
-
测试
-
条件语句
为什么你需要掌握 Bash 脚本基础
Shell 脚本是任何开发者不可或缺的工具;即使你不是每周都编写脚本,你也会阅读脚本。在本章中,我们将介绍你需要了解的基础知识,这样你就能在例如以下情况时感到得心应手:
-
你会面对别人几年前写的 Shell 脚本,例如“你能检查一下我们能否重用 Steve 在离开谷歌之前写的自动化脚本吗?”
-
当你看到有机会编写自己的 Shell 脚本,处理已经有现成 Shell 程序可以解决的工作(如过滤、搜索、排序输出,或者将一个程序的输出传递给另一个程序)时,你就会有这样的想法。
-
你希望在构建镜像时精确控制每个 Docker 层中包含的内容。
-
你需要在 Linux 服务器的操作系统环境中协调其他软件:启动顺序、错误检查、程序间的提前中止等等。
有无数的使用案例,Shell 脚本恰好是解决你问题空间的最佳工具。完成本章后,你将具备编写自定义脚本所需的技能。
基础
Bash 可以像学习任何其他编程语言一样学习。它有一个环境(Unix 或 Linux)、一种标准库(系统上安装的任何 CLI 驱动程序)、变量、控制流(循环、测试和迭代)、插值、一些内置的数据结构(数组、字符串和布尔值——算是吧)等等。
本书假设你是一个软件开发人员,因此知道如何编程,因此我们不会向你教授这些标准的编程语言特性,而是简单地展示它们在 Bash 中的表现,并提供一些习惯用法(或常见误用)的建议。
变量
像其他编程语言一样,Bash 有可以为空或设置为某个值的变量。未设置的变量就是“空的”,除非你通过set -u
设置了-u
选项(在未设置变量时产生错误),否则 Bash 会愉快地使用它们而不会慌张。
设置
设置变量时,使用等号。
FOOBAR=nice
将把 FOOBAR
变量的值设置为 nice
。
Bash 没有类型——它几乎是最不具类型的编程语言。
变量符号本身可以包含字母、数字和下划线,但不能以数字开头。
通常的做法是,环境变量使用大写字母命名,而 Bash 脚本中的变量使用小写字母命名。这些变量名通常使用下划线来分隔单词。当在变量名中使用数字时,避免以数字开头。Bash 不允许这样做。和其他语言一样,命名时最好让变量名能表示它的用途,并指示它是否是常量,或者对于数组使用复数名称:
-
不合法:
%foo&bar=bad
-
不合法:
2foo_bar=bad
-
合法但不推荐:
foo_BAR123=still_very_bad
-
正确的环境变量:
PORT=443
-
正确示例:
local_var=512
-
正确的环境变量:
FOO_BAR123=good
-
正确的本地数组变量:
words=(foo bar baz)
获取
要使用变量,可以通过$
符号引用它:
$ echo $FOOBAR
nice
Bash 与其他 shell 的比较
在类 Unix 环境中存在着各种各样的 shell 程序;你可以认为,Unix 的流行原因之一就是它一直是一个几乎没有障碍的脚本和自动化环境。
本章将教你如何用 Bash 编写自己的脚本。你在这里学到的很多内容也可以在其他 shell(例如 ksh
和你在 /bin/sh
找到的其他常见最小化 shell)上使用,但我们这里关注的是 Bash。
如果你在编写 shell 脚本,Bash 在广泛可用性和足够大的语言功能集之间取得了完美的平衡,足以让你编写小型程序时感到舒适。
Shebang 和可执行文本文件,也就是脚本
在类 Unix 系统中,“脚本”只是一个可执行的纯文本文件。操作系统(在 Linux 中通常被称为“内核”)查看第一行,以确定将文件的内容传递给哪个解释器。
第一行是所谓的“shebang”(或称 hashbang),由一个井号和一个感叹号(#!
)字符组成,后面跟着用于执行文件代码的解释器的路径。以下是一个示例的 shebang 行:
#!/usr/bin/env bash
当 Unix 类系统的内核运行一个具有可执行位的文件时,它们会查看文件的前几个字节。这可能包含一个魔术数字。这个数字可以是二进制文件的一部分,或者是像 shebang 中那样的人类可读字符。内核使用这些信息来判断是否有适当的方式执行它。例如,这可以防止内核尝试执行一个图像文件并崩溃。根据系统的不同,内核或 shell 会确保随后的命令被执行。env
程序将运行该命令,并考虑PATH
环境变量来查找并执行bash
。
虽然井号在大多数脚本语言中表示注释,因此会被解释器忽略,但文件开头的这个特殊注释告诉操作系统应该运行哪个命令来解释文件的其余部分。这里是你会看到的一些常见示例:
-
#!/bin/sh
:在这个特定的文件系统位置使用这个特定的 shell 程序 -
#!/usr/bin/python3
:使用这个特定的 Python 二进制文件 -
#!/usr/bin/env python
:使用env
程序来确定在该环境中使用哪个 Python 二进制文件(不同的系统可能会在不同的路径下安装同一个程序的不同版本)
虽然你会看到所有这些变体,但最佳选择始终是使用/usr/bin/env
以确保可移植性。/bin/sh
是一个例外,因为每个符合 POSIX 的系统都必须在该位置提供一个符合 POSIX 的 shell。
常见的 Bash 设置(选项/参数)
由于 shebang 行作为命令执行,因此也可以传递参数。虽然保持简单通常是一个好主意,但一个常见的做法是向 shell 传递额外的参数,特别是对于 Bash 来说,它常用于大型脚本,因为与通常位于/bin/sh
的较小 shell 相比,Bash 提供了更多的特性。
在 Bash 脚本中,你会经常看到传递的-e
、-u
、-x
、-o pipefail
等参数。你可能会在 shebang 行本身看到这些参数:
#!/usr/bin/env bash -euxo pipefail
或者,使用 Bash 中的set
命令设置参数或选项,正如下一个语句所示:
#!/usr/bin/env bash
set -eu -o pipefail
设置这些选项使得 Bash 的行为更像你习惯的编程语言,具体包括:
-
如果命令管道的任何组成部分失败,立即退出,并且
-
将未设置的变量视为致命错误。
这是这些选项的文档拆解,并附带一个有用的调试选项(-x
)作为额外内容:
-
-o pipefail
:在使用管道时,这将确保管道中发生的错误会被传递。如果发生多个错误,将使用最右侧的错误。 -
-e
:如果出现错误或命令失败,这将确保 shell 脚本立即退出。 -
-u
:如果使用任何未设置的变量,将抛出错误。
对于调试,-x
非常有用:
-x
:这将启用跟踪。这意味着每个命令将在执行之前写入标准错误。
除了 –o pipefail
外,所有参数都可以在大多数 Unix shell 中找到。有关 Bash 选项的更多信息,请参阅其手册页:manpages.org/bash
。
/usr/bin/env
记住一件事:/bin/sh
是一个 POSIX 标准路径,指向任何 POSIX 兼容的 shell。你可以放心它在任何 Linux 或 Unix 系统上都会存在。通常这不是 Bash,而是一个更简约的 shell,提供足够的功能以满足 POSIX 标准,使你能够编写非常便携的 shell 脚本。对于脚本可能需要的所有其他 shell 和解释器,最佳实践是使用 #!/usr/bin/env
前缀,这样可以确保使用 PATH
中的正确路径,并防止当二进制文件没有在 /usr/bin/
中时出现“命令未找到”错误。
有多种情况可能导致 /usr/bin/bash
或 /bin/bash
不是正确的路径。例如:
-
包管理器或公司特定的配置脚本通常会将解释器安装在与开发系统不同的地方。
-
手动安装软件的人,例如,为了绕过或重现一个 bug,通常会将生成的二进制文件放在
/usr/local
。 -
各种脚本语言的虚拟环境将会把二进制文件放到每个源代码
project/repository
的子目录中。 -
没有 root 权限的用户安装解释器,例如,在他们的主目录中。
-
使用解释器版本管理器的人(例如,rvm、nvm 等)。
-
各种类 Unix 操作系统和一些 Linux 发行版不会将第三方软件包安装到
/usr/
目录。
尽管很多人无法想象他们的脚本最终会出现在如此不标准的位置,但你最终很可能会遇到这种情况。与其冒着软件因环境的轻微变化而崩溃的风险,不如养成在脚本中写入 /usr/bin/env bash
(或你的代码所使用的解释器)的习惯。这样可以防止其他人——或者在凌晨 3 点被 pager 叫醒的你——不得不注意、排除故障、查找或在源文件中做出这样的更改。
特殊字符与转义
你应该经常使用的一个特殊字符是哈希符号(#
),它会使该行符号后面的所有内容成为注释,解释器会忽略它们。
其他字符在 Bash 中有特殊含义,当你将它们作为变量的值的一部分使用时,需要用正斜杠(\
)转义。以下是一些例子:
-
引号(
"
和'
) -
括号和圆括号(
{
、}
、[
、]
和(
、)
) -
插入符号(
<
和>
) -
波浪线:
~
-
星号(Bash 中的“通配符”字符):
*
-
和号:
&
-
问号:
?
-
常见操作符:
!
、=
、|
等
像在大多数其他编程语言中一样转义它们:
$ FOO="jaa\$\'"
命令替换
Shell 脚本的一个优势和主要用例是任何命令都很容易访问。一个非常常见的例子是命令替换。当你想要使用一个或多个命令的输出时,这非常有用。你可以通过命令替换来做到这一点:
echo "Right now, it's $(date)"
这将执行这些命令 —— 在这个例子中只是 date
,但它也可以是你通过管道连接起来的复杂表达式。另一种做法是使用反引号。以下示例将产生相同的输出:
echo "Right now, it's `date`"
测试
这里显示的测试命令通常与 if/else
控制流语句一起使用。字符串测试函数([[
)和算术测试函数(((
)如果测试结果为 true
,则返回 0;如果结果为 false
,则返回 1。这是因为命令的退出代码 0 表示成功,这与你可能熟悉的其他编程语言不同,后者通常将零值评估为 false。Bash 中没有原生的 boolean
数据类型;整数 0 和 1 在布尔上下文中被使用。在脚本中,有时会初始化并使用变量 true
和 false
。
测试运算符
这里是一些你可以在 Bash 中用来构造语句的基本布尔运算符 —— 本质上就是你在其他编程语言中已经习惯的运算符:
-
!
– 非(否定) -
&&
– 且 -
||
– 或
这些运算符可以与字符串和算术测试类型一起使用:
-
==
– 等于 -
!= 表示不等于
[[ 文件和字符串测试 ]]
[[
复合命令允许你执行(并结合)“字符串”比较。如前所述,Bash 没有你在其他编程语言中习惯的严格数据类型,因此我们将其称为“字符串”或“类似字符串”的比较,因为这对软件开发人员来说是一个熟悉的概念。
如果用户的主目录不存在,创建它:
if [[ ! -d $HOME ]]; then
echo "Creating home directory: ${HOME}..."
mkdir -p $HOME
echo "done"
fi
那个 !
字符是 Bash 的否定运算符,因此你可以将这个示例的第一行理解为 if NOT (test) is-a-directory $HOME, then…
。
这是一个稍微复杂一点的示例。如果用户的主目录不存在,或者
如果 ALWAYSCREATE
变量设置为 yes
,则创建主目录:
ALWAYSCREATE=yes
if ! [[ -d $HOME ]] || [[ $ALWAYSCREATE == yes ]]; then
echo "Creating home directory: ${HOME}..."
mkdir -p $HOME
echo "done"
fi
有用的字符串测试运算符
-
-z
是未设置(用于变量) -
-n
是非零的(set
– 用于变量) -
=~
是一个左操作数,用于匹配正则表达式(右操作数),例如[[ foobar =~ f*bar ]]
文件测试的有用运算符
-
-d
: 目录 -
-e
: 存在 -
-f
: 一个常规文件 -
-S
: 一个套接字文件 -
-w
: 可写,从这个 Bash 进程的角度来看
(( 算术测试 ))
在 ((
测试中进行的算术评估如果表达式的结果为 0,将把测试的退出值设置为 1;否则,它将返回退出状态 0。使用你在几乎所有其他编程语言中都知道的运算符,这使得测试变得非常直观:
-
>
和>=
– 大于和大于或等于 -
<
和<=
– 小于和小于或等于 -
==
– 测试相等性
(( $SOME_NUMBER == 24 ))
是一个相当直接的算术测试。让我们看看它的行为。
对于数字 24:
→ SOME_NUMBER=24
→ (( $SOME_NUMBER == 24 ))
→ echo $?
0
“echo $?
” 命令输出上一个命令的退出状态,这让我们能够看到算术测试实际评估的结果。对于其他值,包括非数字值:
→ SOME_NUMBER=foobar
→ (( $SOME_NUMBER == 24 ))
→ echo $?
1
如果 $SOME_NUMBER
未设置(例如,[[ -z $SOME_NUMBER ]]
):
→ unset SOME_NUMBER
→ (( $SOME_NUMBER == 24 ))
zsh: bad math expression: operand expected at `== 24 '
所以,总结一下:
-
(( $SOME_NUMBER == 24 ))
如果SOME_NUMBER
变量设置为 24,将评估为 0。 -
如果
$SOME_NUMBER
设置为一个不是 24
的值(包括非数字值),它将评估为 1。 -
如果
$SOME_NUMBER
被 未设置,你将会得到一个错误,因为你的算术测试没有左操作数可以用于比较。
条件语句:if/then/else
一个 Bash if
语句通常以这种形式出现:
if [[ $TEST ]]; then $STATEMENTS else $OTHER_STATEMENTS fi
在我们查看示例之前,记住这个形式的一些事项:
-
if
和fi
分别用于开始和结束if
块。 -
;
用于分隔 Bash 中的语句;在测试后加一个分号。 -
[[
和]]
用于分隔你的测试表达式。 -
else
子句是可选的。
这就是 Bash 中 if
语句的样子:
if [[ -e "example.txt" ]]; then
echo "The file exists!"
ifelse
如果你想在这个结构中加上 else
子句,当然可以!
if [[ -e "example.txt" ]]; then
echo "The file exists!"
else
echo "The file does not exist!"
fi
循环
Bash 循环的一般格式是 for / do / done
。它们还支持 break
和 continue
语句,分别用来跳出循环和跳到下一个迭代。
C 风格的循环
Bash 支持 C 风格的循环,包括初始化表达式、条件表达式和计数表达式:
for (( i=0; i<=9; i++ ))
do
echo "Loop var i is currently $i"
done
for…in
让我们来谈谈使用 for...in
循环的迭代。试着在你的 Shell 中运行以下命令:
for i in 1 2 3 4 5
do
echo $i
done
这是一个包含一些控制流的循环:
for os in FreeBSD Linux NetBSD "macOS" DragonflyBSD
do
echo "Checking out ${os}..."
if [[ "$os" == 'NetBSD' ]]; then
echo "(I'm pretty sure this would run on my toaster, actually)"
fi
sleep 1
done
While
另一个你可能在其他编程语言中熟悉的常见控制结构是 while
循环。在 Bash 中,这个语法非常相似。要跳出循环,可以使用 break
语句。
以下脚本将逐行读取 lines.txt
文件,直到遇到 STOP
行。最后一行还展示了如何将文件通过管道传递给循环。read
命令将逐行处理文件:
file="lines.txt"
while read line; do
if [[ $line == "STOP" ]]; then
echo "Encountered STOP. Exiting loop."
break
fi
echo "Processing: $line"
# Additional commands to process $line can be added here.
done < "$file"
变量导出
通过在变量前加上 export
来导出一个变量,确保从你脚本的进程派生出来的任何子 Shell 也能访问该变量的值。这是一种确保变量能够传递到当前 Shell 变量作用域或命名空间的任何未来子作用域(或子命名空间)中的方法。
在你的 Shell 中设置一个变量:
MYDIR=$HOME
创建并运行这个脚本(警告:这将失败!):
#!/usr/bin/env bash
LISTING=$(ls "${MYDIR}/Documents")
echo $LISTING
你会看到一个错误,ls: /Documents: No such file or directory
,因为运行这个脚本启动了一个子 Shell,而该子 Shell 无法访问父 Shell 中未导出的变量(换句话说,就是你交互式 Shell 的父 Shell)。让子 Shell 访问你的变量必须通过 export
关键字明确地进行:
export MYDIR=$HOME
导出变量后重新运行示例脚本,你会发现它现在可以访问MYDIR
变量。
函数
我们通常建议,当你发现自己需要使用 Bash 函数时,应该已经找到一种其他语言来编写你日益增长的程序。然而,有时 Bash 仍然是解决问题的正确语言,我们希望向你展示最基本的内容,尤其是我们推荐的使用方式。
使用function
关键字来定义一个函数:
function my_great_function {
$EXPRESSIONS
}
通过简单地调用函数的名称来调用函数:
my_great_function
偏好使用本地变量
Bash 工作在更或少是全局作用域——更准确地说,是每个(子)Shell 的作用域。许多现代编程语言提供了独立的函数作用域,这样函数状态在函数退出后不会污染全局状态。
在函数中使用local
变量可以防止这种情况发生,我们建议你使用它们:
#!/usr/bin/env bash
important_var=somevalue
function local_var_example() {
local important_var="changed this locally, don't worry"
echo "local_var_example: ${important_var}"
}
function bad_example() {
important_var="this is mutating the global var because I'm bad, and I should feel bad."
echo "bad_example: ${important_var}"
}
echo "before functions: ${important_var}"
local_var_example
echo
echo "after local_var_example: ${important_var}"
echo
bad_example
echo "after bad_example: ${important_var}"
exit 0
运行这段代码,自己看看使用本地vars
带来的不同。
输入和输出重定向
当你运行脚本时,你通常会希望将它们的输出重定向:
-
重定向到另一个程序(通过管道
|
——更多细节见第十一章,管道和重定向) -
重定向到常规文件(如日志文件)
-
重定向到一个特殊位置,如
/dev/null
,它可以作为一个“黑洞”,用于存放你不需要的数据
除了管道外,以下是你在实际中最常见的输入/输出重定向技巧:
<
:输入重定向
这通常用于从文件中获取输入,而不是通过 Shell 启动进程:
grep foobar < stuff.txt
> 和>>
:输出重定向
>
符号会将输出流重定向到你指定的地方,如果它是一个常规文件,将覆盖已有的内容:
ps aux | grep foo > /var/log/foo_overwrite.log
每次运行这个命令时,ps aux | grep foo
的输出会被写入/var/log/foo_overwrite.log
,并覆盖任何现有的文件内容。
使用>>
代替会附加到输出文件,保留现有内容不变。这通常是你处理日志文件时需要的:
echo $(date && cat /proc/stat) >> /var/log/kernelstate.log
使用2>&1
来重定向STDERR
和STDOUT
有时,你可能希望将标准输出和标准错误都重定向到一个文件:
consul agent -dev >> /var/log/consul.log 2>&1 &
这个命令以dev-mode
模式运行 Hashicorp 的 Consul,并将进程放到后台(结尾的&
符号),将标准输出重定向到日志文件。2>&1
告诉 Bash“将文件描述符 2(STDERR
)重定向到和 1(STDOUT
)相同的位置”——在这种情况下,就是/var/log/consul.log
。
你已经了解了文件描述符——STDIN
、STDOUT
和STDERR
。如果你只想将标准错误重定向到一个不同的文件,而不是标准输出呢?
变量插值语法——${}
为了在大多数编程语言中实现“字符串插值”——用变量的值替换字符串的一部分——你需要使用 Bash 的变量插值,${}
。
自己尝试一下:
MYNAME=dave
echo "I can't do that, ${MYNAME}."
在 Bash 中还有其他方法可以插入变量,但这是我们最喜欢的方式,因为它在处理形状不确定的输入(空格、特殊字符等)时,最不容易让程序崩溃。
如果你打算将一个变量用作类似字符串的值,请使用这种语法——即使该变量本身并不需要插入到其他字符串中:
NAME="${MYNAME}"
这将防止出现许多奇怪的 Bug 和 Bash 中的异常行为,所以这是一个值得养成的好习惯。
注意
在处理变量插值时,你几乎总是会希望以-u
选项运行 Bash(可以通过-u
调用它,或者在脚本开头使用set -euo pipefail
,如我们建议的那样)。这将防止你在使用变量之前需要检查其是否为零值。
Shell 脚本的局限性
Bash 有无数功能,许多我们在这里没有涵盖。如果你需要深入了解 Bash 语言和环境,有许多书籍和大量免费的网络资源可供参考。Bash 手册页(man bash
)也是一个很好的起点,既然你已经有了基本的了解。
我们预期你在职业生涯中会遇到许多 Bash 脚本。然而,很可能你花更多时间阅读和解读现有脚本,而不是编写大型新的 Bash 程序。Bash 非常适合解决小问题和系统任务,尤其是那些可以通过现有软件解决的问题,只需要将它们拼接成一个解决方案。对于超出标准 Linux 和 Unix 程序组合的大问题,它通常是一个糟糕的选择。
使用 Bash 时,我们发现:
-
小巧优于庞大
-
清晰优于巧妙
-
安全总比后悔好
用不同编程语言(通常是 Python)编写的工具替代 Bash 脚本并不少见,尤其是当脚本变得庞大时。这并不是说 Bash 不好!它在其所占据的领域内非常完美,这也是它能长期广泛使用的原因。如果你偶尔停下来问自己,“Bash 脚本仍然是解决这个问题的正确方案吗?”你会没问题的。
结论
本章是一个不留情面的、从消防栓中喝水般的 Bash 脚本速成课程。内容密集,但它涵盖了成为高效 Bash 脚本编写者所需的所有基础知识。如果有必要,多看几遍。除了语法外,我们还介绍了我们认为的最佳实践,使得编写可读、可维护的实际脚本变得更容易(或者至少是可能的)。
多加练习,多加练习,多加练习——最好是在你遇到的实际问题上练习,而不仅仅是玩具示例。没有比这更快的提升方式了。
引用
- Bash 测试和比较函数。用于[[和((选项表。访问日期:2022 年 9 月 25 日
developer.ibm.com/tutorials/l-bash-test/
)。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问,并了解新版本的发布——请扫描下面的二维码:
第十三章:使用 SSH 进行安全远程访问
安全外壳协议(SSH)是一把瑞士军刀——一个可以做任何事情的工具——用于创建安全连接并通过这些连接传输数据。在你的职业生涯中,你将使用 SSH 来做几乎所有事情:
-
安全登录到远程系统
-
克隆你的私人 Git 仓库
-
从你的笔记本电脑向服务器传输文件,或在服务器之间传输文件
-
将通过 VPN 连接的网络服务映射到你笔记本电脑的本地端口,以便你家庭网络中的某人可以使用它
-
其他涉及通过多个网络连接隧道传输流量或发送文件的任务
在本章中,我们将为你提供你所需的所有基础知识,让你感到得心应手。你将学习公钥加密是如何工作的,这是理解这些工具及其使用的基础。你将创建 SSH 密钥,并使用它们登录到远程服务器。为了巩固这些基础知识,我们甚至为你创建了一个小项目,你将在其中为你经常使用的远程主机设置基于密钥的登录。
在你使用 SSH 时难免会遇到一些问题,我们整理了在实际应用中最常见的 SSH 错误。你将学习这些常见错误信息的含义,以及如何使用 SSH 内置的“调试”选项来解决它们。
在本章中,我们将涵盖以下主题:
-
公钥加密入门
-
消息加密
-
消息签名
-
SSH 密钥
-
将 SSH2 密钥转换为 OpenSSH 格式
-
文件传输
-
SSH 隧道
-
配置文件
让我们直接从公钥加密的基础知识开始,让你能理解本章的其余内容,而不至于觉得它像是深奥的黑魔法。
公钥加密入门
根据你职业发展的路径,你可能已经接触过公钥加密的主题。虽然加密学是一个独立的领域,而这本书并不是关于加密学的,但了解其基础知识是很重要的。幸运的是,核心概念非常简单,掌握了这些就能走得很远。我们会尽量简短地介绍这一部分,然后直接进入你需要使用的命令,帮助你配置并使用 SSH 进行安全访问。
公钥加密是一种使用两个独立密钥的系统,分别称为公钥和私钥。合起来,这些密钥组成了所谓的密钥对。正如名字所示,公钥是可以与任何人共享的密钥,而私钥则必须保持私密,绝对不能泄露。
消息加密
一旦你创建了密钥对,任何使用该密钥对公钥加密的消息都可以用相应的私钥解密。
假设有一个名叫 Alice 的人,她想要发送一条加密消息给 Bob。为此,Alice 下载了 Bob 的公钥,并用它来加密消息。然后,Alice 将加密后的消息发送给 Bob。因为 Bob 拥有匹配的私钥,他可以解密并读取这条消息。即使其他人看到加密后的消息,也无法解密和读取,因为只有 Bob 拥有私钥。
因此,绝对不要将你的私钥与第三方共享。这样做会违反安全原则。
消息签名
还有一种使用这两个密钥的方法:它们可以用来签名消息。对消息进行“签名”是一种加密学方法,用来证明消息确实是由拥有该密钥的人所写。其原理是,虽然用公钥加密的消息可以用私钥解密,但反之亦然——用私钥加密的消息可以用公钥解密。
当 Alice 想要签名一条消息时,可以使用私钥来加密消息(或者加密消息的安全哈希)。任何拥有 Alice 公钥的人都可以用它解密消息,如果解密成功,就可以确定这条消息一定是用 Alice 的私钥加密的。
加密和签名这两种机制通常是一起使用的。此外,签名本身常常用于确保安全(通过验证作者身份),例如,在通过包管理器或应用商店下载软件时。
值得注意的是,这些核心的公钥加密和签名机制被广泛应用于从加密电子邮件到保护 HTTPS 网络流量,再到现代世界中的成千上万种应用。换句话说,Alice 和 Bob 不一定是人类;他们也可以是计算机、服务等。
现在你已经了解了 SSH 如何利用基本的加密构建块来保护你的远程访问,接下来你可以实际应用这些先进的技术了!
SSH 密钥
在使用 SSH 时,你可能会首先做的事情就是创建你自己的密钥对。这将允许你通过 SSH 认证到服务器。创建密钥对的经典命令如下:
ssh-keygen -t ed25519 -C 'John Doe <john.doe@example.org>'
这将创建一个 ed25519
(一种现代椭圆曲线加密算法)密钥对,并以 John Doe <john.doe@example.org>
作为注释。注释类似于你在编程语言中熟悉的注释,可以是任意文本字符串,并不会在功能上产生干扰。在 SSH 的情况下,这个注释将被附加到你的公钥上,方便在将密钥上传到服务器时区分各个密钥,例如在 authorized_keys
文件中。稍后在本章中,我们将更深入地讨论 authorized_keys
文件以及如何使用它们来设置无缝、安全的远程服务器访问。
运行此命令后,OpenSSH 会询问你一些关于存储它创建的密钥文件位置以及你希望用于加密私钥的密码的问题。由于这将是用来访问远程系统的密钥,确保设置一个强密码。
注意
默认情况下,密钥将被放置在你的 ~/.ssh
目录中。
现在你已经创建了密钥对,是时候重申最重要的实用点:永远不要共享私钥。这样做会让第三方冒充你。没有任何服务应该要求你共享私钥。公共密钥应该是共享的,并且可以公开的,它会带有 .pub
后缀,这样你就能区分它。
幸运的是,这些文件的内容看起来非常不同,因此如果你感到困惑,可以查看它们来判断哪一个是哪个:
-
公钥的格式是
<algorithm> <key> <comment>
。 -
私钥以类似
-----BEGIN OPENSSH PRIVATE KEY-----
的行开始,接着是密钥和类似的结束行。
小心不要覆盖这些密钥文件,并确保你将它们都存储在一个安全的备份中。同样,密码管理器是一个不错的选择。许多密码管理器甚至有专门选项来存储私钥文件或通用文本。
这些规则的例外
对于个人使用——例如在你的笔记本电脑上——你总是希望加密你的私钥(在创建密钥时指定一个密码),然后像之前提到的那样永远不要共享它。
然而,在某些情况下,打破这些规则是可以接受的——特别是在为自动化系统设置密钥时。如果你希望构建服务器在检出代码库之前通过 GitHub 进行身份验证,你可能会使用一个私钥未加密的密钥对(除非你希望在每次构建服务运行时手动输入密码)。
机器与机器之间的身份验证和加密是使用加密密钥对的一个重要原因。只要确保总是为这些任务创建专用的、单一用途的密钥对,并且不要在不同的机器或服务之间重复使用或共享这些密钥对。
登录和身份验证
使用基于 SSH 的身份验证登录远程系统的过程类似于这样:
ssh user@example.org
user
是你希望登录的用户名,example.org
是你想连接的任何远程系统的代号。它通常是一个 IP 地址,而不是一个完全限定的域名。
如果你正在使用 SSH 密钥登录,或者需要指定特定的密钥(身份,或 -i
),它看起来像这样:
ssh -i ~/.ssh/id_ecdsa user@example.org
当你访问一个从未连接过的 SSH 服务器时,你将看到远程服务器的指纹。这使你能够确保你连接的服务器确实是你想连接的服务器,并且没有发生中间人攻击。你应该确保这个指纹是正确的。
一旦您输入yes
并将该指纹标记为受信任,它将被保存到一个文件中。如果它发生了变化——例如,如果有人在相同 IP 地址上设置了一个恶意服务器,您的 SSH 客户端将通知您,并且身份验证将不可能成功。
在您将服务器标记为受信任后,您的本地客户端和服务器将协商使用哪种身份验证方式。OpenSSH 提供了多种选项,其中最常见的两种涉及密码或密钥对。根据选择的选项,OpenSSH 将要求您输入密码(或用于解密私钥的密码)。一旦身份验证步骤成功,您将登录。
实际项目:设置基于密钥的登录到远程服务器
假设您有权限访问长时间运行的 Linux 服务器,并希望允许基于密钥的登录,请按照以下步骤操作。
Step 1: 在 SSH 客户端(而非服务器)上打开您的终端
您将在接下来的步骤中使用本地命令行环境。
Step 2: 生成密钥对
如果您已经设置了密钥对,因为您之前在本章中跟随了这些步骤,那么太棒了!您可以跳过此步骤。
如果您还没有 SSH 密钥对,请输入以下命令并按Enter键创建一个:
ssh-keygen -t ed25519
# ensure that the public key is not world-readable
chmod 600 ~/.ssh/id_ed25519
正如之前提到的,我们强烈建议添加一个密码短语以增强安全性。
Step 3: 将公钥复制到您的服务器
生成密钥后,您需要将公钥放置在您的服务器上。公钥通常具有扩展名.pub
,默认情况下将位于您的~/.ssh
目录中。
您可以将其手动复制到远程用户的authorized_keys
文件中(该文件包含该用户的所有授权公钥,每行一个密钥),或者您可以使用ssh-copy-id
程序将所有这些操作压缩为单个命令:
ssh-copy-id username@example.org
将username
替换为远程服务器上的用户,将remote_server_address
替换为服务器的 IP 地址或域名。
此命令将要求您在远程服务器上输入用户密码。输入后,公钥将追加到远程用户主目录下的~/.ssh/authorized_keys
文件中。这允许您登录并在远程机器上执行命令,而无需提示输入密码。
Step 4: 测试一下吧!
现在尝试登录服务器:
ssh username@example.org
如果一切顺利,您应该能够登录而无需输入密码(除非您为 SSH 密钥设置了一个密码短语)。现在,您将使用一个更安全的加密密钥来进行身份验证,而不是使用可能被攻击者猜到的小密码字符串。
欢迎来到安全且无密码的 SSH 访问的美好世界!
将 SSH2 密钥转换为 OpenSSH 格式
当不使用基于 Unix 的操作系统时,你通常会遇到 SSH2 公钥格式。PuTTY 可能是使用这种格式的最著名软件,许多使用 Windows 的人也使用它通过 SSH 进行连接。要连接到SSH 文件传输协议(SFTP)服务器、Git 仓库或其他使用 OpenSSH 密钥格式的系统,你需要将 SSH2 公钥转换为 OpenSSH 格式。下面是如何进行转换。
我们想要实现的目标
我们从一个 SSH2 格式的公钥开始,它看起来像这样:
---- BEGIN SSH2 PUBLIC KEY ----
Comment: "rsa-key-20160402"
AAAAB3NzaC1yc2EAAAABJQAAAgEAiL0jjDdFqK/kYThqKt7THrjABTPWvXmB3URI
pGKCP/jZlSuCUP3Oc+IxuFeXSIMvVIYeW2PZAjXQGTn60XzPHr+M0NoGcPAvzZf2
u57aX3YKaL93cZSBHR97H+XhcYdrm7ATwfjMDgfgj7+VTvW4nI46Z+qjxmYifc8u
VELolg1TDHWY789ggcdvy92oGjB0VUgMEywrOP+LS0DgG4dmkoUBWGP9dvYcPZDU
F4q0XY9ZHhvyPWEZ3o2vETTrEJr9QHYwgjmFfJn2VFNnD/4qeDDHOmSlDgEOfQcZ
Im+XUOn9eVsv//dAPSY/yMJXf8d0ZSm+VS29QShMjA4R+7yh5WhsIhouBRno2PpE
VVb37Xwe3V6U3o9UnQ3ADtL75DbrZ5beNWcmKzlJ7jVX5QzHSBAnePbBx/fyeP/f
144xPtJWB3jW/kXjtPyWjpzGndaPQ0WgXkbf8fvIuB3NJTTcZ7PeIKnLaMIzT5XN
CR+xobvdC8J9d6k84/q/laJKF3G8KbRGPNwnoVg1cwWFez+dzqo2ypcTtv/20yAm
z86EvuohZoWrtoWvkZLCoyxdqO93ymEjgHAn2bsIWyOODtXovxAJqPgk3dxM1f9P
AEQwc1bG+Z/Gc1Fd8DncgxyhKSQzLsfWroTnIn8wsnmhPJtaZWNuT5BJa8GhnzX0
9g6nhbk=
---- END SSH2 PUBLIC KEY ----
目标是将其转换为以下格式的 OpenSSH 公钥:
ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAgEAiL0jjDdFqK/kYThqKt7THrjABTPWvXmB3URIpGK
CP/jZlSuCUP3Oc+IxuFeXSIMvVIYeW2PZAjXQGTn60XzPHr+M0NoGcPAvzZf2u57aX3YKaL93cZSBHR
97H+XhcYdrm7ATwfjMDgfgj7+VTvW4nI46Z+qjxmYifc8uVELolg1TDHWY789ggcdvy92oG
jB0VUgMEywrOP+LS0DgG4dmkoUBWGP9dvYcPZDUF4q0XY9ZHhvyPWEZ3o2vETTrEJr9QHYwgjmFf
Jn2VFNnD/4qeDDHOmSlDgEOfQcZIm+XUOn9eVsv//dAPSY/yMJXf8d0ZSm+VS29QShMjA4R+7yh5Wh
sIhouBRno2PpEVVb37Xwe3V6U3o9UnQ3ADtL75DbrZ5beNWcmKzlJ7jVX5QzHSBAnePbBx/fyeP/
f144xPtJWB3jW/kXjtPyWjpzGndaPQ0WgXkbf8fvIuB3NJTTcZ7PeIKnLaMIzT5XNCR+xobvdC8
J9d6k84/q/laJKF3G8KbRGPNwnoVg1cwWFez+dzqo2ypcTtv/20yAmz86EvuohZoWrtoWvkZLCoyxdqO93ymE
jgHAn2bsIWyOODtXovxAJqPgk3dxM1f9PAEQwc1bG+Z/Gc1Fd8DncgxyhKSQzLsfWroTn
In8wsnmhPJtaZWNuT5BJa8GhnzX09g6nhbk=
如何将 SSH2 格式的密钥转换为 OpenSSH
我们用来创建新密钥的ssh-keygen
命令,也可以通过这个非常简单的命令进行转换:
ssh-keygen -i -f ssh2.pub > openssh.pub
上述命令将从文件ssh2.pub
中获取密钥,并将其写入openssh.pub
。
如果你只想查看 OpenSSH 密钥内容,或者准备好进行复制和粘贴,那么不必担心将标准输出重定向到文件(与上面的命令相同,只是去掉了最后一部分):
ssh-keygen -i -f ssh2.pub
这将简单地显示 OpenSSH 格式的公钥。
一个更实用的例子可能是将同事的密钥转换并追加到服务器的authorized_keys
文件中。可以使用以下命令实现:
ssh-keygen -i -f coworker.pub >> ~/.ssh/authorized_keys
之后,使用相关私钥的同事将能够以运行此命令的用户身份登录系统。
另一个方向:将 SSH2 密钥转换为 OpenSSH 格式
反向操作——将 OpenSSH 转换为 SSH2 密钥——也是可能的。只需使用-e
(用于导出)标志,而不是-i
(用于导入):
ssh-keygen -e -f openssh.pub > ssh2.pub
SSH 代理
当你频繁使用 SSH 密钥登录服务器时,必须每次连接(或重新连接)到主机时反复输入私钥密码,可能会让人觉得烦人。SSH-Agent 允许你在本地会话中存储一个身份(私钥)——换句话说,它让你解密一次私钥,然后将其保存在内存中,直到你注销或启动新的 shell 会话。这意味着你只需添加一个身份(密钥对),然后可以反复使用它,而无需重新解密私钥。
注意
SSH 代理并不总是在你的本地 shell 会话中运行——各种 IDE、窗口管理器、桌面管理器和密码管理器也可以为你运行代理。当你只需输入一次身份密码时,你就知道这是在运行代理。
要将密钥添加到代理中,只需使用ssh-add
命令——参数是该身份的私钥路径:
ssh-add ~/.ssh/id_ecdsa
我们建议养成使用–t
选项的习惯,该选项会设置一个时间限制,指定密钥在内存中保持解密状态的时间。以下命令与上面相同,唯一的不同是它设置了 30 秒的时间限制,之后代理将从内存中删除密钥:
ssh-add –t 30 ~/.ssh/id_ecdsa
要查看已添加到代理中的密钥,可以使用以下命令:
ssh-add -L
要从 SSH 代理中删除所有身份,使用-D
:
ssh-add -D
注意
如果您已经向代理中添加了超过三个身份,您可能仍然需要在通过 SSH 登录时指定-i $YOUR_IDENTITY
。这是因为大多数服务器在三次错误尝试后会拒绝登录,而 SSH 在登录时会逐一尝试代理中存储的每个密钥。如果第一个密钥无法工作,它将尝试第二个,以此类推。如果服务器在三次尝试后中止登录,您将永远无法使用代理中的第四个密钥。
在使用 SSH 登录远程机器时,您可以启用代理转发(-A
);但我们建议您谨慎且小心地使用它,原因如下:
ssh -A user@remotehost
使用此命令登录remotehost
后,SSH 将把您的密钥转发到该主机,以便您可以从那里跳转到其他主机。我们推荐谨慎使用这一功能,因为它允许已被攻破的主机看到您的私钥,而我们希望您始终保持这些密钥的私密性,最好保存在您的计算机上,或者在您最狂野的时刻,保存在密码管理器中。换句话说,如果remotehost
已经被黑客攻击,那么您的 SSH 密钥也会受到威胁。
关于安全性的一点说明:一些 Linux 桌面环境,如 GNOME/MATE,当您使用并解密 SSH 密钥时,会将密钥一直保存在内存中。这是默认行为,且可能带来安全风险,您应该注意这一点。
常见的 SSH 错误和-v(详细模式)参数
SSH 命令中的-v
标志启用详细模式,会输出连接过程的逐步日志。这一功能对于诊断常见问题非常有用,例如身份验证失败、连接超时和密钥不匹配。使用方法如下:
ssh -v username@example.org
您将获得有关 SSH 握手和连接的逐步信息,便于识别和解决可能出现的任何问题。
以下是一些您可能遇到的常见错误,详细输出可以帮助您诊断这些错误:
-
权限被拒绝(公钥/密码):这表示服务器拒绝了您的登录尝试。详细日志将显示尝试过的密钥,帮助您确定是否使用了正确的密钥,甚至是否提供了密钥。这是一个极其常见的问题,尤其是在客户端存储了超过三个密钥对,而服务器仅允许三次尝试时。
-
连接超时:如果连接时间过长,可能是网络问题或 IP 地址/端口错误。
-v
标志会显示连接过程卡住的位置,帮助您了解客户端是否到达了服务器。 -
连接被拒绝:这通常意味着 SSH 没有在服务器的目标端口上运行(或无法访问)。详细输出会清晰地指出连接尝试被拒绝,帮助您集中精力检查防火墙规则或 SSH 服务器设置。
-
主机密钥验证失败:服务器的密钥与系统的
known_hosts
文件中保存的密钥不匹配。-v
标志会显示不匹配的密钥,此时你可以重点查看为什么(例如,这个 IP 地址或主机名是否有新服务器?)。 -
无法解析主机名:通常与 DNS 或网络问题有关。
-
无法连接到主机:这通常表示网络问题,可能涉及防火墙或路由设置错误。
-
身份验证失败次数过多:已达到最大身份验证尝试次数。详细模式将显示所有已尝试的方法,其中可能包括不需要的或意外的密钥提议。
-
密钥加载错误:这些通常表示你的 SSH 密钥的格式或权限存在问题。
-v
标志会显示 SSH 客户端正在尝试加载的密钥,你可以检查格式或权限问题。
使用-v
标志将帮助你理解到底出了什么问题,以及如何修复它。至少,它会帮助你开始朝正确的方向查找。
文件传输
在下面的章节中,我们将探索用于文件传输的sftp
和scp
命令。通过几个示例,你将理解如何在大多数情况下处理文件。也就是说,我们还将介绍不使用 SFTP 或 SCP 的文件传输方式,以防它们在服务器上被禁用。
SFTP
虽然 OpenSSH 通常用于登录远程系统,但它也允许在不需要登录会话的情况下进行文件传输。这通常是通过 SFTP 子系统实现的。虽然 SFTP 类似于文件传输协议(FTP),它实际上是一个完全自定义的协议。像 FTP 一样,SFTP 允许经过身份验证的用户向远程服务器上传和下载文件。与不安全的 FTP 不同,SFTP 的身份验证和文件传输是安全的,并且完全加密。
有许多 FTP 客户端也支持 SFTP。一个著名的例子是 Filezilla,它有一个出色的图形用户界面。然而,由于这是一本关于 Linux 命令行的书,我们将为你提供如何在命令行中使用 SFTP 的基本概述。
身份验证几乎与ssh
相同:
sftp user@example.org
完成后,你将看到一个类似 FTP 的界面。它接受一些我们在前几章中已讨论的简化/修改版的 Shell 命令,并引入了一些新的命令。以下是最常用的命令:
-
help
:列出所有命令并提供简短总结 -
ls
:列出远程目录的内容 -
lls
:列出本地目录的内容 -
cd
:更改远程目录 -
lcd
:更改本地目录 -
pwd
:显示你所在的远程目录 -
lpwd
:显示你所在的本地目录 -
get
:从远程服务器下载文件 -
put
:将文件上传到远程服务器 -
chmod
:更改远程文件或目录的权限 -
chown
:更改远程文件或目录的所有者 -
quit
,exit
,bye
:退出 SFTP(CTRL+D 也有效)
SCP
scp
命令通常比 sftp
更实用,用于上传和下载文件和目录。虽然历史上它独立于 SFTP,但今天它使用了 SFTP 子系统。它作为 cp
命令的替代品,除了它可以实现从远程服务器上传和下载。
命令格式如下:
scp $SOURCE:filepath $DESTINATION:filepath
$SOURCE
,$DESTINATION
,或者两者都可以是远程系统;scp
使用你已经看到的 SSH user@example.org
语法。
下面是实际操作的样子:
scp user@example.org:/home/user/my_remote_file /home/user/my_local_file
这将执行以下操作:
-
连接到
example.org
。 -
作为
user
进行身份验证(如果你没有使用 SSH 密钥和 SSH 代理,它会提示你输入密码)。 -
将文件复制到本地路径
/home/user/my_local_file
。
如你所见,参数的顺序与 Linux 的 cp
(复制)命令相同。
反转源和目标参数——上传文件,而不是下载文件——看起来应该是你预期的样子:
scp /home/user/my_local_file user@example.org:/home/user/my_remote_file
就像使用 cp
一样,你可以指定相对路径。以下命令会将远程文件复制到当前(本地)目录:
scp user@example.org:/home/user/my_remote_file .
与 cp
命令一样,也可以使用 -r
(递归,某些系统上是 -R
)标志递归地复制整个目录:
scp -r user@example.org:/home/user/directory local_directory
巧妙的示例
与所有命令和工具一样,ssh
和 scp
也可以在脚本中使用;例如,你可以使用 ssh 快速备份数据库:
ssh username@example.org "pg_dump databasename | gzip -c" > database_backup.sql.gz
如果在脚本中使用类似的巧妙 shell 技巧,请注意在出现错误时进行提示;否则,程序可能在遇到问题时默默卡住。对于开发环境来说,这种命令确实非常实用。
如果没有 SFTP 或 SCP
在一些罕见的情况下,你可能会发现 SFTP 在服务器上被禁用,且只能登录到交互式 shell。你仍然希望将文件传输到远程系统,尽管可能可以在编辑器中打开文件,但并不总是可行(例如,整个目录或二进制文件)。下面是一些你可以用来实现目标的技巧(它们都利用了 Unix 管道与 SSH 会话的结合)。
最简单的情况是从远程服务器下载文件:
ssh user@example.org "cat /path/to/file" > local_target_file
此命令将执行以下操作:
-
登录到服务器。
-
在远程服务器上运行命令
cat /path/to/file
,这将导致该文件的内容被流式传输到stdout
。 -
stdout
可以被本地重定向到我们选择的文件中。
像大多数行为规范的 Unix 软件一样,错误信息和其他可能干扰的输出会发送到 STDERR
,所以你无需担心密码提示会阻碍文件内容的传输并破坏通过 SSH 隧道传输的文件内容。
目录上传和 .tar.gz 压缩
让我们做点不同的事情,上传整个目录。由于这可能是相当大的数据,我们也将使用tar
程序进行压缩。tar
是一个将多个文件和/或目录合并为一个文件(即“归档”)的命令。
在归档后添加压缩步骤是很常见的——例如,你可能见过以tar.gz
或tar.bz2
结尾的文件。这意味着文件首先使用tar
归档成一个单一文件,然后使用gzip
或bzip2
进行压缩:
tar czf - /home/user/directory_to_upload | ssh user@example.org "tar -xvzf -C /home/user/"
在这里,我们首先使用tar
归档/home/user/directory
,将结果变成字节流。
-
是目标文件。如果你想立即存储它,而不是将其传输到另一个程序,它可能是像/home/user/directory.tar.gz
这样的路径。就像许多 Unix 命令一样,破折号意味着该内容应写入标准输出(stdout)。
然后,生成的字节流会被传递到ssh
进程中,作为远程系统上的tar
命令的输入(stdin
),该命令会解压并解档该流,将结果目录写入/home/user/directory_to_upload
。
隧道
SSH 隧道用于通过 SSH 连接传输数据。在接下来的部分中,我们将探讨两种隧道方法:本地转发和代理。
本地转发
SSH 可以创建到远程系统的安全加密隧道。这个功能类似于 VPN,能够让你访问远程系统可以到达的服务。
这是一个强大的功能,实际上通过 SSH 可以很容易地实现。你只需要在建立 SSH 会话时,指定一个额外的参数-L
,以及目标和要绑定的本地端口。
假设有一个远程系统在8080
端口运行 HTTP 服务器。你想在你的笔记本电脑上通过3000
端口访问它。以下是使用简单命令实现这一目标的方法:
ssh -L 3000:localhost:8080 user@example.org
现在,你可以打开浏览器并访问http://localhost:3000/
,像在远程系统上打开浏览器并访问http://localhost:8080
一样,访问 Web 服务器。
代理
如果你想访问一个你没有权限访问的服务器,但远程服务器有权限的情况,也是一样的。假设app.example.org
是你运行 Web 应用程序的地方。这个 Web 应用程序连接到像 PostgreSQL 这样的数据库服务器db.example.org
,而这个数据库服务器仅能从www.example.org
内部网络访问。像大多数生产数据库一样,它被防火墙保护,防止外部直接连接。
从网络的角度来看,它是这样的:
(localhost) ——-> (app.example.org) ——-> (db.example.org)
你可能想使用本地的psql
PostgreSQL 客户端连接到那个数据库。你可以像这样创建一个隧道:
ssh -L 5000:db.example.org:5432 user@app.example.org
这将打开一个新的 SSH 会话连接到app.example.org
,从那里连接到数据库服务器,并将db.example.org:5432
映射到localhost:5000
。
现在,在你的笔记本电脑上运行 psql --port=5000 --host=localhost dbname
将通过 app.example.org
进行中转,从而连接到 db.example.org:5432
上的 Postgres 数据库。
配置文件
可以在 .ssh/config
中指定主机配置。这在多种情况下都非常有用,因为它允许你指定:
-
为主机设置自定义(友好的)名称
-
默认用户
-
端口
-
在连接前打开的隧道
-
身份文件(密钥)
以及许多其他内容。
注意
如果你连接的服务器有一个永久的 IP 地址,那么在 SSH 配置文件中指定它是有意义的,这样可以避免在灾难恢复情况下依赖 DNS 或 CDN。
SSH 配置文件并不特别复杂,因此我们将在这里展示一个使用许多可用功能的示例:
# Set Defaults for all hosts using the glob character (*)Host *
ServerAliveInterval 30 # Check if the connection is alive every 30 seconds
ForwardAgent yes # Forward SSH agent to the remote host
Compression yes # Enable compression
IdentityFile ~/.ssh/id_rsa # Default identity file
# Specific settings for host "example1.com"
Host example1
HostName example1.com # The real hostname to connect to
User john # Default username for this host
Port 22 # SSH port (default is 22)
IdentityFile ~/.ssh/id_ecdsa # Different identity file for this host
# Settings for another specific host "example2.com"
Host example2
HostName example2.com
User jane
Port 22000 # Different SSH port for this host
IdentityFile ~/.ssh/id_ed25519
# Using a jump host to connect to a host behind a firewall
Host target-behind-fw
HostName 192.168.0.2 # Private IP of the target host
User alice
Port 22
ProxyJump jump-host # Use 'jump-host' as a jump host
# Configuration for the jump host
Host jump-host
HostName jump.example.com # Public IP or domain of the jump host
User jumpuser
Port 22
IdentityFile ~/.ssh/jump_key
# Using a SOCKS proxy to connect to a host
Host proxy-host
HostName proxy-target.com # The real hostname to connect to
User proxyuser
Port 22
ProxyCommand nc -X 5 -x localhost:1080 %h %p # Using SOCKS proxy on localhost port 1080
结论
OpenSSH 是一个非常多功能的工具,我们希望你在这一章中获得的介绍能够激发你进行实验并学习更多。想想我们涵盖的所有内容:
你已经学会了公钥密码学的基础知识,这对于理解这类工具及其使用至关重要。你了解了如何创建 SSH 密钥,并将它们用于远程 shell 会话。
希望你通过跟随本章并为你经常使用的远程主机设置基于密钥的登录,也获得了一些实践经验。如果该远程主机恰好位于 Amazon Web Services(AWS)或其他使用 .pem
密钥的平台,你还学会了如何在密钥格式之间进行转换(单凭这一技巧,就一定能让你的同事印象深刻)。
即使你自己没有遇到这些问题,我们也向你展示了人们在实际操作中常遇到的 SSH 错误,以及如何使用 –v
选项来追踪这些问题。
我们甚至讨论了远超远程交互式 shell 会话的 SSH 用法——文件传输、隧道网络流量以及为不同服务器设置自定义配置。令人惊讶的是,OpenSSH 还能做更多的事情:
-
使用
ssh-keygen
加密和签名文件 -
通过 FIDO/U2F 添加双因素认证,并将密钥存储在外部设备上
-
强制在登录后运行某些命令,这既可以限制攻击面,又能让 SSH 成为服务接口的一部分
OpenSSH 项目在其手册页和网站上提供了优秀的文档。如果你遇到需要在机器之间建立安全连接的问题,并且希望使用经过实战检验的技术,那么 OpenSSH 值得一试。现在,去加密吧!
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第十四章:使用 Git 进行版本控制
Git 是一个 分布式版本控制系统(DVCS),在过去的二十年里,它已经成为全球使用最广泛的版本控制系统。虽然你很可能已经知道如何使用 Git 的基础知识,但你可能不熟悉常见的命令行模式,或者一些它的较少使用(但强大的!)功能。我们将在这里讲解这些内容。本章还将提供一些背景知识,使得常用的 Git 术语更易理解,常被提及的概念更加清晰。
你将学到的内容包括:
-
Git 和分布式版本控制的基础
-
第一次 Git 设置
-
基本的 Git 命令
-
常见的 Git 术语
-
两个强大且稍微复杂一点的 Git 概念:二分查找和变基
-
Git 最佳实践,尤其是在有效使用提交信息方面
-
有用的 Git Shell 别名,它们能帮你节省大量打字时间
-
你可以用来与 Git 交互的 GUI 工具
最后,穷人的 GitHub 部分介绍了一个小而有用的项目,你可以通过这个项目来练习并整合你迄今为止学到的 Linux 技能。我们希望你能试试看:如果你这么做了,你在命令行上的熟练度和舒适度将会有极大的提升。
Git 的一些背景知识
Git 是一个由 Linux 内核创建者 Linus Torvalds 开发的 DVCS。Git 的起源可以追溯到 2005 年,当时 Linux 内核社区与一个名为 BitKeeper 的专有分布式版本控制系统的关系破裂。
为此,Torvalds 旨在创建一个免费的开源 DVCS,满足 Linux 内核开发过程的需求。仅仅几天内,他就构思并奠定了 Git 的基础。
Git 优先考虑性能、安全性、灵活性和非线性开发(支持成千上万的并行分支),因此迅速在软件开发社区中获得了广泛的关注。它的设计强调速度、数据完整性和支持分布式工作流,这使得它成为开发者的最爱,随后它也成为了软件行业版本控制的事实标准。
什么是分布式版本控制系统?
传统的版本控制系统(如 并发版本系统(CVS)等)使用中央服务器,始终保持单一、统一的仓库状态。这些系统允许开发者推送和拉取代码,并支持使用分支、标签等常见机制。重要的一点是,这些版本控制系统的设计是以中央权限为核心的。
Git 和其他分布式版本控制系统(如 Mercurial 和 Fossil)采用不同的方法。每个开发者都有自己完整的仓库。其他开发者则不是通过一个中央服务器,而是从彼此的仓库中拉取更改。在 Linux 项目的情况下,有数百个开发者独立使用的仓库。一旦开发者认为其中某个仓库的状态已经准备好,他们会请求将更改拉取到主内核中。这就是 pull request(拉取请求)一词的来源。
虽然 GitHub、GitLab、sourcehut 等提供了 Git 的集中式托管,处理用户授权等事务,并提供许多与软件项目开发相关的其他功能,但 Git 本身在没有这些托管的情况下也能很好地工作,并提供多种机制来实现这一点。甚至可以通过电子邮件发送和接收补丁和一组提交,而无需离开命令行和 Git。这使得即使贡献者只有一个电子邮件地址,也可以轻松地进行协作,发送补丁。
Git 基础
这里是对最重要的 Git 命令行基础的快速回顾。这些内容作为参考提供,而不是逐步指导—尽管我们已经将它们编写成你可以跟着操作的格式,以便如果你想练习时能够使用。
首次设置
首先,如果你在机器上第一次运行 Git,你可能想设置一些全局配置选项。
将默认分支名称设置为 main
:
git config --global init.defaultBranch main
现在配置你的默认用户名和电子邮件(与所有提交相关联):
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
现在你可以初始化一个新的 Git 仓库。
初始化一个新的 Git 仓库
创建一个目录并进入它:
mkdir my-repo
cd my-repo
现在告诉 Git 你希望将这个目录初始化为一个新的 Git 仓库:
git init
创建并查看更改
创建一个包含一些简单内容的文件,并展示 Git 检测到的变化:
echo "Hello World" >> README
git status
暂存并提交更改
将你做出的更改暂存起来,以便提交,并观察 git status
的输出如何变化:
git add README
git status
显示分阶段的内容:
git diff --staged
提交暂存的更改:
git commit -m 'Add README file'
这是 commit
命令的简短形式,直接指定消息(-m
)。commit
命令也有一个交互式版本,你可以通过仅运行 git commit
来使用它。
该命令的交互式版本(没有 –m
选项)会打开你在 shell 中指定的 EDITOR
环境变量所指定的文本编辑器,一旦文件保存并且编辑器退出——也就是说,当 $EDITOR
命令返回时——提交将被写入。
可选:添加一个远程 Git 仓库
以下命令将添加一个名为 origin
的远程仓库,Git 可以向其推送和拉取数据。这可能看起来像我们在 第十三章,使用 SSH 进行安全远程访问 中讲解的 SSH 登录命令,因为 Git 在这种情况下确实会使用 SSH。Git 还支持其他协议,如 HTTPS。
git remote add origin git@example.org:repo-path
这只是一个例子,但当你在真实的仓库上工作时——例如,存在于 GitHub 上的一个仓库——你需要更改主机名和 repo-path
来匹配你想要添加的仓库。GitHub 和其他源代码托管工具都有清晰的文档说明如何为其托管的仓库执行此操作。
推送和拉取
推送当前分支的更改到远程 Git 仓库:
git push -u origin HEAD
从远程分支拉取更改:
git pull
克隆仓库
让我们克隆一个远程仓库——我创建的一个基于 Linux 课程的项目的所有代码:
git clone https://github.com/groovemonkey/hands_on_linux-self_hosted_wordpress_for_linux_beginners
这将下载代码库的 Git 历史记录,并将远程仓库的源设置为指定的 URL。然后你可以使用本章中已学习的所有 Git 命令来操作代码库。
如前所述,你可以检查仓库的状态:
git status
尽管此命令通常用于检查修改的内容,它也会提供有关正在进行的合并、显示合并冲突期间受影响的文件,并在二分代码和其他各种情况下帮助你。当你不确定发生了什么时,检查 git status
是值得的;很可能你处于一个特殊的 Git 状态中,你要么想要退出这种状态,要么在继续之前完成它。
现在我们已经介绍了你经常使用的命令,让我们帮助你熟悉一些术语,这些术语常常让刚开始使用 Git 的人感到困惑。
你可能会遇到的术语
在理解 Git 词汇的基础上会非常有帮助。虽然其他软件混合使用这些术语可能会令人困惑,但了解它们在 Git 世界中的含义可以让你在故障排除和阅读错误消息时更加自信地工作。
这里是最常见术语的概述及其含义。
仓库
这本质上是一个“项目”,由版本控制管理和跟踪的代码的根目录——包含 .git
目录的项目。仓库保存了你的源代码及其历史和更改。
裸仓库
这具有类似的意义,只是代码未被检出。它匹配 .git
目录中的内容。在托管仓库的服务器上,比如 GitHub、GitLab、sourcehut,或者你公司的 Gogs 或 Gitea 实例中,这些通常在名为 project-name.git
的目录中,只包含你在 project-name/.git
中看到的内容。
分支
如果你将第一个提交想象为新仓库的种子,那么一个项目由各种分支组成。有一个主分支(下文有描述),通常还有一个或多个侧分支,包含项目正在采取的其他方向。
这些可能是主要版本分支,已经修复了其中的错误,但永远不会再合并回主分支。它们也可能是实验性分支,这些分支可能永远不会被合并回主分支。或者,它们可能是正在开发中的新特性或错误修复分支,一旦准备好就会被合并——可能性是无穷无尽的。
主分支(Main/Master)
这是默认分支,当初始化或克隆一个仓库时将使用它。根据项目的不同,它通常包含最新的(正在开发中的)或最新的稳定代码。
HEAD
这是分支上的最新提交。它有时也被称为分支的“尖端”。在命令行中,HEAD
通常与相对提交一起使用。
例如,HEAD~2
表示回退到两个提交;因此,以下命令会显示直到两个提交之前的日志:
git log HEAD~2
在脚本和日常使用中,它也可以作为当前分支的替代,因为它是当前分支的尖端。
标签
与分支不同,标签是一种标记特定提交的方式,例如,用来创建(并随后引用)代码库的特定版本。
浅层
通常,“浅层”用来描述没有或几乎没有历史记录的检出。浅层检出用于当 Git 仅作为获取代码的手段,而不是完整的仓库及其历史记录时。然而,这可能会导致某些依赖更多历史记录的命令和工具无法工作。
合并
合并是将一个分支的代码集成到另一个分支的过程。这种情况可能发生在多种场景中,比如将功能分支合并到主分支、从远程分支拉取更改、从 Git 存储区获取代码等等。这些合并通常是完全自动化的。有时,如在合并冲突的情况下,合并可能需要手动干预。
合并提交
这是由于代码合并所产生的提交。当合并代码时,合并本身会变成一个提交。当出现合并冲突时,这个合并提交将包含解决该冲突的更改。虽然技术上是可能的,但在这种提交中添加其他更改(如额外的错误修复)并不是一个好主意。合并提交应该仅包含使特定合并能够工作的所需更改。
对于 Git 自动处理的无冲突合并,通常只需提交它们,而不对代码或提交消息进行任何手动修改。
合并冲突
当 Git 无法确定如何合并即将合并的代码时,就会导致合并冲突,你需要手动解决,通常使用合并工具。在拉取代码、应用代码存储、合并分支或进行任何其他对当前检出的代码进行操作的活动时,都可能发生这种冲突。合并冲突需要解决并提交。git status
通常会告诉你如何继续。
存储区
有时需要将变更保存起来,稍后再取用。Git 提供了一个机制来实现这一点,称为 stash。stash 像栈一样结构化,使得可以通过 git stash pop
逐步应用变更。
Pull 请求
Git 是一个分布式版本控制系统,这意味着每个开发者都有自己的完整仓库,因此可以独立于其他开发者进行工作,即便他们在同一个项目中。
假设有一个开发者 Steve,他在自己的仓库中做了一些修改。他希望另一位开发者 Sarah 在即将发布的软件版本之前,将这些修改整合到代码库中。Steve 请求 Sarah 拉取这些修改到她的仓库中——正如我们在本章之前所看到的,这就是“pull request”这一术语的来源。
由于许多公司和项目并不使用 Git 作为 DVCS,而是偏好使用一个中心化的、权威的代码仓库,所有开发者都从中拉取和推送代码,因此“pull request”这个术语现在通常用来描述请求将代码添加到该权威仓库(或有时只是添加到仓库的主分支)中的操作。
注意
因为这个概念偏离了 Git 的去中心化特性,所以 Git 本身并没有对应的术语来描述它。实现这一工作流的不同产品(更新代码库的权威中心版本)有不同的命名:GitHub 称之为“pull request”,而 Launchpad 称其为“merge proposal”,GitLab 则称其为“merge request”。
挑选提交
有时只需要从其他分支获取单个变更(提交)。一个典型的例子是开发分支中的 bug 修复,比如一个功能分支应该被添加到稳定分支中发布。这可以通过挑选提交(cherry-picking)来完成。与合并整个分支不同,挑选提交允许你指定单个的提交进行添加。
Bisect 操作
git bisect
是一种快速定位引起变更的提交的方法,通常用于确定哪个提交引入了特定的 bug。为此,你需要指定一个已知的“坏”提交和一个已知的“好”提交。坏的提交包含 bug,而好的提交仍然正常。Git 会向你展示可以用来测试 bug 的提交。以下是一个例子:
git bisect start
git bisect bad
git bisect good a0634a0
Bisecting: 675 revisions left to test after this (roughly 10 steps)
第一行开始进行 bisect。在第二行,我们告诉 Git 当前版本有问题,因此包含了 bug。由于我们知道提交 a0634a0
仍然是正常的,所以在第三行指定了它。当然,这不一定非得是提交,也可以是标签或分支。Git 将会告知我们还需要检查多少个版本。
现在是测试我们试图找出 bug 的代码的时候。如果存在 bug,我们输入 git bisect bad
,否则输入 git bisect good
。反复进行,直到最终定位到引入 bug 的具体提交。
如果你想退出这种模式,回到之前的状态,输入 git bisect reset
即可。
根据你要查找的内容,"好"和"坏"不是最佳词汇,在查找任何其他类型的行为变化时可能会引起混淆。因此,可以改用"旧"和"新",以找到引入新行为的提交。请记住,这两个术语不能混用。它要么是好和坏,要么是旧和新。
还有一些方法可以加快这个过程,比如指定文件或目录,如果你知道行为是如何引入的。如果你知道某个更改与some/directory
或some/other/directory
的内容有关,你可以这样缩小搜索范围:
git bisect start -- some/directory some/other/directory
Git 会确保只考虑对这些路径做出更改的提交。
还有更多方法可以加快这个过程,比如指定多个好的提交,或者甚至传递一个测试脚本,根据退出代码,自动找到提交。如果你需要检查大量提交,查看man git-bisect
也很有帮助。
变基
git rebase
是一种常用的方式,通过“重放”(实际上是重新创建)一组给定的更改(如功能分支)到一个新的基础提交上,而不是它们真正分岔的基础提交,从而保持提交历史易于跟踪。
因为开发通常是分布式的,你可能会有这样的“真实”提交历史:
图 14.1:“真实”提交历史
多个功能分支的历史交织在一起,通常比有用更令人困惑,因此 Git 的rebase
功能用于在合并时精简这些功能提交。
特性 1 先合并,所以它使用了原始的基础提交。历史现在看起来是这样的:
图 14.2:特性 1 已变基/合并 1 月 13 日
特性 3 是下一个需要变基并合并的分支,所以现在历史看起来是这样的:
图 14.3:特性 3 已变基/合并 1 月 14 日
最终,特性 2 被变基,导致其基础提交更改为 1 月 14 日的特性 3 提交。现在我们得到了一个简洁流畅的历史,如下所示:
图 14.4:特性 2 已变基/合并 1 月 15 日
GitHub 和其他集中式 Git 仓库托管服务在合并时具有自动化此过程的功能,因此你很少需要在命令行上手动变基。然而,下面是执行此操作的过程:
-
创建一个新分支并添加一个提交:
git checkout -b dave/myfeature git commit -m "made some changes"
-
假设基础分支名为
main
,并且自从你开始开发分支以来已经有一些提交,你现在可以“基于 main 进行变基”:git rebase main
这将修改 Git 历史,将你分支的提交变基到最新的main
提交,如上图所示。由于你正在更改现有历史,这可能需要强制推送到权威仓库(例如 GitHub 仓库),这可能会导致其他用户出现冲突。请在进行变基时注意这一点。
现在我们已经确定了一些你在使用 Git 时会遇到的关键术语和概念,我们可以概述一些编写有效提交信息的良好实践。
提交信息的最佳实践
一般来说,“每次提交一个更改,每个更改对应一次提交”是保持 Git 提交及历史记录有用的方式。
有很多情况你可能只处理一个主要的更改,但同时还对代码做了一些小的(无关的)修正和改进。这些无关的更改一般应单独提交。保持每次提交专注于你想要完成的具体任务:小修复、修正拼写错误、改变样式、添加一个(单一的)功能等等,即使你最终一次性做了多个相互关联的更改,稍后将它们拆分成多个提交仍然有意义。更频繁的提交可以使这个过程变得更加简单。
这个规则有很多原因。最实际的原因之一是,当你的提交很小时,个别的更改可以很容易地被挑选出来或撤销(即使你在修改代码时没有预见到这种需求)。保持小而集中的提交在别人使用git blame
理解更改时也很有帮助。
良好的提交信息
有时,一些模糊的建议,例如“在使用git commit
时保持提交信息简短”,可能会让人困惑且难以遵循。为了更好地理解,首先有必要解释 Git 的使用目的。Git 作为一个分布式版本控制系统(DVCS),允许通过电子邮件发送补丁。因此,提交信息本身在某种程度上呈现出电子邮件的形式。第一行被视为主题行,简要概述所做的更改,后面跟着一个空行以及对更改的更详细总结。
由于这是一个非常开放的框架,因此有一些普遍公认的规则。像所有此类规则一样,它们可以根据项目或组织的不同而被覆盖,但以下是许多知名开源项目的概述做法:
-
保持第一行简短,最好在 72 个字符以内,用于总结。
-
使第一行使用祈使动词(例如
Add
…,Fix
…)。 -
使主题行首字母大写。
-
如果需要更多内容,可以添加一个空行并提供完整的总结。
-
使用正文部分来解释你为什么进行此更改。这对于未来使用
git blame
的读者来说非常有帮助。 -
确保描述你是如何得出结论或实现的,以及为什么它相关。这对于复杂的提交尤其重要,特别是那些对仅查看代码的人来说可能并不马上显而易见的提交。这在跟踪错误、删除过时的代码、重写系统或理解代码时可以提供极大的帮助。
-
考虑一下你在提交信息中写的内容是否可以更好地放到代码注释中。
-
假设审阅者或未来的读者完全没有上下文。确保代码更改可以被轻松理解。
有了这些建议,你应该能够编写清晰且有条理的提交信息。接下来,我们将探讨一些关于如何轻松高效使用 Git 的进一步建议。
图形用户界面(GUIs)
尽管本书强烈关注提升你的命令行技能,但值得一提的是,确实有一些图形化工具可以在某些使用场景下让与 Git 的交互更加容易。
tig
和 gitk
是两个图形化仓库浏览工具,它们为你提供一个类似许多 IDE 提供的 Git 界面。要尝试它们,只需使用 cd
导航到仓库并运行 gitk
或 tig
。你可能需要通过包管理器安装这些工具;许多 Unix 系统(包括流行的 Linux 发行版和 macOS)默认没有安装它们。
有用的 shell 别名
这里有一些常用 Git 命令的有用 shell 别名。你可以将这些别名添加到你的~/.bash_aliases
文件中(假设你正在使用 Bash):
alias gpo='git push origin $(git branch | grep "*" | cut -d " " -f2)'
alias gp='git pull'
alias gs='git status'
alias gd='git diff'
alias gds='git diff --staged'
如果你每天都要敲很多次 git status
,那么添加一个别名让你只需输入 gs
可能会带来巨大的改善。你可以随意将这些别名更改为更方便的名称——这正是自定义的意义所在!
现在让我们稍微 zoom out(拉远视角),看看如何在构建一个小型 Linux 服务器项目时实际应用所有这些知识:你自己的私人 Git 服务器。
穷人的 GitHub
在这一部分,我们将向你展示如何为自己设置一个远程 Git 仓库。你只需要一个远程机器上的 SSH 账户和本地机器上的 Git 二进制文件(即 Git 命令本身)。如果远程机器上已经安装了 Git,你甚至不需要 root 权限。
这是一个有趣的项目,它会让你熟悉 Git 所涉及的基本操作系统概念。这个设置不一定适合生产环境使用;相反,它会告诉你 Git 并没有什么神奇之处。就像 Linux 中的其他一切一样,它只是文件(在这个案例中,是远程文件和一个 SSH 隧道)。
注意事项
根据你是否有 root 权限以及是否希望与他人共享仓库,你可能需要考虑为共享的 Git 服务创建一个专用用户。这是完全可选的。
我们将使用一个 SSH 账户进行身份验证,因此,如果你共享 Git 仓库,与你共享的人将拥有该用户在远程机器上的相同权限。如果你不完全信任其他程序员访问此账户,可能需要在远程机器上为此项目创建一个单独的用户(命名为 git
)。
本项目假设你能熟练地设置 SSH 并连接到服务器——如果你对细节有些生疏,可以参考上一章:第十三章,使用 SSH 进行安全远程访问。
1. 连接到你的服务器
使用你希望仓库属于的帐户连接到服务器(例如 git 或你的用户帐户):
ssh myuser@example.com
2. 安装 Git
首先,通过运行以下命令检查 Git 是否已经安装:
git version
这应该会输出安装在服务器上的 Git 版本。如果你收到类似command not found
的消息,那么说明 Git 并没有安装在你的系统上。
要安装 Git,只需使用系统的包管理器安装git
包。在 Ubuntu 上,你可以运行以下命令:
apt-get install git
3. 初始化仓库
现在你可以初始化一个新的裸仓库。在这种情况下,我们将其命名为my-project
。你可以在任何你想要的位置创建它。为了简便起见,我们假设它位于你的主目录下:
git init --bare my-project.git
这将创建一个名为my-project.git
的目录。它不是一个文件,而是 Git 认为是仓库的目录结构。我们在这里不深入讨论,实际上在很长一段时间内,你可能不需要做任何更改。
信不信由你,其实这就是你需要做的所有步骤!
现在你可以断开与服务器的连接(如果你通过 SSH 连接,可以按 Ctrl+D)。
4. 克隆仓库
尽管仓库完全为空,你已经可以克隆它了。在断开与服务器的连接后,从本地机器运行以下命令:
git clone myuser@example.com:my-project.git
如前所述,这假设你在myuser
用户的主目录下创建了仓库。从example.com
主机名后开始(如果你没有设置 DNS,这可能只是服务器的 IP 地址),路径是相对于用户主目录的。如果你想指定完整(绝对)路径,只需从斜杠开始。换句话说,使用命令git clone myuser@example.com:/home/myuser/my-project.git
也会克隆相同的目录。
Git 会警告你克隆了一个空仓库。但由于这是我们预期的结果,所以无需担心。
5. 编辑项目并推送你的更改
现在我们可以切换到克隆的目录并开始进行项目工作:
cd my-project
echo "My personal project" >> README
git add README
git commit -m 'initial commit'
现在我们有了第一次提交,可以将其推送到远程仓库。第一次推送有一个小注意事项:由于仓库仍然是完全空的,它还没有任何分支,甚至连 master 分支都没有。如果你仅仅运行git push
,Git 会告诉你这一点。所以,在第一次推送到一个新仓库时,只需确保告诉 Git 分支信息:
git push origin master
就是这样!
现在,你或其他有权限访问你 SSH 帐户的人可以克隆、推送和拉取这个仓库。你甚至可以设置钩子并做其他有趣的事情。Git 是一个非常强大的工具,拥有丰富的功能。可能需要一段时间才能习惯这些功能,所以就把这当作你的起点吧。
可能性是无限的,我总是很高兴听到人们使用 Git 来处理有趣或独特的用例。祝你玩得开心!
结论
在本章中,你学习了使用 Git 的基本概念、命令和工作流程。现在你应该对一些常用的高级功能和术语有了更清晰的了解,我们还传授了一些关于“软” Git 技能的建议,例如如何写好提交信息。
我们展示的 shell 别名在一天的编程中为我们节省了数百个击键;我们希望它们对你也同样有用,并且你会为所有那些难以记住或难以输入的命令使用命令别名。
我们还希望你能跟随 贫民版 GitHub 项目一起操作!运行这些命令只需要几分钟,但如果你花一个下午时间深入尝试(租一台 Linux 虚拟机几个小时,在上面设置一个远程仓库并推送一些示例提交),你会感受到当你将新学的 Linux 技能结合起来解决实际问题时,它们是多么强大和高效。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第十五章:使用 Docker 对应用程序进行容器化
在过去的十年里,Docker 容器化已经成为 Web 应用和现代微服务的默认打包格式。在容器中,您的程序处于一个非常轻量级、与宿主环境安全隔离的 Linux 文件系统、进程、用户和网络抽象的壳层中。容器镜像也非常便携——它们可以轻松地从开发者的笔记本电脑转移到测试或预发布环境,再到生产服务器。这解决了过去几十年中困扰软件和基础设施的许多问题。
从某种意义上说,容器与您从软件仓库中学习如何安装的 Linux 包非常相似。容器镜像大致上是您应用程序的压缩档案(例如 .tar.gz
文件),以及应用程序所需的所有配置文件和依赖项。这个小包——镜像——可以通过 Docker 执行。关于这种容器的革命性之处在于,它将一切都整齐地打包在一个单一的工件中,并且可以在任何安装了容器运行时(如 Docker)的 Linux 系统上运行。
本章内容本身就足以成为一本书——Docker 和 Linux 容器总体上是非常庞大的主题。然而,像本书中的其他内容一样,我们只关注让您能够舒适地与基于 Docker 的基础设施进行交互所需的基本理论和实践技能。
在本章中,您将了解以下内容:
-
容器解决的应用开发和运营问题
-
容器是什么,它们与 Linux 包的相似之处
-
Docker 镜像与 Docker 容器的区别
-
在开发工作流中使用 Docker 的所有实用基础
-
如何通过 Dockerfile 构建自己的容器镜像(您将容器化一个真实的 Python Web 应用)
-
一些更高级的话题,比如虚拟机和容器的区别,以及 Linux 如何通过命名空间实现容器抽象
-
一些经过实践验证的容器技巧、窍门和最佳实践
让我们开始吧。
容器如何作为包工作
Docker 成为打包软件的标准工具,目的是包括一个已知的、能够正常工作的系统。Docker 容器通常包含您想要运行的软件以及一个完整的,虽然常常经过精简的,Linux 系统作为其执行环境。这个执行环境提供了库和工具,以及一些其他内容,如基本的系统配置,使得容器可以作为一个独立实体运行,不依赖于运行容器的系统。主要目标是确保应用程序能够在开发者的机器、生产和测试环境以及其他地方成功运行,而不需要处理操作系统版本、已安装的库等细节问题。
需要记住的是,操作系统和库并不会消失。库中的漏洞仍然存在,任何打包的依赖项都应因安全等原因进行更新。然而,软件的使用者,无论是最终用户、系统运维人员,还是任何编排软件,现在都获得了一个通用的软件包,不需要关心系统依赖项。虽然软件的运行和配置细节仍然取决于软件本身,但它的执行方式(在容器中)在某种程度上是标准化的。
总结来说,这意味着环境的任何特定设置(例如安装依赖项)现在都在 Dockerfile 中描述,一旦创建了有效的容器镜像,除了特定配置之外,容器应该能在任何支持 Docker 运行的系统上运行,或者更广泛地说,OCI 镜像。
OCI(Open Container Initiative的缩写)提供了指定镜像格式、执行 Linux 容器等标准。它有时与 Docker 同义使用,意思是开发者可能使用 Docker 创建镜像,但在编排器上执行时可能完全不使用 Docker。
没有比在你的机器上安装 Docker 并开始尝试一些命令更好的入门方式了,来,我们开始吧。
前提:安装 Docker
首先,下载并安装 Docker Desktop。你可以在这里找到安装说明:docs.docker.com/get-docker/
。
另外,官方提供了一个出色的入门教程,链接在这里:docs.docker.com/get-started/
,但是我们建议在完成本章内容后再阅读它。我们会介绍一些基础内容,但重点不在于特定的命令行标志,而是如何作为应用开发者使用这些命令和工作流程。
现在你已经安装了 Docker,接下来让我们开始启动第一个容器吧!
Docker 快速入门
Docker 镜像是我们比喻中的“包”——它是一个静态的工件,保存、存储并传输。当它在机器上执行时,就变成了一个容器。这点很重要,因为你有时会听到这些术语被误用:Docker 镜像是容器的不可变基础——容器是一个正在运行的、有命名空间的进程。镜像是预构建的模板,从中生成运行时的活容器。
镜像设计为不可变的:如果你下载一个 nginx 网页服务器镜像并运行它,所做的任何更改都不会影响基础镜像。这是大多数开发者容易犯错的地方,因为他们习惯了长时间运行的虚拟机,虚拟机通常是一次性配置,然后多次启动和停止,并在整个过程中保持其内部状态。
Docker 容器是不同的。理想情况下,它们被设计为短暂和无状态的,而它们所基于的镜像则充当长期存在的蓝图,可以在多个执行环境中启动无限多个容器。
接下来是一个基本的 Docker 工作流示例,旨在帮助你熟悉最重要的 Docker 命令。无需担心记住命令;我们稍后将在本章中深入讲解它们。目前,我们只会解释每一步发生了什么,这样你就能适应下次微服务工作中会看到的内容。
首先,让我们启动 nginx 容器(docker run
)并交互式地(-it
)运行 Bash shell(/bin/bash
):
→ ~ docker run -it nginx /bin/bash
这为我们提供了一个唯一容器的 shell 提示符,该容器是从nginx
镜像启动的。现在,容器的 Bash shell 已连接到我们的终端:
root@e96107c9a58e:/#
让我们写一个名为test.txt
的文件并验证它是否存在:
root@e96107c9a58e:/# echo "I am immutable" >> test.txt
root@e96107c9a58e:/# cat test.txt
I am immutable
我们可以通过常规命令ctrl-d
退出 shell:
root@e96107c9a58e:/#
exit
容器退出后,我们回到了常规的 shell。这是大多数第一次使用 Docker 的用户感到困惑的地方:让我们重新运行第一个命令,再次从 nginx Docker 镜像启动容器,并检查我们的文件:
→ ~ docker run -it nginx /bin/bash
root@c3b4d95ab9e6:/# cat test.txt
cat: test.txt: No such file or directory
文件 A 错误报告!Docker 坏了!
其实不是的。这并不是你写入test.txt
文件的那个容器。如果你仔细看,你可能注意到第二个容器的 shell 提示符中的主机名不同。这是因为每次运行docker run
命令时,都会从指定的镜像启动一个新的容器。容器的设计是运行、退出并永远消失。
实际上,原始容器仍然存在:
→ ~ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c3b4d95ab9e6 nginx "/docker-entrypoint.…" 14 minutes ago Exited (1) 6 minutes ago agitated_hofstadter
e96107c9a58e nginx "/docker-entrypoint.…" 14 minutes ago Exited (0) 14 minutes ago nervous_gould
要删除它们,你可以使用docker rm
并指定你要删除的容器的 ID:
→ ~ docker rm c3b4d95ab9e6
c3b4d95ab9e6
你可以使用docker start
启动一个停止的容器:
→ ~ docker start e96107c9a58e
e96107c9a58e
此时,你将看到容器在 Docker 进程列表中运行:
→ ~ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e96107c9a58e nginx "/docker-entrypoint.…" 18 minutes ago Up 1 second 80/tcp nervous_gould
然后你可以使用docker exec
在容器内执行命令,再次通过–it
启动并附加到 Bash shell 程序。然后,你可以查看我们修改过的文件系统状态(test.txt
):
→ ~ docker exec -it e96107c9a58e /bin/bash
root@e96107c9a58e:/# cat test.txt
I am immutable
然而,长时间保留容器——修改它们的状态、停止并重新启动它们,而不是每次都从镜像启动新容器——是不被鼓励的,这会导致很多 Docker 最初帮助解决的相同问题。
让我们避免所有这些错误,通过强制删除它来永远删除这个正在运行的容器:
→ ~ docker rm -f e96107c9a58e
e96107c9a58e
你也可以先运行docker stop
,然后运行docker rm
,但是使用docker rm -f
强制删除将会一举停止并删除一个正在运行的容器。
你可以看到 Docker 鼓励使用不可变容器来保持状态不偏离镜像,而镜像是应用程序初始环境的“真理源”。如果你想修改镜像,不能直接修改——镜像是不可变的。
变更应该是明确的和有意图的,这对于创建和运行可靠的软件至关重要。我们怎么做呢?我们从原始镜像开始,以可控和可复现的方式做出更改(而不是“SSH 登录到服务器并尝试运行这些命令”),然后通过创建新镜像来保存这些更改。这里就需要强大的 Dockerfile。
使用 Dockerfile 创建镜像
如果你曾经被要求构建新的 Docker 镜像,或修改现有镜像——可能是为你正在开发的 Web 应用程序——你将会大量使用 Dockerfile(请参阅官方的 Dockerfile 文档:docs.docker.com/engine/reference/builder/
)。
大部分新的软件都已经有了官方的(或者至少是开源的、第三方的)Dockerfile。即使你需要在使用这些 Dockerfile 之前做一些自定义,查找你所使用软件或框架的文档也是一个不错的选择。这些示例在软件发布重大升级时通常不会像你自己编写的自定义 Dockerfile 那样容易出错。
此外,一些框架或开发环境,例如 Spring Boot(Java),可以在构建过程中生成 Docker 镜像。
所以,即使你有可能永远不需要自己接触 Dockerfile,但这种可能性不大,你应该对它们的工作原理有基本的了解。
让我们看一下一个非常简单的 Dockerfile,来自开源的 HTTP 回显服务器项目(github.com/hashicorp/http-echo
)。它创建了一个 Docker 镜像,将一个作为简单 Web 服务器的 Go 二进制文件打包在内:
FROM alpine
ADD "https://curl.haxx.se/ca/cacert.pem" "/etc/ssl/certs/ca-certificates.crt"
ADD "./pkg/linux_amd64/http-echo" "/"
RUN apk add curl
ENTRYPOINT ["/http-echo"]
基本上,这通过以下方式创建一个新的容器镜像:
-
使用
alpine
Linux 镜像作为基础进行构建。 -
下载一些证书并将它们添加到镜像层中(本质上是将某些东西添加到最终容器的文件系统中)。
-
将构建目录中的
http-echo
二进制文件复制到容器镜像中。 -
运行 Alpine 包管理命令来安装
curl
程序。 -
定义在从这个镜像启动的容器启动时运行的可执行文件或命令。
这些步骤中的每一个都由 Dockerfile 解析器知道如何执行的(大写)指令触发。这个特定的 Dockerfile 只使用了 Dockerfile 中可用指令的子集(FROM
、ADD
、RUN
和 ENTRYPOINT
)。
这是创建新 Docker 镜像时,Dockerfile 中可用的完整指令集:
-
ARG
:声明一个构建时参数;基本上是一个在构建过程中稍后使用的变量。 -
ENV
:在构建过程中设置的环境变量,这些变量将在你的运行容器环境中持久存在(不仅仅在构建期间)。采用key=value
格式。 -
FROM
:基础镜像。 -
CMD
:为容器启动时运行的默认命令(或默认ENTRYPOINT
参数)。可以被覆盖,但在 Dockerfile 中应有一个。每个 Dockerfile 只能有一个CMD
,如果有多个CMD
,只有最后一个会生效。 -
ADD
:一个灵活的指令,复制文件和目录,将它们添加到镜像的文件系统中。它也可以用于从镜像外部或远程 URL(通过 HTTP)复制文件,并进行复杂操作,如扩展、解压、解档等。你在上面的示例 Dockerfile 中看到它作为curl
命令的替代,用于下载 CA 证书文件。 -
COPY
:仅复制文件和目录,比ADD
更简单、魔法性和功能性更强。 -
LABEL
:添加镜像元数据,格式为key=value
。 -
EXPOSE
:通知使用此镜像的消费者,该容器将监听哪些网络协议和端口。 -
ENTRYPOINT
:告诉容器启动时运行哪个命令。使用 exec 形式 (ENTRYPOINT ["executable", "param1", "param2"]
) 以确保容器能够接收并响应来自容器外部的信号。 -
RUN command arg1 arg2
:在镜像的 shell 中运行command
,并传入参数arg1
和arg2
:-
RUN ["command", "arg1", "arg2", "argN"]
:与上面相同,但有助于避免 shell 字符串混乱。 -
每个
RUN
指令都会在新的镜像层中执行(我们在这里不深入探讨镜像层,但知道这一点可能会有帮助)。 -
RUN --mount
可用于在构建过程中暂时将文件系统挂载到容器中,而无需将文件本身复制到镜像层中。 -
RUN --network
和RUN --security
也存在,用于分别管理网络上下文和特权容器。
-
-
WORKDIR
:为 Dockerfile 中随后的指令设置工作目录。相当于类 Unix 操作系统中的cd
。 -
SHELL
:覆盖在 Docker 构建过程中用于解释命令的默认 shell。命令必须使用 exec 形式。 -
STOPSIGNAL
:设置此容器应解释为退出信号的系统调用信号。默认情况下,这是 SIGTERM,像其他 Linux 进程一样。 -
VOLUME
:定义将从主机挂载进来的卷。 -
USER
:将构建命令使用的(容器)用户更改为此后的用户(可以在构建过程中多次切换用户)。 -
ONBUILD
:定义一个指令,当该镜像作为基础镜像用于另一个构建时触发。 -
HEALTHCHECK
:一些健康检查功能,你可能不会使用它,因为你的容器调度器有自己的健康检查功能。
我们将通过一个实际的、端到端的示例来演示如何将这些内容结合起来,但首先让我们更加深入地回顾一下我们刚才使用过的命令。
容器命令
现在让我们深入探讨一些较为复杂但重要的命令和命令调用,这些命令你在使用 Docker 时可能会遇到。
docker run
让我们来看一下之前使用过的docker run
命令的一个更复杂的调用:
docker run --rm --name mywebcontainer -p 80:80 -v /tmp:/usr/share/nginx/html:ro -d nginx
-
--rm
:容器退出时清理(删除)此容器。 -
--name mywebcontainer
:给这个容器起一个友好的名字——mywebcontainer
。 -
-p 80:80
:将主机的80
端口映射到容器的80
端口。左边的端口号代表“外部”(运行容器的环境),右边的端口号代表“内部”(容器)端口。例如,-p 4000:80
将容器的80
端口映射到localhost:4000
。 -
-v /tmp:/usr/share/nginx/html:ro
:挂载一个卷——主机环境中的/tmp
目录将被挂载到容器中的/usr/share/nginx/html
目录;:ro
确保这是一个只读挂载(挂载的文件不能从容器内修改)。 -
-d
:以分离模式(在后台)运行容器。 -
nginx
:用于启动此容器的镜像。
如果你想在http://localhost:80
上查看一些 HTML 内容,你可以将一个index.html
文件添加到你的/tmp
目录中:
cat <<EOF > /tmp/index.html
<!doctype html>
<h1>Hello World</h1>
<p>This is my container</p>
</html>
EOF
因为我们的/tmp
目录映射到了容器的/usr/share/nginx/html
目录(nginx 会在此目录中查找 HTML 文件),nginx 会立即识别并开始提供该文件。
卷是有状态应用程序可以使用无状态容器运行的机制。
docker image list
要查看你本地下载的镜像,可以运行docker images
(或如果你更喜欢,可以运行docker image list
)。
如果你已经构建并使用了很多 Docker 镜像,列表可能会很长!
$ docker image list
REPOSITORY TAG IMAGE ID CREATED SIZE
nginx latest 51086ed63d8c 10 days ago 142MB
vault latest 22fdc6314051 2 months ago 207MB
golang 1.19-alpine d0f5238dcb8b 2 months ago 352MB
docker ps
docker ps
有点像 Linux 中的ps
命令。它让你查看哪些容器正在主机上运行,并显示一些信息,比如它们的 ID、正在运行的命令、创建时间和运行时间、端口映射等。
运行命令
$ docker ps
将产生类似以下的输出:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2aca849eef73 nginx "/docker-entrypoint.…" About a minute ago Up About a minute 0.0.0.0:80->80/tcp mywebcontainer
docker exec
在容器镜像的开发过程中,通常会进入容器并运行命令。要在运行中的容器中启动一个交互式的 shell,可以使用docker exec
:
docker exec -it mywebcontainer /bin/bash
对于我们之前启动的 nginx 容器,这将会在容器环境内启动一个 Bash shell。你所做的任何状态更改(如文件创建、内核设置等)将在容器停止时丢失——下一个docker run
会简单地从相同的基础镜像状态启动一个新的容器。
docker stop
要停止一个容器,运行docker stop $CONTAINERNAME
——如果容器没有友好名称,你也可以使用容器 ID:
docker stop mywebcontainer
如果容器是使用--rm
选项启动的,就像我们启动的 nginx 容器一样,容器将在停止后被删除,并且其状态(如果与基础镜像有差异)将丢失。
如果容器没有使用--rm
选项启动,它的状态将保留在你的文件系统中,你可以通过docker start $CONTAINERNAME
再次启动该容器。其状态将被保留。
Docker 项目:Python/Flask 应用容器
我们将容器化一个使用 Flask Web 框架的小型 Python Web 服务。这是一个非常常见的模式,Python 非常适合容器化,因为很多 Python 项目的打包和依赖管理非常混乱。你将自己创建所有文件——试着使用命令行文本编辑器进行练习!
1. 设置应用程序
首先,创建一个新目录并进入该目录:
mkdir dockerpy && cd dockerpy
创建一个小型的 Python Web 应用程序。在这个示例中,我使用的是 vim 编辑器,但你可以使用任何你喜欢的编辑器:
vim echo_server.py
将以下文本粘贴进去:
from flask import Flask, request
import os
app = Flask(__name__)
@app.route('/')
def echo():
return {
"method": request.method,
"headers": dict(request.headers),
"args": request.args
}
@app.route('/health')
def health():
return {"status": "healthy"}
if __name__ == "__main__":
env_port = os.environ.get("PORT", 8080)
app.run(host='0.0.0.0', port=env_port)
这就是整个 Web 应用程序——它只是读取一些来自传入请求的信息,并利用这些信息将响应推送回客户端。
保存并退出文件(esc
,:x
)。
创建一个名为 requirements.txt
的文件,文件内容仅包含以下一行:
Flask>=3.0.0
接下来,创建你的 Dockerfile:
vim Dockerfile
输入以下内容:
# Use an official Python base image
FROM python:3.12-slim
# Set the working directory inside the container
WORKDIR /app
# Copy our list of dependencies into the container
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the script into the container
COPY echo_server.py .
# Set a healthcheck to kill the container if it's not listening on the internal port
HEALTHCHECK --interval=30s --timeout=5s \
CMD curl --fail http://localhost:8080/health || exit 1
# Expose port for the application
EXPOSE 8080
ENV PORT=8080
CMD ["python", "echo_server.py"]
目前你只需要这些。你的 dockerpy
目录现在应该包含三个文件:
-
Dockerfile
-
echo_server.py
-
requirements.txt
2. 创建 Docker 镜像
使用 docker build
命令构建一个新的 Docker 镜像。-t
用于为容器打标签并命名:
docker build -t dockerpy .
请注意末尾的 .
字符,它指示 Docker 使用当前目录作为其构建上下文。
3. 从你的镜像启动一个容器
你已经在本章之前使用过 docker run
命令。现在用它从你新构建的镜像启动一个容器:
docker run --rm -d -p 8080:8080 --name my-dockerpy dockerpy
(the command will print out the ID of your new container)
这里有一些新的参数:
-
--rm
告诉 Docker 在容器退出时删除该容器。这防止了旧的容器在文件系统中滞留,就像你在本章的第一个示例中看到的那样。 -
-d
告诉 Docker 将容器后台运行,这样它就不会在前台附加到你的终端。 -
-p
设置端口映射:冒号左边是容器端口,右边是它映射到的主机端口。如果容器应用程序在端口1234
上运行,并且你希望它映射到主机的端口80
,那么命令会写成–p 1234:80
。 -
--name
为你的容器标记一个名字,方便你在docker ps
的输出中找到它。
现在,你的容器化应用程序已经运行,并且可以通过浏览器或命令行访问它。让我们使用 curl
命令连接并向 web 服务发送请求:
curl localhost:8080
{"args":{},"headers":{"Accept":"*/*","Host":"localhost:8080","User-Agent":"curl/8.1.2"},"method":"GET"}
对于那些经历过无法重现的依赖地狱(Python、Ruby 等因这类问题而出名)的人来说,这应该是一个启示。你过去需要与应用程序一起拖动的所有复杂性——从本地开发环境到 CI 和测试,再到预生产,最后到生产——现在都浓缩成一个单一的制品,保证无论在哪里运行,它都包含相同的内容。
我们之前没有用过的一个命令是 docker exec
,它允许你在正在运行的容器内执行命令。如果由于某些原因,你必须检查或修改一个正在运行的容器,这个命令非常有用:
docker exec -it my-dockerpy /bin/sh
这会启动并连接到容器中的 /bin/sh
(大多数生产容器中只会有一个最小的 shell,在 /bin/sh
,并且不会配备像 Bash 这样功能全面的 shell)。
让我们通过接下来要讲解的最后一个命令 docker kill
停止服务器:
docker kill my-dockerpy
这会向进程发送 SIGKILL
(信号 9),而不是 SIGTERM
(信号 15),并立即停止进程,而不给它机会优雅地关闭。
容器与虚拟机
现在你已经初步了解了创建和使用 Docker 镜像的工作流程。然而,了解容器和虚拟机之间的基本区别也是非常有益的。当你在排除操作问题时,这些知识可能会派上用场,同时它也是面试中常见的一个问题,用来评估你对容器化原理的理解程度。
虚拟机(VMs)允许你在另一个主机操作系统上运行完整的操作系统,如 Linux、Windows 或 DragonFly BSD。虚拟机独立于宿主系统运行。实际上,在 macOS 上运行 Docker 会透明地使用虚拟机来提供 Docker 所需的 Linux 操作系统。
因此,虚拟机会运行完整的操作系统,如 Linux,这又使用像 systemd
这样的初始化系统。由于这一点,你管理服务和进程的方式就像管理物理机器一样。就日常使用而言,适用于物理机器的所有操作也适用于虚拟机。然而,容器的使用方式通常并非如此。
Docker 容器通常只包含单个应用程序;实际上,它们通常只包含一个进程。如果容器内有多个进程,通常是因为一个多进程的应用程序生成了子进程(例如,网页服务器或命令执行程序通常会这样做)。由于广泛认同的最佳实践是让容器只运行一个进程,并且在该进程退出时容器也会退出,所以任何形式的内部进程监督和管理在这里都是多余的。
相反,你会发现通常由操作系统的初始化系统完成的工作已经转移到容器运行时环境之外,转移到外部系统中,这些系统负责管理容器,例如 Kubernetes、Nomad 等。
在这种新模型中,容器类似于操作系统进程,而容器编排器则扮演各种操作系统和调度器的角色。
在 Docker 容器中,PID1(一个完整 Linux 操作系统中的初始化系统)是你的 CMD 或 ENTRYPOINT。通常,这就是你正在运行的软件的主进程。通常情况下,容器应该运行一个单一的进程。虽然在某些场景中,人们故意以不同的方式运行容器,但运行一个单独的进程,并在该进程停止时让容器停止,是预期的行为。尤其是在将服务容器化并运行在生产环境中时,应该确保遵循这种方式。这个规则有例外,特别是当运行一些早于 Docker 容器普及的旧软件时,但在这种情况下,你通常会有所了解,并且往往是基于专为此目的制作的容器。
关于 Docker 镜像仓库的快速说明
在本章中,我们一直在使用 nginx
镜像。但这个镜像到底来自哪里呢?默认情况下,Docker 会尝试从 Docker Hub 下载镜像(hub.docker.com/
),Docker Hub 是一个公共 Docker 镜像的中央仓库。Docker Hub 的工作方式类似于 Linux 软件包仓库,包含了可以随时使用的上传 Docker 镜像。大多数流行的服务器软件都可以在那里找到,像你刚刚看到的 nginx
一样,下载并使用非常简单。
然而,并不是所有的应用都是公开的,使用私有仓库来存储 Docker 镜像是很常见的。Docker 镜像仓库提供商的名单不断变化,所以我们不会在这里列出它们,但了解它们都与 Docker Hub 的工作方式相同就足够了。
痛苦的容器化经验教训
当你开始构建自己的容器时,记住 Docker 官方文档中讨论的最佳实践可以帮助你避免许多问题,详细内容请参考:docs.docker.com/get-started/09_image_best/
。
话虽如此,我们列出了一些我们注意到的最严重的容器化错误,以及如何避免它们。这个部分是许多不眠之夜、故障和艰难学习的结果。
镜像大小
从最小的镜像开始,比如 Scratch 或 Alpine。为了部署大多数应用程序,尽量避免使用像 Ubuntu 这样的大型镜像和发行版是一个好主意。如果构建过程中需要依赖项,建议在构建较大或多容器项目时,删除这些依赖项或使用中间构建容器。
小巧、精简的镜像不仅意味着更快的下载速度和更少的资源消耗,还使得你更容易管理它们。如果一个镜像没有包含你不需要的软件和库,那么你需要更新的内容就更少,攻击者可以利用的漏洞面更小,容器安全扫描工具发出的噪声警告也更少。
C 标准库
需要了解你正在使用的C 标准库(也叫做 libc)。许多 Linux 发行版使用 glibc
;一些像 Alpine Linux 则使用 musl
或其他库。这些库及其生成的二进制文件可能在不同的系统间不兼容。例如,在 Alpine 上,你可能需要自己编译一些不太常见的工具。如果你的项目依赖于基础镜像中通过包提供的某些库,你可能会遇到不兼容的问题。当然,升级、降级或完全切换基础镜像可能会引发类似的问题。
然而,由于 Alpine 和 musl
已稳步获得普及,这些问题变得不太可能发生(至少,更容易通过 Google 搜索找到解决办法!)。如果你不依赖任何 C 库,通常这不会是个问题。另外,静态编译你的代码可以使你更独立于底层系统,从而不再依赖基础镜像。
生产环境不是你的笔记本:外部依赖
不要依赖本地挂载或其他本地容器。已部署容器的环境通常与你的笔记本电脑大不相同。仅仅因为你在笔记本上把数据库容器和 Web 应用容器放在一起,并不意味着这些容器在生产环境中会被调度到同一台机器上。
数据卷也是如此——这些容器外的接触点是你需要与 Ops/DevOps 同事进行规划的地方。你可能会通过调度器或其他 DevOps 工具集成服务发现、健康检查和共享卷。
容器理论:命名空间
如果你在想这些容器魔法是如何在底层工作的,或者只是担心有一天在压力下需要排查一个容器环境,那么了解命名空间的概念会很有帮助。如果你不关心容器抽象是如何在 Linux 上构建的,可以跳过这一部分。
命名空间是一个多重含义的术语,在不同的技术领域有不同的解释。在 Linux 容器的上下文中,命名空间的概念最好通过 chroot
(更改根目录)来解释。chroot
是一个老旧的 Unix 和类 Unix 操作系统工具,允许用户更改文件系统的根路径(即 /
路径)。
这个工具的使用其实非常简单:chroot /some/path
将设置/some/path
为新的根目录/
。除了允许操作系统安装程序切换到当前正在安装的系统以执行命令外,它还允许进行基本的命名空间隔离。事实上,许多软件和不同 Linux 发行版的配置已经在利用chroot
来增强安全性,因为使用chroot
本质上将文件系统的某些部分排除在当前可读范围之外——它使得新根目录外的任何内容都无法访问。因此,如果攻击者利用某个漏洞在运行在chroot
环境中的 web 服务器上执行远程代码,那么系统和此目录外的任何文件将保持不受影响。
用于在 Linux 和其他操作系统上实现容器的技术原语在过去十年中发生了显著变化,未来可能还会继续变化。幸运的是,低级实现对你作为主要使用容器化技术的软件工程师并不至关重要,而对于容器技术的操作员或实施者则非常重要。
“容器”抽象依赖于像以下技术:
-
文件系统命名空间(例如,使用
chroot
)。 -
用户和进程的命名空间,使得容器外部的进程在容器内部不可见。换句话说,容器中的 root 和 PID 5 将分别映射到容器命名空间外的非特权用户和另一个进程 ID。
-
资源分组和计费技术,如
cgroups
。 -
网络虚拟化/命名空间,使得容器无法直接访问网络接口,同时也可以处理端口号重叠问题。例如,你可以运行两个不同的容器并暴露端口
8080
;由于容器的网络栈相互独立,因此不会出现端口已被占用的错误。
我们如何通过容器进行运维?
虽然本书不是面向系统管理员或站点可靠性工程师的,但你应该了解容器通常运行的基本背景。主要思想是,容器大致上是无状态的“函数”,它们处理输入(来自其他服务的 web 请求或 HTTP 消息)并生成输出(web 响应、侧面效应和流向 STDOUT 的日志)。在运行良好的操作环境中,容器可以视为 Linux 进程或编程中的函数的类比。
容器通常通过像 Kubernetes、Nomad 等第三方工具层“调度”到主机上。如果容器类似于进程,那么这些工具就充当操作系统调度器的角色(整体系统是一个分布式系统,而不是单一主机)。
容器的输出通常由相同的工具捕获,并重定向到日志解决方案,如 Logstash、Graylog 和 Datadog。所有运行中的容器的度量指标可以被提取并输入像 Prometheus 这样的工具进行分析和故障排除。
结论
在这一章中,你对与 Docker 及容器工作相关的最重要的事项进行了快速浏览。虽然单独的技术可能会发生变化——比如哪个容器调度器流行,或如何最好地处理日志流——但我们始终专注于每个现代软件开发者应该掌握的核心理论和技能。
我们希望你从这一章中带走几个主要观点。首先,我们希望你能直观地理解容器化为人们解决了哪些问题,主要通过控制复杂性并将依赖打包成一个单一的工件。
同样重要的是要记住图像和容器之间的区别,并且通过使用官方文档,练习从零开始构建自己的 Dockerfile。
我们希望访问一些更高级的话题,比如虚拟机和容器的区别,以及命名空间是如何工作的,这些会在故障排除或面试时派上用场。我们讨论的最佳实践也会在这些情况下有所帮助。
最后,为了巩固你的学习,我们建议你通过将自己的应用程序容器化来练习这些技能。你将学到很多东西,而且在本章的所有信息仍然鲜活在你脑海中的时候开始会更加容易。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第十六章:监控应用程序日志
欢迎进入 Linux 日志的世界!作为软件开发者,理解 Linux 中的日志,特别是使用像systemd
和journald
这样的工具,是至关重要的。下面是你需要了解的内容概览。
日志是记录软件应用程序或操作系统中发生事件的记录。它是一种灵活的格式,并且每个应用程序的日志格式各不相同,但日志是如何处理、存储和检索的,在现代系统中相对统一。作为开发者,理解日志至关重要,因为你在 Linux 中可以访问的日志能为你提供操作系统及其上运行的所有应用程序的行为洞察。你将利用这些知识来理解错误、跟踪应用程序性能以及进行调试。日志是故障排除的第一道防线,因此准备好与它们建立亲密关系。
在本章中,我们将为你概述 Unix 和 Linux 的日志系统,并展示软件开发者与日志互动的最常见方式。你将看到:
-
系统及其上运行的应用程序如何生成日志
-
大多数现代 Linux 系统上日志的收集位置
-
过去日志如何工作的历史知识,这些知识在你遇到许多生产系统时仍然非常有用
-
如何在故障排除应用程序时查找和查看日志
-
在公司以及云环境中部署服务时,日志如何被集中化管理
我们还将提供一些关于如何充分利用结构化日志的技巧,并避免开发者常犯的一些常见陷阱。
日志简介
正如我们在介绍中看到的,日志仅仅是信息性消息——记录软件应用程序或操作系统中发生的事件。像许多 Unix 概念一样,日志并没有硬性规定:如果你写了一个两行的脚本,往文本文件中写入时间戳,那也可能算作一条日志。有些日志是简单的纯文本字符串,发送到系统上的已知文件位置,其他则是由像systemd
这样的守护进程专门管理的高度结构化的二进制数据。
作为开发者,你可能对日志级别比较熟悉,它们是表示软件中事件紧急程度的标签。例如“错误”、“信息”和“调试”信息,你肯定在开发软件时看到过它们在终端滚动显示。我们稍后会介绍这些常见的日志级别,但目前,你需要了解在现代的、功能齐全的 Linux 环境中,日志有三大主要的来源:系统、服务和非服务应用程序日志。日志的来源可以为你提供关于特定日志消息的关键信息。
系统日志是操作系统(“内核”)本身发送的日志。这些日志包括错误、硬件事件消息、资源消耗与限制、配置与安全性,以及系统状态中的显著变化。
服务日志是由系统上运行的服务产生的。具体来说,在 Linux 上,它们是由systemd
初始化系统管理的服务产生的,这些服务通过名为journald
的服务进行日志记录。它们可以提供有关各种服务的健康状况和状态的洞察。
在你可能遇到的系统上,系统日志和服务日志通常会一起混合在journald
中。随着我们深入本章,你将学习到关于systemd
、journald
(和journalctl
)的所有内容。
非 systemd 管理的应用程序是那些通常不通过journald
进行日志记录的特例。你需要通过每个应用程序的文档来查找它们的日志文件,尽管行为规范的应用程序通常会在像/var/log/$APPLICATION_NAME/
这样的目录中写入自己的日志文件,其中$APPLICATION_NAME
是应用程序的名称。
在本章中,当我们深入学习时,你会发现理解journald
和journalctl
命令的重要性。然而,在进入这些内容之前,我们应该先说明一些关于 Linux 日志记录的细节。
在 Linux 上的日志记录可能会变得...怪异
你现在应该已经看到,类 Unix 系统是极其灵活的。如果你不喜欢默认的做法,你可以打破常规,按自己的方式进行配置。
这也是在学习 Unix 和 Linux 基础时的一个巨大缺点。许多事情——从软件配置到默认用户设置——可以通过多种方式进行配置,而且在新环境中没有办法知道常规做法是什么,除非问(有时还需要排除故障)。
这种怪异现象在日志记录中尤为明显,尤其是随着最近公司计算方式的变化而产生的影响。日志记录在过去几十年里是按一种方式进行的,当时大多数公司直接购买、配置和管理长期使用的物理服务器,每台服务器上安装着单一的操作系统。随着工作负载转向云计算,以及每台物理机器上运行多个操作系统(虚拟机)甚至每个操作系统上运行多个环境(容器),传统的日志记录方式也发生了变化。
这也就是说,当你在新工作或团队中了解日志记录时,问题不是“Linux 上是如何进行日志记录的?”,而是“这里目前是如何进行日志记录的?”这实际上取决于你使用的软件开发人员所做的决定。在学习本章关于日志记录的内容时,我们建议你记住这一点。
发送日志消息
虽然在大多数情况下,服务会通过库或仅仅写入stdout
来进行日志记录,类 Unix 系统提供了一条命令来将日志发送到 syslog 服务器。我们将在下面讨论这意味着什么。由于syslogd
和systemd
提供了一个 syslog 服务器,无论你使用什么类型的系统,因此有一个统一的命令来发送日志消息:
logger Hello World!
这将记录 Hello World
。logger
命令有很多选项,它在调试问题时、希望在 shell 脚本中记录日志时,或解释日志如何工作的过程中都非常有用。
systemd 日志
当系统使用 systemd
时,journald
会负责日志记录工作。当你在排查一个运行 systemd
的 Linux 机器时,这是你应该首先查看的日志位置。默认情况下,journald 会捕获所有受控进程的输出。任何在 stderr
上发出的内容都会被视为错误。因此,除非软件被配置或硬编码为记录到不在 stderr/stdout
位置的地方,否则你将会在 systemd
日志中找到这些日志。
记录到 journald
的日志可以通过 journalctl
命令进行查询。它提供了基于单个服务、时间和系统重启进行查询的方法,并允许使用类似 tail
命令的选项。让我们开始使用 journalctl
进行一些实践。
示例 journalctl 命令
使用 journalctl
的基本操作非常简单。想想看,当你在排查应用程序问题时,你需要做什么来查看日志?
首先,你需要能够找到并查看当前的日志。journalctl
可以为你提供这个功能,但你会很快意识到,你实际上并不需要 所有 的日志,只是需要查看最近的日志。所以,让我们使用 -n
选项进行过滤。
要查看 journald
中最后 100 条日志消息,可以尝试以下命令:
journalctl –n 100
这将打印出系统记录的最后 100 行日志。你会注意到,这与本书前面解释的 tail
命令类似。如果你跟着做,这些日志很可能包含上面的“Hello World!”消息。
跟踪单元的实时日志
你可能还想实时查看日志。例如,在启动过程中跟踪你的应用程序日志可以帮助你准确地看到问题发生的时间:
journalctl -fu unitname
-f
选项表示“跟踪”,而 -u
选项表示“单元”——即你想要过滤日志的系统单元(或“服务”)。
按时间过滤
即使你过滤到一个特定的单元,你仍然可能会被匹配的日志数量所淹没。按时间过滤在这里特别有用,尤其是在你尝试将已知的外部问题(如停机、错误等)与从那一刻起的应用程序日志关联时。可以使用 --since
和 --until
来实现:
journalctl --since "2021-01-01 00:00:00"
你也可以使用一些简写方式,如 today
:
journalctl --until today
你还可以使用 --until
设置过滤器的结束时间,并将这些选项组合起来,达到非常具体的过滤效果。例如:
journalctl --since "2021-01-01 00:00:00" --until "1 hour ago"
注意
使用时间过滤和查看日志时的一个注意事项是,你几乎总是需要使用 --utc
选项,它会以 UTC 时间显示时间戳。当你帮助运维团队排查停机故障时,几乎总是使用 UTC 时间,这样可以避免时区相关的混淆。
还有一些其他过滤器,如按用户/组 ID 过滤。
按特定日志级别过滤
如果你知道自己要查找的是错误信息,可以告诉 journalctl
只显示错误日志(或列出的其他日志级别,按照优先级递减的顺序:wiki.archlinux.org/title/Systemd/Journal#Priority_level
:emerg
、alert
、crit
、err
、warning
、notice
、info
、debug
):
journalctl -p err
检查之前启动的日志
有时候,情况会变得非常混乱,故障甚至会导致系统重启。在这种情况下,你可能需要查看之前启动时的日志。你可以使用 --list-boots
查看所有可用的启动记录,如下所示:
journalctl --list-boots
然后使用 -b
参数从列表中选择一个特定的启动记录。在这种情况下,我们要选择标签为 2
的那一项:
journalctl -b –2
单独使用 –b
标志表示“当前系统启动”。
内核消息
在引言中,我们提到过,系统级日志消息是由操作系统(在 Linux 术语中是“内核”)发送的。要查看仅由系统发送的这些消息,请使用 --k
(或者出于历史原因,使用 --dmesg
)标志。
在 Docker 容器中记录日志
在 Docker 容器中,处理日志的最常见方式是简单地假设容器的主进程就是我们需要输出的目标,而且它将日志写入标准输出(stdout
)。容器编排工具(比如 Kubernetes 和 Nomad)以及负责执行容器的各种云服务,都会假设 stdout
是日志输出的目标,并根据配置进行转发。我们将在下面的 集中式日志记录 部分进一步讨论这一点。
Syslog 基础
相比我们之前展示的 systemd
/journald
日志,syslog 看起来可能有些过时。我们更愿意认为它有着 悠久的历史——虽然自 1980 年代以来它就已经存在,但它依然是一个有用、灵活且广泛使用的日志工具。更重要的是,你几乎可以肯定会在实际生产系统中遇到它,因此了解其基础知识是值得的,以免在停机事件中被突如其来的情况搞得措手不及,而时间又至关重要。
在类 Unix 系统中,记录日志到 syslog 通常等同于记录到 /var/log
中的一个文件,其中大部分消息通常会写入 /var/log/messages
。但请记住,并非你在 /var/log
中找到的所有内容都一定经过了 syslog。许多软件也实现了自己记录日志文件的方式,完全跳过了 syslog 守护进程。
其原理是,syslog 会接收所有发送给它的日志,并根据以下提到的各种参数,将其输出到一个文件中。在几乎所有系统中,默认的输出路径是 /var/log/messages
。如果你跟着操作并且你的系统使用了 syslog,那么这里也会找到之前提到的 Hello World!
消息。
Syslog 是一种标准化的日志协议。尽管在撰写本文时,syslogd
主要处理日志行,但当前标准 [RFC 5424
] 也允许进行结构化日志记录。然而,由于这尚未得到广泛支持,我们将简要介绍它作为行/消息日志协议的基本概念。如果你完全不与 syslog 交互,可以跳过本节。
如前所述,syslog 是一种协议。虽然它最常用于软件,例如希望本地记录日志的数据库,但在生产环境中,通常会有一个集中式日志服务器,日志会发送到该服务器。作为一个协议,各种软件(如 PostgreSQL、nginx 等)可以使用该协议生成日志,此外,诸如 Logstash
、Loki
、syslogd
、syslog-ng
等日志相关软件可以接收其日志。它通常使用端口 514(UDP)或端口 6514(TCP)。
设施
由于 syslog 是一种非常古老的协议,起源于 1980 年代,所以它的一些概念可能显得有些过时。它使用预定义的 设施 来指定日志消息的类型。每个设施都有自己的代码:
-
0:
kern
– 内核消息。 -
1:
user
– 用户级消息。这些消息通常由进程使用。 -
2:
mail
– 邮件系统。主要用于邮件服务器,如 SMTP、IMAP 和 POP3。与垃圾邮件相关的守护进程和软件通常会在此记录日志。 -
3:
daemon
– 系统守护进程。守护进程,特别是与操作系统相关的守护进程(如 NTP),会在此记录日志。 -
4:
auth
– 安全/认证消息。你通常会在此找到本地的登录尝试,例如通过 SSH,也可以找到各种其他服务的相关日志。 -
5:
syslog
–syslogd
内部生成的消息。这些将是与 syslog 本身相关的消息。 -
6:
lpr
– 行打印机子系统。与打印机相关的日志。 -
7:
news
– 网络新闻子系统。这是历史遗留的,现今通常不再使用。 -
8:
uucp
– UUCP 子系统。这是历史遗留的,现今通常不再使用。 -
9:
cron
– Cron 子系统。与 cron 作业相关的日志。这些日志对调试 cron 作业非常有用。 -
10:
authpriv
– 安全/认证消息。这类似于auth
,但通常被认为是记录到更受限的目的地。大多数 Linux 软件会在此而非 auth 中记录日志。 -
11:
ftp
– FTP 守护进程。主要是历史遗留的日志,记录 FTP 服务器的相关信息。 -
12:
ntp
– NTP 子系统。网络时间协议 (NTP) 的日志,用于时钟同步。 -
13:
security
– 日志审计。与安全相关的事件。 -
14:
console
– 日志警报。与“本地控制台”相关的消息。 -
15:
solaris-cron
– 时钟守护进程。 -
16 到 23:
local0
到local7
– 本地使用的设施,意味着本地软件。例如,PostgreSQL 在默认情况下会将日志记录到local0
。
在许多系统中,你会在 /var/log/
目录下找到与这些设施类似命名的文件。例如,如果你需要调试一个不使用 journald 的系统中的 cron 任务,你可能会在 /var/log/cron
、/var/cron/log
、/var/log/messages
或类似文件中找到输出。
请记住,每个设施中具体记录的内容并没有标准化。你很可能会遇到不同操作系统或类似软件在选择记录到哪个设施时没有达成一致的情况。
严重性级别
这是你可能更熟悉的概念。消息会附带七个不同级别中的一个严重性:
-
0:
emerg
– 紧急 -
0:
alert
– 警报 -
0:
crit
– 严重 -
0:
err
– 错误 -
0:
warning
– 警告 -
0:
notice
– 通知 -
0:
info
– 信息 -
0:
debug
– 调试
和设施一样,具体什么构成每个严重性级别取决于使用的软件。
配置和实现
Syslog 有多种实现方式。这些通常允许你配置过滤、保存和转发基于设施和严重性级别的消息。有些系统提供名为 syslogd
、rsyslog
或 syslog-ng
的服务,可以在 /etc/syslog.conf
、/etc/syslog-ng/
中进行配置。Loki、Logstash 以及其他分布式日志管理工具也有各自的日志配置方式,通常采用三重结构:一个地方定义输入,另一个地方进行过滤和转换,第三个地方存储或转发输出。
日志记录技巧
每个人的日志记录方式略有不同,什么是最佳实践也会因项目和时间而异。然而,有一些事情是你应该了解的。
使用结构化日志时的关键词
在使用任何类型的结构化日志时,尽量确保共享常见的关键词,比如请求和用户 ID,同时避免使用相似但不完全相同的关键词。根据后端数据库的不同,你可能还会遇到类型相关的问题,比如在 user
可能是整数、字符串或嵌套结构(如 JSON 对象)的情况下。
有时通过创建每个服务的命名空间并保持“全局使用”键的列表及其定义,可以避免任何重叠。
严重性
在开发软件时,有一份内部文档来解释每个严重性对应的含义是很有意义的。这可以避免因为公开可访问的服务的登录失败尝试,或者爬虫请求过时网站时返回的 404 错误代码,触发警报并在半夜叫醒同事的情况。但即使不是这种情况,它也能让调试和识别问题变得更加容易。
因此,最好能清楚区分以下几点:
-
可能表明存在问题的情况
-
不应该发生的情况,但可能发生
-
清楚表明存在缺陷或更严重问题的情况
随着软件的增长和服务的增加,日志往往会变得更加复杂,因此,从一开始就明确记录什么以及何时记录日志是一个很好的投资。
集中式日志
在企业环境中,通常会将日志集中管理。这样,在调试问题时,连接各个线索会更加容易。这也意味着,在分布式应用程序中,不必单独查看每台物理或虚拟机、容器上的每个日志。这些集中式日志服务通常使查询大量日志变得简单和快速,特别是在公司使用结构化日志并且服务遵循统一日志结构时。
这些日志服务可以是它们自己的产品,例如 rsyslog、Loki、ELK 堆栈(Elastic Search、Logstash 和 Kibana)以及 Graylog,或者它们是托管服务。例如,它们可以是我们刚才提到的服务的托管变体,或者是云特定的日志解决方案,如 Google 的操作套件(前称 Stackdriver)、AWS CloudWatch 或 Azure Monitor。这些系统之间有许多相似之处,它们提供机制以“传送”日志(通过文件或某种 API),过滤和重构日志,最终将其保存到最终存储中,准备好进行查询。
在微服务架构中,通常会传递上下文信息,例如请求 ID,以便客户端的请求能够轻松地在各个服务之间追踪,这是调试涉及多个服务的架构时至关重要的。
如前所述,这些系统中的大多数都有机制将日志传送到中央日志服务器或集群。它们通常使用如 Logstash(ELK 堆栈中的 L)和 Promtail(与 Loki 配合使用)等名称,通常提供多种日志摄取方式。例如,它们可以配置为创建一个 HTTP 服务器,作为 syslog 服务器,接入 journald,从任何其他日志传输服务读取,使用云 API,或只是跟踪文件。它们通常作为系统上的附加守护进程、Kubernetes 中的 Pod 或 Nomad 设置的一部分运行。由于它们旨在允许无论使用什么软件都能集中日志,因此它们通常允许各种日志输入,并且在设置方面通常非常灵活——例如,允许通过在各个日志传输服务之间转发来创建层次结构。在像 Kubernetes 和 Nomad 这样的容器编排工具上,这通常通过“侧车容器”来实现,侧车容器与应用容器一起运行,捕获来自该 Pod/分配/节点中所有容器的日志,然后将其传送到目的地。
图 16.1:带侧车容器的容器编排
正如您所看到的,虽然周围有许多技术和产品涉及日志,但它们都至少属于上述某一类别,因此,当您在环境中集中管理日志时,这应该能帮助您了解各个部分如何彼此关联。
结论
日志记录在现代生产环境中可能是一个不断变化的目标。本章所涵盖的基础内容,学习和实验后应能为你提供一个良好的基础。我们希望,通过熟悉 syslog 和 journalctl,你能够掌握低级的理解和历史视角,这将使你更容易理解明天的日志即服务解决方案是如何在幕后工作的。
我们认为,你会发现本章所学的技能,在设计、调试和优化你创建和部署的应用时,能给你带来实际且可衡量的优势。正如你刚刚看到的,掌握 journald 的基础知识可以让你迅速诊断和定位问题,无论问题是与应用本身相关,还是与周围更大的 Linux 系统有关。了解一些替代的和历史上的 Linux 日志记录方法,也有助于你在排除故障时处理那些很久没有更新的系统(或人)。
这不仅仅是解决问题;它还关乎让你的生活更轻松,工作更高效。此外,它是一项让你脱颖而出的技能。简而言之,了解 Linux 日志的运作方式会让你成为一个更聪明、更高效的开发者。
在 Discord 上了解更多
加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
第十七章:负载均衡与 HTTP
本章我们将采取一种稍微不同的方式,因此请系好安全带。一方面,我们将回顾一些关于超文本传输协议(HTTP)的背景知识,并重点讨论一些常见的误解,这些误解常常让许多开发人员在实际工作中踩坑。
另一方面,我们将保持实用,讲解一个在命令行上非常强大的标准 HTTP 工具——curl
。具体来说,我们将教授你在使用 curl
排查常见的 web 应用问题时的基础知识。
我们假设你作为 web 开发者,已经对 HTTP 有一定了解。所以,虽然本章的目标不是教授你这一协议的基础知识,但我们会回顾一些基本内容,帮助你尽快跟上进度,特别是如果你有一段时间没接触过它。如果你完全不懂 HTTP,网上有很多优秀的文档可以帮助你。至于推荐,我们强烈建议你参考 Mozilla 提供的 MDN 文档。
其实,我们更希望聚焦在 HTTP 的常见误解和在实际应用中的陷阱,这些问题常常会让人吃惊。这些误解通常源于这样一个事实:作为开发者,你在非常简单的本地环境中编写 web 应用,但在复杂的生产环境中运行它们,这些环境与用于构建和测试的笔记本电脑大不相同。
开发环境中应用与本地机器的交互方式与应用在部署到暂存或生产环境后的基础设施之间的差异,是许多混淆和细微错误的源头。
在本章中,我们将讨论这些差异中最重要的内容:你将了解网关、上游服务器以及与现代网站或 web 应用的基础设施层交叉的其他概念。然后我们将讲解一些关于 HTTP 的常见错误,这些错误通常会导致调试困难的头部、状态码等问题。我们还会介绍一些现代安全功能,例如跨域资源共享(CORS),以及 HTTP 的历史和你可能会遇到的版本。最后,你将学习负载均衡的基本概念:了解这些基础知识能帮助你避免对客户端请求路径的错误理解,这是应用程序/基础设施边界处常见的设计问题源。
你将学习:
-
理解本章后续讨论的更复杂的 web 基础设施所需的一些基本术语。
-
关于 HTTP 状态码的常见误解,充分理解这些误解有助于你编写更简洁、更准确的状态处理代码。
-
HTTP 头部,以及你在自己的网站应用中可能遇到的一些相关问题。
-
你在实际使用中可能遇到的不同 HTTP 版本。
-
了解负载均衡是如何工作的,以及作为开发者为什么即使你从不打算接触应用程序基础设施,也需要理解它。
-
如何使用名为
curl
的工具,通过命令行排查与所有这些主题相关的 Web 问题。
本章唯一的前提知识是对 HTTP 请求工作原理的基本了解,以及对 Web 应用程序开发工具的基本认识(例如,你应该知道如何使用浏览器的控制台和其他开发工具来调试基本的 HTTP 问题)。
让我们从一些基本术语开始,这些术语在我们进行故障排除时会派上用场。
基本术语
后续章节将使用一些你可能不熟悉的术语,所以我们在这里快速覆盖一下这些术语。
网关
在当今的世界中,网关通常是一个 HTTP 反向代理、负载均衡器,或者两者的组合。这可以是一个 HTTP 服务器,如 nginx 或 Apache,一个传统意义上的物理负载均衡器,或是这种思路的云变体。它也可以是一个内容分发网络(CDN)。所以,当你收到一个 HTTP 状态码,提到与网关相关的错误时,就是这些网关设备或应用程序在与你通信。
上游
上游是指应用程序代理的服务。在大多数情况下,这将是实际的应用程序或服务,例如,你编写的 HTTP 服务。需要记住的是,代理可以级联或层叠,因此,可能在第一个代理和实际 Web 应用程序之间还有一个中间代理。例如,在许多云基础设施中,有一个入口负载均衡器负责处理和过滤传入流量,后面则是一个应用负载均衡器,实际上检查 HTTP 流量并将其路由到正确的应用服务器池。
现在,我们已经介绍了一些超越 HTTP 的术语,你已经为本章后续的内容做好了准备。接下来,让我们更详细地了解一些 HTTP 中常见的误解部分,并开始使用 curl
工具来练习常见的 CLI 排查命令。
关于 HTTP 的常见误解
在开发 Web 应用程序和 HTTP API 时,了解一些很多开发者容易忽略的细节是很有价值的。让我们来看一下几个关键领域,掌握这些额外的知识对你所创建的应用程序的可靠性非常有帮助。本章中我们介绍的 curl
技能,也将使你能够从命令行开始排查像“网站无法访问”这样模糊的问题。
HTTP 状态码
在接下来的章节中,我们将介绍一些你可能会遇到的常见 HTTP 状态码。我们还将讨论一些关于这些状态码的重要信息和误解,你需要牢记这些内容。
不要只检查 200 OK
检查错误的常见方法是只检查 200 或整个 2xx 范围的状态码,以确定请求是否成功。然而,进行此操作时需要注意一些陷阱。
200 范围(2xx,即 200 到 299 之间的每个状态码)通常表示成功,许多 API 返回 204 No Content 来表示操作成功,尤其是当 API 通常返回已创建或已修改的资源时,但在某些场景下,例如 DELETE 请求,或者当返回资源会浪费资源时,则不会返回。
检查响应状态是否在 2xx 范围内,对于某些应用来说可能足够,但重要的是要理解像“如果不是 200,记录错误”这样的应用逻辑是错误的。1xx 范围和 3xx 范围都不表示错误,即使它们不是 200。
1xx 范围的状态码在没有预期的情况下出现是相对罕见的,因为涉及 100 的最常见情况是切换到 WebSocket,但这并不意味着它们是错误。3xx 状态码经常被返回,用于通知客户端重定向,虽然它可能表示需要某些操作——例如更新已移动内容的路径——但它本身绝对不是失败。
3xx 范围中一个在生产环境中比开发环境中更常见的状态码是 304 Not Modified。在开发时,这个状态码很容易被忽视,也可能由于基础设施变化或库更新,改进或引入了新的缓存行为。这个状态码在客户端(如浏览器或 HTTP 库)发送带有 If-Not-Modified 头的请求时使用,特别是为了利用缓存。
因此,通常只将以 400 开头的状态码视为任何潜在错误,而不是仅仅将 2xx 状态码视为成功。这仍然有助于应用程序内部保持简洁的逻辑:检查状态码是否大于或等于 400,与检查其是否在 2xx 范围内一样简洁。
404 Not Found
需要记住的一点是,Not Found 状态码根据应用程序的不同可能意味着不同的事情。404 可以由(文件)服务器和网关返回,也可以由应用程序返回。它可以表示某个路由不存在,也可以表示某个特定资源(例如,帖子或评论)因某种原因(例如被删除)而不存在。
这就是为什么 404 常常是健康应用正常响应集的一部分,且该应用按设计工作。在某些情况下,客户端甚至可能依赖这种行为,例如在创建某个资源之前或在告诉用户某个资源或资源名称是否已被占用时,验证某个内容是否不存在。
换句话说,单独的 404 状态码(没有关于应用程序和请求的更多上下文)不足以表明一个问题。正如你刚刚看到的,它可能在多个层面和层次上表示成功。有时可以通过在应用层避免使用它,并以不同的方式表示“未找到”情况,例如仍然返回 200 状态码,来避免这种情况。正确的做法取决于应用程序、团队或标准的约定,以及你应用程序使用的架构风格。
502 错误网关
这个状态码意味着网关没有理解上游返回的内容;换句话说,网关转发请求的响应不是一个完整且有效的 HTTP 响应。这通常表示上游服务有问题。
503 服务不可用
503 通常意味着上游服务在网关配置的端口上无法访问。实际上,这意味着 Web 应用程序可能崩溃了,或者它没有监听 HTTP 请求,或者它监听的是错误的端口,或者它被防火墙规则或破损的路由规则阻止,或者有其他无数原因。
504 网关超时
当网关与上游建立连接时,这个连接会在某个时刻超时。这一点很重要,因为挂起的进程会消耗双方的资源;无论是网关还是服务。通常,只有在这种情况出乎意料且没有字节被写入或读取时,才会发生这种超时。
如果上游服务有一个需要长时间运行的请求(例如等待计算等),一种选择是增加请求的超时时间。然而,通常建议采取不同的方法。例如,可以将该端点改为异步,或者通过提前开始写入字节(例如通过数据流式传输)来帮助解决问题。
这样做的原因是,在超时之前,资源会被消耗,而请求方并不知道应用程序是否会返回响应。因此,如果 Web 服务出现故障,网关和客户端都不会知道。这可能还会导致网关认为故障的服务仍在运行,而不是快速检测问题并切换到服务的其他实例。
curl 简介:检查 HTTP 响应状态
如果你只学习一个命令行工具来帮助你排查 HTTP 问题,curl
是一个不错的选择。当我们继续深入讨论 HTTP 的各个方面时,我们会添加类似这样的章节,展示你可以在排查与 HTTP 相关的常见问题时使用的实用curl
命令。
最简单的curl
调用如下所示:
curl https://tutorialinux.com/
这就像将 URL 粘贴到浏览器中——只是它跳过了浏览器,直接返回 web 服务器的响应到命令行。虽然这并不是浏览网页最激动人心的方式,但它更适合你下一个故障排除脚本的需求:检查 HTTP 响应的状态!
Curl 可以轻松地用来检查一个 web 服务器是否正常运行并响应请求:
curl -IsS https://tutorialinux.com/ | head -n 1
HTTP/2 200
根据这个输出,我们知道 web 服务器已经启动,并且 /
路由正在响应 200 OK 状态。你还可以看到这里的 HTTP 版本(HTTP/2),我们稍后会讨论。具体来说,这个命令发出了一个 HEAD 请求(-I
或 --head
),静默了 curl 的进度和错误信息(-s
或 --silent
)。
然而,通过 -S
(或 --show-error
)选项,你仍然会看到错误信息,当出现问题时:
curl -IsS https://tutoriajsdkfksjdhfkjshdflinux.com/ | head -n 1
curl: (6) Could not resolve host: tutoriajsdkfksjdhfkjshdflinux.com
最后,我们将截取大部分头部,只查看状态码,这是第一行(| head -n 1
)。
然而,在故障排除时,你通常需要查看头部信息。让我们看几个与头部相关的 HTTP 陷阱,然后尝试使用 curl
检查头部。
HTTP 头部
头部不区分大小写
HTTP 中的头部是不区分大小写的。某些软件依赖于这一点,结果某些网关可能会修改并“规范化”这些头部。幸运的是,开发人员在编写 web 应用程序时很少直接与原始头部值交互。相反,他们使用 web 库,这些库会将大部分复杂性抽象化,并处理这些细节。然而,你仍然应该确保这一点,并规范化它们,例如通过将你 web 应用程序设置的头部转换为小写。这样也可以防止响应头部因字母大小写不同而被意外添加多次。
自定义头部
当你为应用程序创建自定义 HTTP 头部时,要注意你决定使用的前缀。过去,通常会使用 X-
作为自定义头部的前缀,例如 X-My-Header
。这种做法现在已被认为是不好的(参见 RFC 6648,已经弃用该做法)。相反,更合适的是创建一个自定义前缀,例如项目、产品或公司名称,或者它的缩写。这可以避免其他开发人员误将这个头部当作 HTTP 标准的一部分而重复使用。
使用 curl 查看 HTTP 头部
我们在前面的 curl
示例中介绍的 -I
选项对于查看响应头非常有用,这可以帮助发现缓存问题、内容类型不匹配以及其他问题。让我们看看 tutorialinux 服务器的头部信息:
curl -I https://tutorialinux.com/
HTTP/2 200
server: nginx/1.24.0
date: Sat, 21 Oct 2023 16:37:12 GMT
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
x-powered-by: PHP/8.2.11
link: <https://tutorialinux.com/wp-json/>; rel="https://api.w.org/"
strict-transport-security: max-age=31536000; includeSubdomains; preload
目前服务器没有问题,但这些头部信息已经给了我一些如何改进 nginx 配置的想法:从安全角度来看,泄露软件名称和版本号通常是不好的做法,而没有人需要知道从这个服务器接收 HTML 或 JSON 的用户,后台正在使用 PHP。
HTTP 版本
为了说明一些你将看到的新 HTTP 特性,我们将简要回顾一下这个协议的历史。HTTP 已经存在一段时间,并且发生了很多变化,特别是在近年来,随着 Web 应用的流行,变化尤为显著。自 HTTP 起源以来,主要的概念和原语基本保持不变,然而,一些技巧、优化和行为已经发生了变化。了解协议版本有助于调试或预防问题,并减少不必要或适得其反的优化和变通方法。
HTTP/0.9
你不太可能再遇到这个版本的 HTTP 它是一个极简的 HTTP 版本。HTTP/0.9 允许向服务器发送一个 GET
请求并接收我们现在称之为 HTTP 请求 主体 的内容。没有发送或返回头部,甚至没有版本头或状态码。
HTTP/1.0 和 HTTP/1.1
HTTP/1.0,特别是 HTTP/1.1,离人们今天对 HTTP 的理解更近了。虽然 HTTP/1.0 添加了版本号和头部,但 HTTP/1.1 为方法和大量扩展(通常以头部的形式)铺平了道路。
HTTP/1.1 还增加了(并默认启用)管道化。这意味着多个请求可以通过同一个 TCP 连接发送。另一个广泛使用的新增功能是 Host
头,它允许同一服务器或 IP 使用多个主机名。
例如,Web 服务器现在可以配置为响应 http://example.org/
、http://www.example.org/
、http://forum.example.org/
和 http://blog.example.org/
的请求,而无需为每一个请求使用单独的 IP 地址。
HTTP/1.1 还启用了许多扩展:缓存、压缩、各种身份验证方案、内容协商,甚至是像 WebSockets 这样的功能。所有这些在今天的 Web 中被广泛使用。
HTTP/2
有很多文章赞扬 HTTP/2 的优点。它是 HTTP 向新方向迈出的巨大且具有争议的一步。虽然 HTTP/1.1 是一个基于文本的协议,允许任何人在终端或文本编辑器中创建完整有效的请求,但 HTTP/2 是一个二进制协议,它还处理流,这是创建轻量级 TCP 连接变体的一种机制。
二进制格式和头部压缩意味着现在需要专用工具来与 HTTP 服务器(或客户端)进行通信。然而,整体概念与早期版本的 HTTP 保持一致,因此作为一名 Web 开发者,你只会在特定情况下注意到差异。
虽然 HTTP/2 也增加了许多全新的功能,但其中许多在面向用户的 Web 应用中很少使用,甚至可能没有被浏览器实现。
虽然这在技术上并不是官方标准的一部分,但在浏览器中,HTTP/2 通常仅限于 HTTPS。
在大多数情况下,Web 应用将从增强功能中受益,例如使用单个 TCP 连接并进行多个流传输,尤其是在许多请求(如静态文件和 AJAX 请求)并行发出时。这可以使某些优化方法(如精灵图或将多个文件合并为一个文件)变得不再必要。当这些优化导致冗余数据传输时,甚至可能适得其反。
一些为 HTTP/1.1 设计的应用在切换到 HTTP/2 时可能需要进行修改,因为像保持连接活动这样的操作可能会产生不可预测的副作用。基于这一点和其他原因,建议在将应用程序转换为 HTTP/2 前进行测试。有时,甚至会发现将应用程序切换到 HTTP/2 会增加页面加载时间或增加资源使用。
这意味着进行实际测试和监控,以比较 HTTP 协议之间的差异是一个好主意。由于 HTTP/2 的许多优点针对的是 Web 浏览器的实际使用,简单的命令行负载测试可能无法与真实用户访问 Web 应用时的结果相同。例如,一个常见的错误是不考虑 HTTP/1.1 的流水线功能。
然而,对于大多数现实生活中的网站来说,HTTP/2 将带来好处。对于微服务中的内部 HTTP API,许多公司选择继续使用 HTTP/1.1 或 gRPC。
HTTP/3 和 QUIC
HTTP/3 基于 HTTP/2 的发展,并将其概念迁移到一种基于 UDP 的传输协议 QUIC(而不是其他 HTTP 版本使用的 TCP)。
像之前版本的 HTTP 一样,HTTP/3 使用流作为建立新 TCP 连接的轻量级替代方案。与 HTTP/2 不同,HTTP/3 并不是通过在现有的 TCP 连接内启动流来实现,而是通过使用 QUIC,它是一个专门为支持此类流而设计的协议。
QUIC 在常见的 HTTP 使用场景中相比于 TCP 有一些优势。例如,由于 QUIC 基于 UDP——UDP 是用户数据报协议,一种比 TCP 更简化但更快速的替代方案——它可以防止因单个数据包尚未到达(即便该数据包是传输给不同流的)而导致整个连接停滞的情况。QUIC 还针对快速建立初始连接进行了优化,包括启动 TLS 以确保客户端与服务器之间的连接安全。QUIC 本身是为了支持未来版本的扩展性而创建的,在标准化后不久,许多此类扩展已经开始标准化。
由于 HTTP/3 基于 UDP 并旨在避免 协议僵化,许多传统的中间节点和网关形式变得过时。
注意
协议僵化指的是当中间节点(或任何与协议交互的部分)要求协议保持某种形式时,导致协议的持续开发和更改变得困难(例如,添加扩展)。
让我们看看这些基本的 HTTP 概念如何融入大多数 Web 应用程序所运行的更大基础设施中。在这样的架构中,通常不仅仅是一个单独的 Web 服务器和客户端(像你的 Web 浏览器或curl
):通常会有几个 HTTP 通信层次,简单的问题可能会累积并变得难以排查。
如常,我们将为你提供你需要理解的最重要概念,随后是一些使用curl
命令行工具排查更复杂 Web 基础设施问题的实用技巧。
负载均衡
负载均衡是一种将目标服务的负载分散到该服务的多个实例上的方法。虽然这并不仅限于 HTTP 和 Web 服务,但 HTTP 是目前负载均衡最常见的应用场景之一。
了解 Web 应用负载均衡的工作原理非常重要,因为它会影响在生产环境中如何出现错误和问题。例如,在本地开发环境中,你通常只处理单一客户端(你的浏览器或其他 API 消费者)和单一服务器(你正在开发的 Web 应用或服务)。但在真实世界中,客户端和应用之间通常会有多个服务器层,每一层都负责转发 HTTP 流量,并可能在流量中引入自己的问题或错误。
本节材料将帮助你高层次地理解那些虽然不属于你编写的应用代码,但却成为整个应用一部分的活动组件。
HTTP 负载均衡通常是通过在应用前面放置一层基础设施来代理 HTTP 请求;通常有以下几种方式:
-
网关服务,例如支持的 HTTP 服务器(如 nginx 或 Apache)
-
专用服务(如 HAProxy 或 relayd)
-
云服务(如 GCP 的负载均衡器、AWS 的 ELB 或 ALB 等)
-
硬件负载均衡器
有时,工程师选择使用自定义服务或基于 DNS 的解决方案,尤其是在区域性负载均衡的场景中,这通常作为前置层添加在上述提到的其他方法之前。容器编排工具和专用的服务发现机制通常也提供另一种负载均衡的方式。
引入负载均衡器会涉及到理解一些其他概念——尤其是如何将来自客户端的请求映射到正在运行你精心设计的 Web 应用实例的服务器上。
会话和 cookie 管理变得复杂,因为长期运行的会话不再保证每次都会命中相同的服务器。应用池中的一台服务器出现故障会成为问题 —— 用户体验会中断吗?他们会丢失数据吗?作为工程师的你在排查自己 web 应用程序的问题时,是否无法重现问题,因为它只发生在数十台或数百台服务器中的一台上?
了解现代负载均衡是如何工作的,对于避免应用设计缺陷或类似这种排查困难的麻烦至关重要,接下来的几节将为你提供一个基本的思维模型,帮助你避免这些问题。
粘性会话、cookies 和确定性哈希
在为 HTTP 服务设置负载均衡时,第一个需要问的问题是该服务是否需要粘性会话。粘性会话是一种将客户端与特定应用服务器绑定在一起的机制,通常对于那些将会话状态保存在应用服务器上的应用程序是必须的。
这也是为什么设计“无状态”应用程序的最佳实践之一,因为这些应用程序将状态写入共享数据层 —— 在这些应用程序中,客户端的第一次请求是否由不同的服务器处理与第二次请求无关。幸运的是,在今天的世界中,尤其是在依赖基于云的基础设施时,通常不需要粘性会话。然而,这一点值得记住,特别是在排查只在负载均衡的生产环境中神秘出现的问题时。
尽管有许多方法可以在 HTTP 中创建粘性会话,但最常见的方法是通过 cookies。这可以通过负载均衡器可以识别的应用程序 cookies(如会话 cookies)来实现,也可以通过负载均衡器自己管理的专用 cookies 来实现。
然而,通过在负载均衡器上存储额外的状态来实现粘性会话本身也存在一些问题。如果负载均衡器必须保持 IP 地址到应用服务器的内部映射,那么如果该负载均衡器崩溃并被替换,导致状态丢失,会发生什么?你会发现,我们只是将状态问题从应用服务器转移到了负载均衡器上,并希望那里不会发生任何不好的事情。然而,正如俗话所说,希望并不是一种可行的策略。
实现粘性会话的一个巧妙方法是通过使用 IP 哈希进行负载均衡,而不需要处理在负载均衡器上存储状态的问题。为了实现这一点,会创建客户端 IP 的哈希值,并用于将请求的 IP 映射到服务实例上。只要客户端的 IP 不变,会话就会对该特定的应用实例保持“粘性”。
现在,一个或多个负载均衡器将确定性地将 IP 地址与应用服务器匹配,无需通信或共享状态。服务器可以随意进出,每个新服务器都会做出与其他服务器相同的匹配决定,因为它们都使用相同的哈希算法,并且始终将给定的 IP 地址匹配到相同的应用服务器。
轮询负载均衡
如果不需要粘性会话,最常见的负载均衡机制是轮询。这意味着每个新的连接或请求都会被路由到下一个实例。从数学角度来看,这意味着实例是通过request_count % instance_count
(%表示取余)来选择的。
其他机制
你现在对 HTTP 负载均衡在现实世界中的工作原理有了一个高层次的概览。当然,还有许多其他的机制可以选择,例如基于资源使用情况的负载分配,但你应该小心真正理解增加复杂性所带来的影响——许多“聪明”的负载均衡算法并非没有重大陷阱。
例如,基于资源利用率的负载均衡器在处理短暂的负载峰值时可能会遇到问题,这可能导致一个实例的利用率不足而另一个实例的利用率过高,因为服务实际处理的工作负载具有波动性,而负载测量的时间不合适,未能平衡这些峰值。
为了平衡这样的负载峰值,可能会增加额外的复杂性,这也可能带来其他问题,比如这些峰值会堆叠在一起。如果你发现自己偏离了更为成熟的负载均衡机制,确保你的团队已经充分考虑了架构和实际应用及其使用场景的技术细节。
高可用性
虽然负载均衡的主要目标可能是确保快速响应,但负载均衡器通常会跟踪哪些服务是可达的。它可能会使用健康检查来验证它发送请求的服务器是否完全正常。这意味着负载均衡也是实现高可用性的一种方式,通常也是零停机架构的一个组成部分,在这种架构中,服务可以被替换(例如,当部署新版本时),而客户端不会察觉。
这可以通过允许实例以某种形式优雅地关闭来实现,其中与客户端的连接不会被简单地断开,而是保持活跃直到完全处理完毕,而新的连接只会被路由到已更新的实例。当最后一个会话结束时,过时的实例可以完全关闭。
健康检查使负载均衡器能够判断服务是否完全正常运行。当然,最基本的检查是是否能够建立连接。然而,在微服务架构中,无法访问外部依赖(比如另一个服务)可能会阻止服务正确响应请求。这也可以通过一个专门的状态端点来指示。
许多应用程序和基础设施团队会达成一致,使用像 /healthcheck
这样的路由,其状态码指示请求是否应路由到该服务。在一些更复杂的环境中,这样的路由甚至可能指示可以路由到实例的请求类型。
当熟练的应用开发人员和平台/SRE 团队聚集在一起时,健康检查路由甚至可以构建为在基础设施需要采取行动时发出信号,比如实例出现严重故障需要替换。如果这些路由设计得当,通常也会提供关于问题的额外上下文和信息,以便简化生产环境中的调试工作。
随着支撑 web 应用的基础设施变得越来越大和复杂,可能出现的问题也呈指数级增加,并且高度依赖于具体的架构和应用程序。一类问题是在 web 基础设施拥有更多代理和路由层时更容易出现的,即重定向循环和一般的重定向错误。
幸运的是,像 curl
这样的命令行工具正好可以用来排查这个问题。
使用 curl 排查重定向问题
正如我们刚刚提到的,重定向可以是 web 应用及其周边基础设施中的常见故障、问题,甚至是一些意外行为的表现。使用 curl 的 -L
(或 --location
)选项来跟踪它们:
curl -IL http://www.tutorialinux.com/
HTTP/1.1 301 Moved Permanently
Server: nginx/1.24.0
Date: Sun, 22 Oct 2023 22:58:02 GMT
Content-Type: text/html
Content-Length: 169
Connection: keep-alive
Location: https://tutorialinux.com/
HTTP/2 200
server: nginx/1.24.0
date: Sun, 22 Oct 2023 22:58:02 GMT
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
x-powered-by: PHP/8.2.11
link: <https://tutorialinux.com/wp-json/>; rel="https://api.w.org/"
strict-transport-security: max-age=31536000; includeSubdomains; preload
你会看到服务器返回 301
(永久移动)和正确的位置,tutorialinux.com/
。curl
跟踪该重定向并向新位置发起请求,在那里它得到了一个 200(OK)状态。
这个重定向按预期工作,但你可以使用这个 curl
命令来做一些事情,例如识别应用程序中的重定向循环,或者排查多层负载均衡设置中的缓存和路由问题。
然而,有时你需要深入并向 web 应用发送数据来进行故障排除。curl
也可以帮助解决这个问题!
使用 curl 作为 API 测试工具
拥有一个快速的 curl
命令用于 API 测试,比你想象的更有用。特别是在处理接受 POST 数据的 JSON API 时,通常会希望向某个端点发送一些测试数据,以确保后端返回了你期望的内容:
curl --header "Content-Type: application/json" \
--request POST \
--data '{"some":"JSON","goes":"here"}' \
http://localhost:4000/api/v1/endpoint
该命令使用了一些你需要记住的标志。首先,--header
(-H
) 参数允许你指定一个头部字符串(你可以通过重复此参数来提供多个头部)。接下来,--request
(或 -X
)标志允许你指定 HTTP 请求类型(默认情况下,curl
执行 GET 请求,但使用此标志可以改变请求类型)。当你进行 POST 或 PATCH 数据请求时,如本例所示,你需要使用 --data
(或 -d
)参数,这样可以发送数据。
在使用 --data
时,记住 bash 转义字符在这里发挥作用,因此对于复杂的数据,你可能会发现使用 --data
选项会更方便,如下所示:
curl -X POST --data "@my/data/file" https://localhost/api/v1/endpoint
记得在文件路径前添加 @
字符。如果你发送的是复杂数据,也可以查阅 --data-raw
、--data-binary
和 --data-urlencode
。根据你的 web 应用程序的要求,你可能还需要发送额外的头部。
你现在已经了解了如何通过 curl
向你正在故障排除的 web 应用程序发送自定义数据,使其更具互动性。但我们还想展示最后一个 curl
技巧:TLS(传输层安全性,HTTPS 中加密现代 Web 流量的方式)不一定是 web 应用程序中一个“被误解”的方面,但它是一个常见的故障点,curl
可以帮助解决。
使用 curl
接受并显示无效的 TLS 证书
curl
为我们提供了 --insecure
选项,允许它接受来自服务器的无效 TLS 证书并继续请求。在故障排除配置错误的服务器时,这非常有用:
curl --insecure -v https://www.tutorialinux.org/
--insecure
(-k
) 选项会让 curl
假装 TLS 证书是有效的,即使它实际上无效。显然,这是一个安全隐患,仅应在故障排除时使用,但它可以让 curl
在 TLS 证书验证失败并且 curl
通常会中止请求的情况下继续执行。
让我们快速了解一下最后一部分关于 HTTP 的内容,如果你从事构建或故障排除 web 应用程序的工作,这部分内容值得学习:CORS。
CORS
CORS 代表跨源资源共享。这个术语是用来表示资源,例如图片、视频、HTML、JavaScript,甚至是异步 JavaScript 和 XML(AJAX)响应将来自不同的主机名。为了防止从第三方加载资源的情况,浏览器首先会询问该第三方是否允许这么做。这被称为预检请求。
预检请求是一个 OPTIONS
请求,期望得到一个包含 HTTP 头部的响应,告知请求是否被允许。这样的响应通常会有一个 204(无内容)状态码,并且只包含头部。如果没有找到这样的头部,或者头部没有指示该请求被允许,则不会触发对资源的后续请求。
下面是这样的交换可能是什么样的例子。
浏览器打开www.example.org/
时,会询问是否允许向api.example.org
的/api/test
进行 POST 请求:
OPTIONS /api/test
Origin: https://www.example.org
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-Custom-Header, Content-Type
一个接受的响应应该是这样的:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.org
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-Custom-Header, Content-Type
Access-Control-Allow-Max-Age: 3600
由于这表示请求被允许,浏览器随后可以发送原始请求,这时请求已获得授权:
POST /api/test
Origin: https://www.example.org
Content-Type: application/json
X-Custom-Header: foobarbaz
对于不被允许的请求,并没有通过错误状态返回信号来表示拒绝——只是缺少预期的Access-Control-Allow-Origin
头信息。在这种情况下,客户端会看到请求未被授权并记录错误。
你可以在浏览器的开发者控制台中看到类似的错误。它们会像这样显示:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://not-allowed. (Reason: something).
这只是对 CORS 的简要介绍,因为它是 Web 开发者必须理解的重要主题。尽管它与命令行无关,但开发者理解这些概念并检查 Web 客户端中这些错误日志是很常见的。想要深入了解该主题,我们推荐 MDN 上的相关文章,链接是developer.mozilla.org/en-US/docs/Web/HTTP/CORS
。
结论
在本章中,你学习了避免一些常见误解、bug 和令人沮丧的设计缺陷所需的知识,这些问题通常出现在 Web 应用程序从开发者的笔记本电脑中走出来,通过复杂的基础设施开始与现实世界互动时。你了解了调解访问你应用程序的一些基础设施,如网关和上游服务。
你还看到了一些我们常见的开发者在使用 HTTP 时犯的错误,你将能利用这些知识避免一些难以调试的头信息问题、不正确或模糊的状态码等问题。你了解了跨域资源共享(CORS)以及 HTTP 是如何发展成现在的形式。
或许最重要的是,你看到如何通过学习像curl
这样的命令行工具并将其与 HTTP 的理论知识结合起来,从而提升你作为开发者的能力。
本章中你学到的内容使你能够快速且准确地排查 Web 应用程序问题,无论是识别损坏的 WordPress 网站中的重定向循环,通过检查 Ruby-on-Rails 应用程序返回的头信息定位微妙的缓存问题,还是在凌晨四点通过向开发服务器 POST 特定的 JSON 数据来重现生产环境中的 bug(并验证修复)。
在 Discord 上了解更多
要加入本书的 Discord 社区——在这里你可以分享反馈、向作者提问并了解新版本——请扫描下面的二维码:
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助你规划个人发展并推动职业生涯。欲了解更多信息,请访问我们的网站。
第十八章:为什么要订阅?
-
花更少的时间学习,花更多的时间编程,通过来自超过 4,000 名行业专业人士的实用电子书和视频
-
利用为你量身定制的技能计划提升你的学习效果
-
每月获得一本免费的电子书或视频
-
完全可搜索,便于快速访问重要信息
-
复制粘贴、打印和书签内容
在 www.packt.com 上,你还可以阅读一系列免费的技术文章,订阅各种免费新闻通讯,并获得 Packt 书籍和电子书的独家折扣和优惠。
你可能会喜欢的其他书籍
如果你喜欢这本书,你可能会对 Packt 出版的其他书籍感兴趣:
掌握 Linux 安全与加固 - 第三版
Donald A. Tevault
ISBN: 978-1-83763-051-6
-
防止恶意行为者入侵生产环境中的 Linux 系统
-
在新版中利用 Linux 的更多功能和能力
-
使用锁定的主目录和强密码创建用户账户
-
防止未经授权的人破坏 Linux 系统
-
配置文件和目录权限以保护敏感数据
-
加固 Secure Shell 服务,防止入侵和数据丢失
-
应用安全模板并设置审计
掌握 Ubuntu 服务器 – 第四版
Jay LaCroix
ISBN: 978-1-80323-424-3
-
在物理服务器和树莓派上安装 Ubuntu 服务器
-
在云端部署 Ubuntu 服务器并托管自己的网站
-
将你的应用程序部署到自己的容器中,并扩展你的基础设施
-
设置流行应用程序,如 Nextcloud
-
使用 Ansible 自动化部署和配置,节省时间
-
通过 LXD 容器化应用程序,以最大化效率
-
掌握最佳实践和故障排除技巧
Packt 正在寻找像你一样的作者
如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并立即申请。我们已与成千上万的开发者和技术专业人士合作,帮助他们与全球技术社区分享见解。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
分享你的想法
现在您已经完成了《Linux 软件开发者指南》,我们很想听听您的想法!如果您是从亚马逊购买的这本书,请点击这里直接进入亚马逊的评论页面,分享您的反馈或在您购买该书的网站上留下评论。
您的评论对我们和技术社区都非常重要,它将帮助我们确保提供优质的内容。