命令列環境
當我們使用 shell 時,可以使用一些方法改善我們的作業流程,本節課我們就來討論這些方法。 我們已經使用 shell 一段時間了,但是到目前為止我們的關注點主要集中在使用不同的指令上面。現在,我們將會學習如何同時執行多個不同的行程(processes)並追蹤它們的狀態、如何停止或暫停某個行程,以及如何使其在背景執行。
我們還將學習一些方法能夠改善我們的 shell 及其他工具的作業流程,像是透過定義別名或修改配置文件。這些方法都可以幫我們節省大量的時間,僅需要執行一些簡單的指令,我們就可以在所有的主機上使用相同的配置。我們還會學習如何使用 SSH 操作遠端電腦。
任務控制
某些情況下我們需要中斷正在執行的任務,比如當一個指令需要執行很長時間才能完成時(假設我們在一個非常大的目錄結構中使用 find
進行搜索)。大多數情況下,我們可以使用 Ctrl-C
來停止指令的執行。但是它的工作原理是什麼呢?為什麼有的時候會無法結束行程?
結束行程
我們的 shell 會使用 UNIX 提供的信號(signal)機制達到行程間的相互溝通。當一個行程接收到信號時,它會停止執行、處理該信號並根據信號傳遞的訊息來改變其執行流程。就這一點而言,信號是一種軟體中斷(software interrupts)。
在上面的例子中,當我們輸入 Ctrl-C
時,shell 會發送一個SIGINT 信號到行程。
下面這個 Python 程式示範如何捕獲信號 SIGINT
並忽略它的基本操作, Ctrl-C
並不會讓程式停止。為了停止這個程式,我們需要使用 SIGQUIT
信號,通過輸入 Ctrl-\
可以發送該信號。
#!/usr/bin/env python
import signal, time
def handler(signum, time):
print("\nI got a SIGINT, but I am not stopping")
signal.signal(signal.SIGINT, handler)
i = 0
while True:
time.sleep(.1)
print("\r{}".format(i), end="")
i += 1
如果我們向這個程式發送兩次 SIGINT
,然後再發送一次 SIGQUIT
,程式會有什麼反應?注意 ^
是我們在終端輸入 Ctrl
時的表示形式:
$ python sigint.py
24^C
I got a SIGINT, but I am not stopping
26^C
I got a SIGINT, but I am not stopping
30^\[1] 39913 quit python sigint.py
SIGINT
和 SIGQUIT
都常常用來發出和終止程式相關的請求,但有個更加通用、優雅地退出信號的方法是 SIGTERM
。為了發出這個信號我們需要使用 kill
指令, 它的語法是: kill -TERM <PID>
。
行程暫停和背景執行
信號可以讓行程做其他的事情,而不僅僅是終止它們。例如,SIGSTOP
會讓行程暫停。在終端中,輸入 Ctrl-Z
會讓 shell 發送 SIGTSTP
(Terminal Stop)信號。
我們可以使用 fg
或 bg
指令恢復暫停的工作。它們分別表示在前台或背景繼續執行。
jobs
指令會列出當前終端作業中尚未完成的任務。我們可以用 pid 指定這些任務(也可以用 pgrep
找出 pid)。更加符合直覺的操作是我們可以使用百分比符號 + 任務編號( jobs
會打印任務編號)來選取該任務。如果要選擇最近的一個任務,可以使用 $!
這一特殊參數。
還有一件事情需要掌握,那就是指令中的 &
後綴可以讓指令在直接在背景執行,這使得我們可以直接在 shell 中繼續做其他操作,不過它此時還是會使用 shell 的標準輸出,這一點有時會比較惱人(這種情況可以使用 shell 重定向處理)。
讓已經在執行的行程轉到背景執行,我們可以輸入 Ctrl-Z
,然後緊接著再輸入 bg
。請注意,背景的行程仍然是我們的終端行程的子行程,一旦我們關閉終端(會發送另外一個信號 SIGHUP
),這些背景的行程也會終止。為了防止這種情況發生,我們可以使用 nohup
(一個用來忽略 SIGHUP
的封裝) 來執行程式。針對已經執行的程式,可以使用 disown
。除此之外,我們可以使用終端多工器來實現,下一章節我們會進行詳細地探討。
用一些簡單的指令來示範上述觀念:
$ sleep 1000
^Z
[1] + 18653 suspended sleep 1000
$ nohup sleep 2000 &
[2] 18745
appending output to nohup.out
$ jobs
[1] + suspended sleep 1000
[2] - running nohup sleep 2000
$ bg %1
[1] - 18653 continued sleep 1000
$ jobs
[1] - running sleep 1000
[2] + running nohup sleep 2000
$ kill -STOP %1
[1] + 18653 suspended (signal) sleep 1000
$ jobs
[1] + suspended (signal) sleep 1000
[2] - running nohup sleep 2000
$ kill -SIGHUP %1
[1] + 18653 hangup sleep 1000
$ jobs
[2] + running nohup sleep 2000
$ kill -SIGHUP %2
$ jobs
[2] + running nohup sleep 2000
$ kill %2
[2] + 18745 terminated nohup sleep 2000
$ jobs
SIGKILL
是一個特殊的信號,它不能被行程捕獲並且會馬上結束該行程。不過這樣做會有一些副作用,例如留下孤兒行程。
我們可以輸入 man signal
或 kill -t
來,詳細可參考here。
終端多工器 (Terminal Multiplexers)
當我們在使用命令列介面時,我們通常會希望同時執行多個任務。舉例來說,我們可以想要同時運行我們的編輯器,並在終端的另外一側執行程序。儘管再打開一個新的終端窗口也能達到目的,使用終端多工器則是一種更好的辦法。
像 tmux
這類的終端多工器可以允許我們根據面板和標籤分割出多個終端窗口,這樣我們便可以同時與多個 shell 進行互動。
不僅如此,終端多工使我們可以分離當前終端作業並在將來重新連接。
這讓我們操作遠端設備時的工作流程大大改善,避免了 nohup
和其他類似技巧的使用。
現在最流行的終端多工器是 tmux
。 tmux
是一個高度可定制的工具,我們可以使用相關快捷鍵建立多個分頁並在它們間切換。
tmux
的快捷鍵需要我們掌握,它們都是類似 <C-b> x
這樣的組合,即先按下Ctrl+b
,鬆開後再按下 x
。 tmux
中對象的繼承結構如下:
- 會話 - 每個會話都是一個獨立的工作區,其中包含一個或多個窗口
tmux
開始一個新會話tmux new -s NAME
以指定名稱開始一個新會話tmux ls
列出當前所有會話- 在
tmux
後輸入<C-b> d
來分離該會話 tmux a
回復最後一個連接的會話, 可以使用-t
來指定哪一個會話
- 窗口 - 相當於編輯器或是瀏覽器中的分頁,從視覺上將一個會話分割為多個部分
<C-b> c
創建一個新的窗口,使用<C-d>
關閉<C-b> N
切換到第 N 個窗口, 每個窗口都是有編號的<C-b> p
切換到前一個窗口<C-b> n
切換到下一個窗口<C-b> ,
重命名當前窗口<C-b> w
列出當前所有窗口
- 面板 - 像 vim 中的分屏一樣,面板使我們可以在一個屏幕裡顯示多個 shell
<C-b> "
水平分割<C-b> %
垂直分割<C-b> <direction>
使用方向鍵切換到不同面板<C-b> z
縮小/放大當前面板<C-b> [
開始往回捲動屏幕。我們可以按下空白鍵來開始選擇,Enter鍵複製選中的部分<C-b> <space>
切換不同板型
這裡 是一份 tmux
快速入門教學, 而 這一篇 文章則更加詳細,它包含了 screen
命令。我們也許想要掌握 screen
命令,因為在大多數 UNIX 系統中都預設安裝有該程序。
别名 (Aliases)
輸入一長串包含許多選項的命令會非常麻煩。 因此,大多數 shell 都支援設置 別名。 shell 的別名相當於一個長命令的縮寫,shell 會自動將其替換成原本的命令。 例如,bash 中的別名語法如下:
alias alias_name="command_to_alias arg1 arg2"
Note that there is no space around the equal sign =
, because alias
is a shell command that takes a single argument.
Aliases have many convenient features:
請注意 =
兩邊是沒有空格的,因為 alias
是一個 shell 命令,它只接受一個參數。
別名有許多很方便的特性:
# 創建常用指令的縮寫
alias ll="ls -lh"
# 能夠節省很多的指令輸入
alias gs="git status"
alias gc="git commit"
alias v="vim"
# 讓我們避免誤打命令
alias sl=ls
# 重定義現有命令的預設行為
alias mv="mv -i" # -i prompts before overwrite
alias mkdir="mkdir -p" # -p make parent dirs as needed
alias df="df -h" # -h prints human readable format
# 別名可以組合使用
alias la="ls -A"
alias lla="la -l"
# 前置反斜線來忽略某個別名
\ls
# 或使用unalias來禁用別名
unalias la
# 印出該別名的定義
alias ll
# 將會印出 ll='ls -lh'
值得注意的是,在默認情況下 shell 並不會保存別名。
為了讓別名持續生效,我們需要將配置放進 shell 的啟動文件裡,像是 .bashrc
或 .zshrc
,下一節我們就會講到。
配置文件 (Dotfiles)
很多程式的配置都是通過純文本格式的被稱作點文件(dotfile)的配置文件來完成的(之所以稱為點文件,是因為它們的文件名以 .
開頭,例如 ~/.vimrc
。也正因為此,它們默認是隱藏文件, ls
並不會顯示它們)。
shell 的配置也是通過這類文件完成的。在啟動時, shell 程式會讀取很多文件以載入其配置選項。根據 shell 本身的不同,我們從登入開始還是以互動的方式完成這一過程可能會有很大的不同。關於這一話題,這裡有非常好的資源。
對於 bash
來說,在大多數系統下,我們可以透過編輯 .bashrc
或 .bash_profile
來進行配置。在文件中我們可以添加需要在啟動時執行的指令,例如上文我們講到過的別名,或者是我們的環境變數。 實際上,很多程序都要求我們在 shell 的配置文件中包含一行類似 export PATH="$PATH:/path/to/program/bin"
的命令,這樣才能確保這些程式能夠被 shell 找到。
還有一些其他工具可以透過點文件配置:
bash
-~/.bashrc
,~/.bash_profile
git
-~/.gitconfig
vim
-~/.vimrc
和~/.vim
目錄ssh
-~/.ssh/config
tmux
-~/.tmux.conf
我們應該如何管理這些配置文件呢,它們應該在它們的文件夾下,並使用版本控制系統進行管理,然後通過腳本將其符號鏈接(symlinked)到需要的地方。這麼做有如下好處:
- 安裝簡單: 如果我們登入了一台新的設備,在這台設備上啟用配置只需要幾分鐘的時間
- 可移植性: 我們的工具在任何地方都以相同的配置工作
- 同步: 在一處更新配置文件,可以同步到其他所有地方
- 追蹤變更: 我們可能要在整個工程師生涯中持續維護這些配置文件,而對於長期項目而言,版本歷史是非常重要的
配置文件中需要放些什麼?我們可以通過線上文件和幫助手冊了解所使用工具的設置項。另一個方法是在網上搜尋有關特定程序的文章,作者們在文章中會分享他們的配置。還有一種方法就是直接瀏覽其他人的配置文件:我們可以在這裡找到無數的dotfiles庫 —— 其中最受歡迎的那些可以在這裡找到(建議不要直接複製別人的配置)。這裡也有一些非常有用的資源。 本課程的老師們也在 GitHub 上開源了他們的配置文件: Anish, Jon, Jose。
可移植性
配置文件的一個常見的痛點是它可能並不能在多種設備上生效。例如,如果我們在不同設備上使用的操作系統或者 shell 是不同的,則配置文件是無法生效的。或者,有時我們僅希望特定的配置只在某些設備上生效。
有一些技巧可以輕鬆達成這些目的。如果配置文件 if 語句,則我們可以藉助它針對不同的設備編寫不同的配置。例如,我們的 shell 可以這樣做:
if [[ "$(uname)" == "Linux" ]]; then {do_something}; fi
# 使用和 shell 相關的配置時先檢查當前 shell 類型
if [[ "$SHELL" == "zsh" ]]; then {do_something}; fi
# 您也可以針對特定的設備進行配置
if [[ "$(hostname)" == "myServer" ]]; then {do_something}; fi
如果配置文件支持 include 功能,您也可以多加利用。例如: ~/.gitconfig
可以這樣編寫:
[include]
path = ~/.gitconfig_local
然後我們可以在每個設備上建立配置文件 ~/.gitconfig_local
,包含與該設備相關的特定配置。甚至應該創建一個單獨的 repository 來管理這些與設備相關的配置。
如果希望在不同的程序之間共享某些配置,該方法也適用。例如,如果您想要在 bash
和 zsh
中同時啟用一些別名,您可以把它們寫在 .aliases
裡,然後在這兩個 shell 裡套用:
# Test if ~/.aliases exists and source it
if [ -f ~/.aliases ]; then
source ~/.aliases
fi
遠端連線
對於工程師來說,在他們的日常工作中使用遠端伺服器已經非常普遍。如果需要使用遠端伺服器來部署後端軟體或一些計算能力強大的伺服器,您就會用到Secure Shell(SSH)。和其他工具一樣,SSH 也是可以高度可配置的,也值得我們花時間學習它。
通過以下指令,可以使用 ssh 連接到其他伺服器:
ssh foo@bar.mit.edu
這裡我們嘗試以用戶名 foo
登入伺服器 bar.mit.edu
。伺服器可以通過 URL 指定(例如 bar.mit.edu
),也可以使用 IP 指定(例如 foobar@192.168.1.42
)。後面我們會介紹如何修改 ssh 配置文件使我們可以用類似 ssh bar
這樣的指令來登陸伺服器。
執行指令
ssh
的一個經常被忽視的特性是它可以直接遠端執行指令。 ssh foobar@server ls
可以直接在用foobar的指令下執行 ls
指令。想要配合管線來使用也可以, ssh foobar@server ls | grep PATTERN
會在本地查詢遠端 ls
的輸出而 ls | ssh foobar@server grep PATTERN
會在遠端對本地 ls
輸出的結果進行查詢。
SSH 密鑰
基於密鑰的驗證機制使用了密碼學中的公鑰,我們只需要向伺服器證明用戶端持有對應的私鑰,而不需要公開其私鑰。這樣就可以避免每次登入都輸入密碼的麻煩了。不過,私鑰(通常是 ~/.ssh/id_rsa
或者 ~/.ssh/id_ed25519
) 等效於您的密碼,所以一定要好好保存它。
密鑰生成
使用 ssh-keygen
命令可以生成一對密鑰:
ssh-keygen -o -a 100 -t ed25519 -f ~/.ssh/id_ed25519
您可以為密鑰設置密碼,防止有人持有您的私鑰並使用它訪問您的伺服器。您可以使用 ssh-agent
或 gpg-agent
,這樣就不需要每次都輸入該密碼了。
如果您曾經設定過使用 SSH 密鑰推送到 GitHub,那麼可能您已經完成了這裡介紹的這些步驟,並且已經有了一對可用的密鑰。要檢查您是否持有密碼並驗證它,您可以運行 ssh-keygen -y -f /path/to/key
。
基於密鑰的認證機制
ssh
會查詢 .ssh/authorized_keys
來確認哪些用戶允許登入。可以通過下面的命令將一個公鑰複製到這裡:
cat .ssh/id_ed25519.pub | ssh foobar@remote 'cat >> ~/.ssh/authorized_keys'
如果支援 ssh-copy-id
的話,可以使用下面這種更簡單的解決方案:
ssh-copy-id -i .ssh/id_ed25519.pub foobar@remote
通過 SSH 複製文件
使用 ssh 複製文件有很多方法:
ssh+tee
, 最簡單的方法是執行ssh
指令,然後通過這樣的方法利用標準輸入實現cat localfile | ssh remote_server tee serverfile
。回憶一下,tee
指令會將標準輸出寫入到一個文件。scp
:當需要拷貝大量的文件或目錄時,使用scp
指令則更加方便,因為它可以方便的遍歷相關路徑。語法如下:scp path/to/local_file remote_host:path/to/remote_file
。rsync
對scp
進行來改進,它可以檢測本地和遠端的文件以防止重複拷貝。它還可以提供一些諸如符號連接(symlinks)、權限管理等額外功能。甚至還可以基於--partial
旗標實現斷點續傳。rsync
的語法和scp
類似。
端口轉發 (Port Forwarding)
很多情況下我們都會遇到軟體需要監聽特定設備的端口。如果是在本機,可以使用 localhost:PORT
或 127.0.0.1:PORT
。但是如果需要監聽遠端伺服器的端口該如何操作呢?這種情況下遠端的端口並不能直接通過網路存取。
此時就需要進行端口轉發(port forwarding)。端口轉發有兩種,一種是本地端口轉發和遠端伺服器發(參見下圖,該圖片引用自這篇StackOverflow 文章)中的圖片。
本地端口轉發
遠端端口轉發
常見的情景是使用本地端口轉發,即遠端設備上的服務監聽一個端口,而您希望在本地設備上的一個端口建立連接並轉發到遠端伺服器。例如,我們在遠端伺服器上運行 jupyter notebook
並監聽 8888
端口。然後,建立從本地端口 9999
的轉發,使用 ssh -L 9999:localhost:8888 foobar@remote_server
。這樣只需要訪問本地的 localhost:9999
即可。
SSH 配置
我們已經介紹了很多SSH可傳入的參數。為它們創建一個別名是個好主意,我們可以這樣做:
alias my_server="ssh -i ~/.id_ed25519 --port 2222 -L 9999:localhost:8888 foobar@remote_server
不過,更好的方法是使用 ~/.ssh/config
。
Host vm
User foobar
HostName 172.16.174.141
Port 2222
IdentityFile ~/.ssh/id_ed25519
LocalForward 9999 localhost:8888
# 在配置文件中也可以使用萬用字元
Host *.mit.edu
User foobaz
這麼做的好處是,使用 ~/.ssh/config
文件來創建別名,類似 scp
、rsync
和mosh
等指令都可以讀取這個配置並將設置轉換為對應的命令列選項。
注意,~/.ssh/config
文件也可以被當作配置文件(dotfile),而且一般情況下也是可以被引入到其他配置文件的。不過,如果您將其公開到網路上,那麼其他人都將會看到您的伺服器地址、用戶名、開放端口等等。這些資訊可能會幫助到那些企圖攻擊您系統的駭客所以請務必三思。
伺服器的配置通常放在 /etc/ssh/sshd_config
。您可以在這裡配置免密碼認證、修改 ssh 端口、開啟 X11 轉發等等。也可以為個別用戶指定配置。
雜項
連接遠端伺服器的一個常見痛點是遇到由關機、休眠或網路環境變化導致的斷線。如果連接的延遲很高也很讓人討厭。 Mosh(即 mobile shell )對 ssh 進行了改進,它允許連接漫遊、間歇性連線及智慧本地回顯。
有時將一個遠端文件夾掛載到本地端會比較方便, sshfs 可以將遠端伺服器上的一個文件夾掛載到本地,然後您就可以使用本地的編輯器了。
Shell & 框架
在 shell 工具和腳本那節課中我們已經介紹了 bash
shell,因為它是目前最通用的 shell,大多數的系統都將其作為默認 shell。但是,它並不是唯一的選項。
例如,zsh
shell 是 bash
的父集合並提供了一些方便的功能:
- 智慧替換, **
- 行內替換/萬用字擴展
- 拼寫修正
- 更好的 tab 補全和選擇
- 路徑展開 (
cd /u/lo/b
會被展開為/usr/local/bin
)
框架也可以改進您的 shell。比較流行的通用框架包括 prezto 或 oh-my-zsh。還有一些更精簡的框架,它們往往專注於某一個特定功能,例如 zsh-syntax-highlighting 或 zsh-history-substring-search。像 fish 這樣的 shell 包含了很多便利的功能,其中一些特性包括:
- 向右對齊
- 指令語法突顯
- 歷史子字串查詢
- 基於手冊頁面的選項補全
- 更聰明的自動補全
- 命令列介面主題
需要注意的是,使用這些框架可能會降低您 shell 的性能,尤其是如果這些框架的程式碼沒有最佳化或者過於冗長。您可以時常測試其性能或禁用某些不常用的功能來達到速度與功能的平衡。
終端模擬器 (Terminal Emulators)
和自定義 shell 一樣,花點時間選擇適合您的終端模擬器並進行設置是很有必要的。有許多終端模擬器可供您選擇(這裡有一些關於它們之間比較的資訊)。
您會花上很多時間在使用終端上,因此研究一下終端的設置是很有必要的,您可以從下面這些方面來設置您的終端:
課後練習
任務控制
- 我們可以使用類似
ps aux | grep
這樣的指令來獲取任務的 pid ,然後您可以根據 pid 來結束這些行程。但我們其實有更好的方法來做這件事。在終端中執行sleep 10000
這個任務。然後用Ctrl-Z
將其切換到後台並使用bg
來繼續允許它。現在,使用pgrep
來查找 pid 並使用pkill
結束行程而不需要手動輸入pid。 (提示: 使用-af
旗標)。
-
如果您希望某個行程結束後再開始另外一個行程, 應該如何實現呢?在這個練習中,我們使用
sleep 60 &
作為先執行的程序。一種方法是使用wait
指令。嘗試啟動這個休眠指令,然後待其結束後再執行ls
指令。但是,如果我們在不同的 bash 會話中進行操作,則上述方法就不起作用了。因為 wait 只能對子行程起作用。之前我們沒有提過的一個特性是,
kill
指令成功退出時其狀態碼為 0 ,其他狀態則是非0。kill -0
則不會發送信號,但是會在行程不存在時返回一個不為0的狀態碼。請撰寫一個 bash 函數pidwait
,它接受一個 pid 作為輸入參數,然後一直等待直到該行程結束。您需要使用sleep
來避免浪費 CPU 性能。
終端多工器
別名
- 創建一個
dc
別名,它的功能是當我們錯誤的將cd
輸入為dc
時也能正確執行。
- 執行
history | awk '{$1="";print substr($0,2)}' | sort | uniq -c | sort -n | tail -n 10
來獲取您最常用的十條指令,嘗試為它們創建別名。注意:這個指令只在 Bash 中生效,如果您使用 ZSH,使用history 1
替換history
。
配置文件
讓我們幫助您進一步學習自動化配置文件:
- 為您的配置文件新建一個文件夾,並設置好版本控制
- 在其中添加至少一個配置文件,比如說您的 shell,在其中包含一些自定義設置(可以從設置
$PS1
開始) - 建立一種在新設備進行快速安裝配置的方法(無需手動操作)。最簡單的方法是寫一個 shell 腳本對每個文件使用
ln -s
,也可以使用專用工具 - 在新的虛擬機上測試該安裝腳本
- 將您現有的所有配置文件移動到dotfiles repository裡
- 將項目發佈到GitHub。
遠端連線
進行下面的練習需要您先安裝一個 Linux 虛擬機(如果已經安裝過則可以直接使用),如果您對虛擬機尚不熟悉,可以參考這篇教學來進行安裝。
- 前往
~/.ssh/
並查看是否已經存在 SSH 密鑰對。如果不存在,請使用ssh-keygen -o -a 100 -t ed25519
來創建一個。建議為密鑰設置密碼然後使用ssh-agent
,更多信息可以參考這裡 - 在
.ssh/config
加入下面內容:
Host vm
User username_goes_here
HostName ip_goes_here
IdentityFile ~/.ssh/id_ed25519
LocalForward 9999 localhost:8888
- 使用
ssh-copy-id vm
將您的 ssh 密鑰複製到伺服器。 - 使用
python -m http.server 8888
在您的虛擬機中啟動一個 Web 伺服器並通過本機的http://localhost:9999
訪問虛擬機上的 Web 伺服器 - 使用
sudo vim /etc/ssh/sshd_config
編輯 SSH 伺服器配置,通過修改PasswordAuthentication
的值來禁用密碼驗證。通過修改PermitRootLogin
的值來禁用 root 登陸。然後使用sudo service sshd restart
重啟ssh
伺服器,然後重新嘗試。 - (進階題) 在虛擬機中安裝
mosh
並啟動連接。然後斷開伺服器/虛擬機的網路卡。 mosh 可以恢復連接嗎? - (進階題) 查看
ssh
的-N
和-f
選項的作用,找出在後台進行端口轉發的命令是什麼?
Licensed under CC BY-NC-SA.