返回開發人員最新消息

Signals in prod: dangers and pitfalls

2022年9月27日發佈者:赵永

在這篇部落格文章中,Meta 的核心工程師 Chris Down 討論了在 Linux 生產環境中使用 Linux 訊號的隱憂,以及為什麼開發人員應盡可能避免使用訊號。

什麼是 Linux 訊號?

訊號是 Linux 系統為回應某些情況而產生的事件。訊號可以由核心傳送給某個程序、由某個程序傳送給另一個程序,或由程序傳送給自己。程序可能會在收到訊號時採取行動。

訊號是 Unix 類型作業環境的核心部分,大概從一開始就存在。訊號是作業系統許多核心元件(核心傾印、程序生命週期管理等)的管道,總體來說,在我們使用訊號的大約 50 年間,一直都保持得很好。因此,當有人提出將訊號用於程序間通訊(IPC)具有潛在危險時,可能會有人認為這是無稽之談。不過,本文將舉例說明訊號已成為生產問題的原因,並提供一些可能的緩解措施和替代方案。

由於訊號的標準化、廣泛的可用性,以及除了作業系統提供的功能,不需要任何其他相依項目的事實,訊號看起來可能很有吸引力。然而,要安全地使用訊號可能會很困難。訊號會造成大量的假設,必須小心驗證是否符合需求,否則就必須小心正確配置。實際上,許多應用程式,即使是廣為人知的應用程式,都不會這麼做,因此將來可能會出現難以偵錯的事件。

讓我們來看看最近發生在 Meta 生產環境中的事件,其更加深了使用訊號的隱憂。我們會簡短地回顧一些訊號的歷史,以及訊號如何引領我們到現今的發展,然後將其與我們目前在生產環境中看到的需求和問題做對照。

事件

首先,讓我們回顧一下。LogDevice 團隊清理了他們的程式碼基底,移除未使用的程式碼和功能。所棄用的功能之一是一種記錄類型,其中記錄該服務已執行的某些作業。此功能最終變得多餘、沒有使用者,因而被移除。您可以在 GitHub 上看到此變更。目前為止一切都很好。

變更過後一小段時間沒什麼問題,生產環境繼續穩定運作,像往常一樣為流量提供服務。幾週後便收到報告指出,服務節點正以驚人的速度遺失。這與新版本的推出有關,但究竟出了 什麼 問題並不清楚。現在有什麼不同而導致事情急轉直下?

出事的團隊歸納問題出在我們前面提到的程式碼變更,也就是棄用了這些記錄。但是為什麼?那個程式碼有什麼問題?如果您還不知道答案,我們邀請您研究一下該差異,並嘗試找出問題所在,因為問題不是立即顯現,而且這是任何人都可能會犯的錯誤。

logrotate,進入循環

logrotate 大概可說是使用 Linux 時的記錄檔輪換標準工具。它現在已經存在了將近三十年,其概念很簡單:以輪換和清空的方式來管理記錄檔的生命週期。

logrotate 本身不會傳送任何訊號,因此您在 logrotate 主頁面或其說明文件中找不到太多相關資訊。然而,logrotate 可以在其輪換之前或之後執行任意命令。就像 CentOS 中預設 logrotate 配置的基本範例一樣,您可以看到以下配置:

/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    sharedscripts
    postrotate
        /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
    endscript
}

有點粗略,但我們忽略這一點,並假設它運作正常。此配置表示,在 logrotate 輪換所列出的任何檔案後,應該會將 SIGHUP 傳送到 /var/run/syslogd.pid 中包含的 pid,這應該是執行 syslogd 實例的 pid。

對於像 syslog 這種具有穩定公開 API 的記錄來說,一切都很好,但如果內部 SIGHUP 實作是可能隨時變更的內部實作細節呢?

掛斷的歷史

這裡的一個問題是,除了不能在用戶空間中攔截,因而只有一種意義的訊號(例如 SIGKILLSIGSTOP)外,訊號的語意是由應用程式開發人員和用戶來解譯及編程的。在某些情況下,這種區別主要是學術上的,例如 SIGTERM,幾乎普遍都是理解為「盡快正常終止」。然而,若是 SIGHUP,意義則不明確許多。

SIGHUP 是為序列線路發明的,原本用來表示連線的另一端已斷線。現在我們當然仍延續我們的傳統,因此仍會針對當今的同等情況:虛擬終端機關閉(因此像 nohup 之類的工具會遮蔽它),傳送 SIGHUP

Unix 早期會需要實作常駐程式重新載入。這通常至少包括重新開啟配置/記錄檔而不重新啟動,而訊號似乎是無相依性的達成方式。當然,這種事情是沒有訊號的,但由於這些常駐程式沒有進行控制的終端機,應該沒有理由接收 SIGHUP,所以它似乎是一個可搭載的方便訊號,而且沒有任何明顯的副作用。

不過,這個計畫有一個小問題。訊號的預設狀態不是「已忽略」,而是訊號特定。因此,例如,程式設計師不必手動配置 SIGTERM 來終止其應用程式。只要他們不設定任何其他訊號處理常式,核心就會免費終止他們的程式,用戶空間中不需要有任何程式碼。非常方便!

但不太方便的是,SIGHUP 也有立即終止程式的預設行為。這對於原本的掛斷情況非常有用,在這種情況下可能不再需要這些應用程式,但是對這個新的意義來說就不是那麼好了。

如果我們移除了所有可能傳送 SIGHUP 給程式的位置,那當然沒問題。問題是在任何大型、成熟的程式碼基底中,這會很困難。SIGHUP 不像嚴格控制的 IPC 呼叫,您可以輕易為其在程式碼基底中執行 grep。訊號可能在任何時間來自任何地方,而且很少對其操作進行檢查(除了最基本的「您是否為這個用戶,或是擁有 CAP_KILL」)。結果是很難判定訊號可能來自哪裡,但若使用更明確的 IPC,我們就會知道此訊號對我們沒有任何意義,應該將其忽略。

從掛斷變成危險

我想您現在可能已經開始在猜發生什麼事了。一個含有上述程式碼變更的 LogDevice 版本,開展了一個毀滅性的下午。起初都沒有問題,但到了隔天午夜,一切開始神秘地崩塌。原因是電腦 logrotate 配置中的以下區段,其將現在未處理(因而具有毀滅性)的 SIGHUP 傳送到 logdevice 常駐程式:

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

移除大型功能時,只遺失一小段 logrotate 配置極為容易且常見。不幸的是,也很難確定其最後殘存的所有遺跡是否都同時移除了。即使在比這更容易驗證的情況下,進行程式碼清理時也常會誤留殘跡,但通常不會有任何破壞性的後果,也就是說,剩餘的殘渣只是無效或沒有作用的程式碼。

概念上,事件本身及其解決方法很簡單:不要傳送 SIGHUP,並較常隨著時間將 LogDevice 動作向外擴散(也就是說,不要準時在午夜執行此動作)。然而,我們在這裡應該關注的不僅僅是此一事件的細微差異。最重要的是,此事件必須當作一個平台,宣導除了最基本、必要的情況外,其他任何情況都應禁止在生產環境中使用訊號。

訊號的危險

訊號對哪些情況有益

首先,使用訊號做為影響作業系統處理狀態變更的機制 有根據的。其中包括 SIGKILL 之類的訊號,這類訊號無法安裝訊號處理常式,並完全按照您的期望執行,而像 SIGABRTSIGTERMSIGINTSIGSEGVSIGQUIT 等訊號,則是用戶和程式設計師通常都很容易理解其核心預設行為。

這些訊號的共同點是,一旦您收到後,這些訊號全都會在核心本身內朝著終端狀態前進。也就是說,一旦您取得沒有用戶空間訊號處理常式的 SIGKILLSIGTERM,就不會再執行用戶空間指令。

終端狀態很重要,因為這通常意味著您正在努力減低目前執行中之堆疊和程式碼的複雜性。隨著並行處理和程式碼流程變得愈來愈混亂,其他想要的狀態通常會導致複雜性其實變得更高且更難推論。

危險的預設行為

您可能會注意到,我們沒有提到一些也會依預設終止的其他訊號。以下列出預設會終止的所有標準訊號(不包括 SIGABRTSIGSEGV 之類的核心傾印訊號,因為這些都很合理):

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

乍看之下,這些似乎很合理,但這裡有一些異常值:

  • SIGHUP:如果只按照原本的目的來使用,預設終止就很合理。若是現今混合用法的意義「重新開啟檔案」,這就很危險了。
  • SIGPOLLSIGPROF:這些訊號屬於「這些應該由某個標準函式在內部處理,而不是由您的程式處理」。然而,雖然可能無害,但預設終止的行為似乎仍不理想。
  • SIGUSR1SIGUSR2:這些是表面上您可以隨意使用的「用戶定義訊號」。但因為這些預設是終端訊號,如果您為了某些特定需求而實作 USR1,之後不再需要,您不能只是安全地移除程式碼。您必須有意識地考慮明確忽略該訊號。即使對每個有經驗的程式設計師來說,這真的也不會很明確。

這幾乎是三分之一的終端訊號,最好的情況是造成問題,最壞的情況則是隨著程式的需求變更而變得非常危險。更糟糕的是,即使是所謂的「用戶定義」訊號,當有人忘記明確將其 SIG_IGN 時,也是一場等待發生的災難。即使是無害的 SIGUSR1SIGPOLL 都可能引起事故。

這不只是熟悉度的問題。無論您多麼瞭解訊號的運作方式,要第一次就編寫出訊號正確的程式碼還是極為困難,因為不管怎麼樣,訊號都遠比看起來複雜很多。

SA_RESTART 的程式碼流程、並行處理和迷思

程式設計師通常不會花一整天時間思考訊號的內部運作方式。這表示在實際實作訊號處理時,他們經常會在細微處做錯事。

我甚至不是在說像訊號處理函式中的安全性這種「瑣細」的案例,這通常只要碰撞 sig_atomic_t 或使用 C++ 的原子訊號圍籬就能解決。不是的,任何人在第一次經歷訊號地獄後,通常很容易就會將其視為隱憂來進行搜尋並記住。更難的是在複雜程式收到訊號時,推論其標稱部分的程式碼流程。進行此推論時,需要在應用程式生命週期的每個部分不斷且明確地考量訊號(嘿,那 EINTR 呢?這裡有 SA_RESTART 就夠了嗎?如果此流程過早終止,我們應該進入什麼流程?我現在有一個並行程式,那意味著什麼?),或是為應用程式生命週期的某個部分設定 sigprocmaskpthread_setmask,並祈禱程式碼流程永遠不會改變(在快速開發的氛圍中,這肯定不是個好推測)。signalfd 或在專用執行緒中執行 sigwaitinfo 在這裡多少會有些幫助,但這兩種作法的邊緣案例和可用性問題都很多,讓人很難推薦。

我們相信大多數有經驗的程式設計師現在都知道,即​​使是亂開玩笑的範例,要正確編寫執行緒安全程式碼都非常困難。如果您認為正確編寫執行緒安全程式碼很困難,那訊號更是難上加難。訊號處理常式必須只依賴具有原子資料結構的嚴格無鎖定程式碼,原因分別是因為執行的主要流程被擱置,而我們不知道它持有什麼鎖,以及因為執行的主要流程可能正在執行非原子作業。訊號處理常式也必須是完全可重新進入的,也就是必須能夠巢放在自己內部,因為如果一個訊號被傳送多次(或甚至有一個訊號具有 SA_NODEFER),訊號處理常式可能會重疊。這就是為什麼您不能在訊號處理常式中使用 printfmalloc 等函式的原因之一,因為它們會依賴全域 mutexes 進行同步。如果您在收到訊號時持有該鎖,然後再次呼叫需要該鎖的函式,您的應用程式最後會鎖死。這真的、真的很難進行推論。這就是為什麼許多人只簡單地編寫如下內容做為其訊號處理方式:

static volatile sig_atomic_t received_sighup; 

static void sighup(int sig __attribute__((unused))) { received_sighup = 1; }

static int configure_signal_handlers(void) {
  return sigaction(
    SIGHUP,
    &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART},
    NULL);
}

int main(int argc, char *argv[]) {
  if (configure_signal_handlers()) {
       /* failed to set handlers */
  }

  /* usual program flow */

  if (received_sighup) {
    /* reload */
    received_sighup = 0;
  }

  /* usual program flow */
}

問題是,雖然在非同步訊號處理時所做的這個(signalfd)或其他嘗試可能看起來相當簡單和健全,但它忽略了一個事實,那就是中斷點與接收訊號後執行的動作一樣重要。例如,假設您的用戶空間程式碼正在執行 I/O 或變更來自核心之物件的中繼資料(例如 inode 或 FD)。在此情況下,中斷時您可能實際上是位在核心空間堆疊中。例如,以下是執行緒嘗試關閉檔案描述符時的樣子:

# cat /proc/2965230/stack
 [<0>] schedule+0x43/0xd0
 [<0>] io_schedule+0x12/0x40
 [<0>] wait_on_page_bit+0x139/0x230
 [<0>] filemap_write_and_wait+0x5a/0x90
 [<0>] filp_close+0x32/0x70
 [<0>] __x64_sys_close+0x1e/0x50
 [<0>] do_syscall_64+0x4e/0x140
 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

這裡的 __x64_sys_closeclose 系統呼叫的 x86_64 變體,它會關閉檔案描述符。在其執行的這個時間點,我們​在等待備份儲存器更新(就是這個 wait_on_page_bit)。由於 I/O 工作通常會比其他操作慢幾個數量級,因此在這裡使用 schedule 可主動向核心的 CPU 排程器提示我們即將執行高延遲操作(如磁碟或網路 I/O),其應考慮尋找另一個程序來排程,而不是使用目前的程序。這樣很好,因為可以讓我們向核心發出訊號,示意最好可以繼續進行,並選擇會實際利用 CPU 的程序,而不是將時間浪費在必須等待某個需要時間的項目回應後才能繼續進行的程序上。

想像我們傳送一個訊號給我們正在執行的程序。我們傳送的訊號在負責接收的執行緒中有一個用戶空間處理常式,因此我們將會在用戶空間中恢復。能夠讓此競用結束的方式有很多,其中之一是核心會嘗試退出 schedule,進一步解開堆疊,最後再傳回 ESYSRESTARTEINTR 的 errno 到用戶空間,指出我們被中斷了。但我們還要多久才能結束?現在檔案描述符的狀態為何?

現在我們已經返回用戶空間,接下來就要執行訊號處理常式了。當訊號處理常式退出時,我們會將錯誤傳播到用戶空間 libc 的 close 包裝函式,然後再傳播到應用程式,理論上,這個應用程式可以對所遇到的狀況做些事情。我們說「理論上」是因為很難知道如何處理許多這些有訊號的情況,而且生產環境中的許多服務並無法妥善處理這裡的邊緣案例。在某些資料完整性不那麼重要的應用程式中,這可能沒什麼問題。然而,在 重視 資料一致性和完整性的生產應用程式中,這就會帶來很大的問題:核心沒有揭露任何精細的方式來瞭解其進度、達到和未達到什麼成果,以及我們應該對這種情況採取什麼實際的行動。更糟糕的是,如果 close 傳回時附有 EINTR,則檔案描述符的狀態現在是未指定的:

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

若要嘗試推論如何在應用程式中安全可靠地處理該情況,只能祝您好運了。一般來說,即使是為運作良好的 syscall 處理 EINTR 也很複雜。有很多細微的問題構成了 SA_RESTART 不足的一大部分原因。並非所有系統呼叫都可以重新啟動,而期望應用程式的每個開發人員都能理解並減少在每個呼叫位置為每個 syscall 取得訊號時的深層細微差異,無非是在要求運作中斷。以下取自 man 7 signal

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

同樣地,使用 sigprocmask 並期望程式碼流程保持靜態也是自找麻煩,因為開發人員通常不會花一生的時間思考訊號處理的界限,或如何產生或保存訊號正確的程式碼。在專用執行緒中使用 sigwaitinfo 來處理訊號也是如此,這很容易導致 GDB 和類似的工具無法為程序偵錯。細微不當的程式碼流程或錯誤處理方式,可能會導致故障、當機、難以進行損毀偵錯、鎖死和許多其他問題,讓您直接投入最愛事件管理工具的溫暖懷抱。

多執行緒環境中的高複雜性

如果您認為所有這些關於並行處理、可重入性和原子性的討論已經夠糟糕了,那麼再將多執行緒丟入這團混亂中,會使事情變得更加複雜。當考量到其實有許多複雜的應用程式會以隱含的方式(例如,隱含在 jemalloc、GLib 之類的程式碼中)執行個別的執行緒時,這一點尤其重要。其中某些程式庫甚至本身就已安裝訊號處理常式,因而打開了另外一整罐蠕蟲。

總體上,man 7 signal 關於這個問題有以下說法:

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

簡單說,就是「對於大多數訊號,核心會將訊號傳送到未以 sigprocmask 封鎖該訊號的任何執行緒。SIGSEGV、SIGILL 等類似陷阱,並且將訊號明確地導向有問題的執行緒。然而,不管怎麼想,大多數訊號都不能明確地傳送到執行緒群組中的單一執行緒,即使使用 tgkillpthread_kill 也一樣。

這表示只要您有一組執行緒,就無法輕易變更整體訊號處理特性。如果服務需要在主執行緒中使用 sigprocmask 進行週期性訊號封鎖,您需要想辦法在外部告知其他執行緒應如何處理這種情形。否則,訊號可能會被其他執行緒吞沒,再也看不見。當然,您可以在子執行緒中封鎖訊號來避免這種情況,但是如果這些子執行緒需要自己處理訊號,即使是像 waitpid 這種單純的程式碼,最終都會使事情變得複雜。

就像這裡的其他所有狀況一樣,這些在技術上並不是無法克服的問題。然而,正確完成這項工作所需的同步複雜性相當棘手,並且為故障、混亂和更糟的情況奠定了基礎,如果忽略這個事實,那就是怠忽職守了。

缺乏成功或失敗的定義和通訊

訊號在核心中為非同步傳播。如果程序或執行緒的 task_struct 有問題,一旦為其記錄擱置中訊號,就會立即傳回 kill syscall。因此,即使訊號未被封鎖,也不能保證及時傳遞。

即使能夠 傳遞訊號,也無法讓訊號發出者得知其要求動作的狀態為何。因此,任何有意義的動作都不應該透過訊號傳遞,因為訊號僅實行「射後不理」(fire-and-forget),沒有真正的機制來回報傳遞和後續動作為成功或失敗。正如我們在上面看到的,即使看似無害的訊號,如果沒有配置在用戶空間中,也可能會很危險。

任何人只要使用 Linux 的時間夠久,一定都會遇到一種情況:想要刪除某個程序,但發現即使是對 SIGKILL 這種據說絕對殺無赦的訊號,該程序都沒有反應。問題在於這個誤導的觀念:kill(1) 的目的不是刪除程序,而只是將要求佇列至核心(未指示何時會服務該要求),表示有人要求採取一些動作而已。

kill syscall 的工作是在核心的任務中繼資料中將訊號標記為擱置中,即使 SIGKILL 任務未終止,這也會成功。特別是在 SIGKILL 的案例中,核心保證不會再執行任何用戶模式指令,但我們可能還是必須在核心模式下執行指令,以完成可能導致資料損毀或釋出資源的動作。因此,即使狀態為 D(不間斷睡眠),我們還是會成功。除非您提供無效的訊號、您無權傳送該訊號,或您要求傳送訊號至某個 pid,但該 pid 不存在,因而無法可靠地用來將非終端狀態傳播到應用程式,否則 Kill 本身並不會失敗。

結論

  • 訊號適用於單純在核心內處理,而沒有用戶空間處理常式的終端狀態。對於您其實想要立即刪除程式的訊號,請將這些訊號留給核心處理。這也代表核心可能會提前退出其工作,更快釋出您的程式資源,而用戶空間 IPC 要求則必須等待用戶空間部分再度開始執行。
  • 避免在處理訊號時遇到麻煩的方法,就是根本不要處理訊號。然而,如果應用程式所處理的狀態程序必須執行 SIGTERM 等案例的相關事務,最好是使用 folly::AsyncSignalHandler 之類的高階 API,其中有很多缺點都已經變得較為直覺性。

  • 避免使用訊號來傳遞應用程式要求。使用自我管理的通知(如 inotify)或用戶空間 RPC 與應用程式生命週期的專用部分來處理,而不要依賴中斷應用程式。
  • 在可能的情況下,使用 sigprocmask 將訊號的範圍限制在程式或執行緒的子區段,以減少需要定期檢查訊號正確性的程式碼量。請記得,如果程式碼流程或執行緒策略變更,遮罩可能不會產生您想要的效果。
  • 在常駐程式啟動時,遮罩無法統一理解並可能在程式中某個點改變用途的終端訊號,以免回復到核心預設行為。我的建議如下:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

即使在編寫良好的程式中,訊號行為的推論也非常複雜,而且在有其他替代方案可用的情況下,在應用程式中使用訊號會帶來不必要的風險。大體上,不要使用訊號來與程式的用戶空間部分進行通訊。反之,應該要讓程式自己直接處理事件(例如,使用 inotify),或是使用能夠向發出者回報錯誤,並可在編譯時列舉和論證的用戶空間通訊,例如 Thrift、gRPC 等等。

我希望本文已經讓您知道,雖然訊號表面上看起來很簡單,但實際上絕非如此。促使將訊號當作 API 用於用戶空間軟體的簡單美學,掩蓋了一系列不適合現代大多數生產環境使用案例的隱含設計決策。

讓我們說得更清楚:訊號有有效的使用案例。舉例來說,如果沒有應刪除程序的用戶空間元件,針對所需的程序狀態使用訊號來與核心進行基本通訊並沒有問題。然而,預期訊號可能會陷在用戶空間時,很難第一次就編寫出訊號正確的程式碼。

由於訊號的標準化、廣泛的可用性,且沒有相依性,可能看起來很有吸引力,但訊號會帶來大量的隱憂,隨著您的專案發展,問題只會愈來愈多。希望本文為您提供了一些緩解措施和替代策略,讓您仍然可以達成目標,但是以一種更安全、不那麼複雜且更直覺的方式。

若要進一步瞭解 Meta 開放原始碼,請造訪我們的開放原始碼網站、訂閱我們的 YouTube 頻道,或追蹤我們的 TwitterFacebookLinkedIn 帳號。