爲了方便大家理解,實際排查和解決過程可能和本文描述的並不完全一致,但是思路是一樣的。
問題過程
某次大促期間,某一個線上應用突然發生大量報警,提示磁盤佔用率過高,一度達到了80%多。
這種情況我們第一時間登錄線上機器,查看線上機器的磁盤使用情況。使用命令:df查看磁盤佔用情況。
$df
Filesystem 1K-blocks Used Available Use% Mounted on
/ 62914560 58911440 4003120 93% /
/dev/sda2 62914560 58911440 4003120 93% /home/admin
發現機器磁盤確實耗費的比較嚴重,因爲大促期間請求量比較多,於是我們最先開始懷疑是不是日誌太多了,導致磁盤耗盡。
這裏插播一個背景,我們的線上機器是配置了日誌的自動壓縮和清理的,單個文件達到一定的大小,或者機器內容達到一定的閾值之後,就會自動觸發。
但是大促當天並沒有觸發日誌的清理,導致機器磁盤一度被耗盡。
經過排查,我們發現是應用的某一些Log文件,佔用了極大的磁盤空間,並且還在不斷的增大。
du -sm * | sort -nr
512 service.log.20201105193331
256 service.log
428 service.log.20201106151311
286 service.log.20201107195011
356 service.log.20201108155838
du -sm * | sort -nr :統計當前目錄下文件大小,並按照大小排序代碼文本框
於是經過和運維同學溝通,我們決定進行緊急處理。
首先採取的手段就是手動清理日誌文件,運維同學登錄到服務器上面之後,手動的清理了一些不太重要的日誌文件。
rm service.log.20201105193331
但是執行了清理命令之後,發現機器上面的磁盤使用率並沒有減少,而且還是在不斷的增加。
$df
Filesystem 1K-blocks Used Available Use% Mounted on
/ 62914560 58911440 4003120 93% /
/dev/sda2 62914560 58911440 4003120 93% /home/admin
於是我們開始排查爲什麼日誌被刪除之後,內存空間沒有被釋放,通過命令,我們查到了是有一個進程還在對文件進行讀取。
lsof |grep deleted
SLS 11526 root 3r REG 253,0 2665433605 104181296 /home/admin/****/service.log.20201205193331 (deleted)
lsof |grep deleted 的作用是:查看所有已打開文件並篩選出其中已刪除狀態的文件
經過排查,這個進程是一個SLS進程,在不斷的從機器上讀取日誌內容。
LS是阿里的一個日誌服務,提供一站式提供數據收集、清洗、分析、可視化和告警功能。簡單點說就是會把服務器上面的日誌採集到,持久化,然後供查詢、分析等。
我們線上日誌都會通過SLS進行採集,所以,通過分析,我們發現磁盤空間沒釋放,和SLS的日誌讀取有關。
到這裏,問題基本已經定位到了,那麼我們插播一下原理,介紹一下這背後的背景知識。
背景知識
Linux系統中是通過link的數量來控制文件刪除的,只有當一個文件不存在任何link的時候,這個文件纔會被刪除。
一般來說,每個文件都有2個link計數器:i_count 和 i_nlink,也就是說:Linux系統中只有i_nlink及i_count都爲0的時候,這個文件纔會真正被刪除。
-
i_count表示當前文件使用者(或被調用)的數量,
-
i_nlink表示介質連接的數量(硬鏈接的數量);
-
可以理解爲i_count是內存引用計數器,i_nlink是磁盤的引用計數器。
當一個文件被某一個進程引用時,對應i_count數就會增加;當創建文件的硬鏈接的時候,對應i_nlink數就會增加。
在Linux或者Unix系統中,通過rm或者文件管理器刪除文件,只是將它會從文件系統的目錄結構上解除鏈接(unlink),實際上就是減少磁盤引用計數i_nlink,但是並不會減少i_count數。
如果一個文件正在被某個進程調用,用戶使用rm命令把文件"刪除"了,這時候通過ls等文件管理命令就無法找到這個文件了,但是並不意味着這個文件真正的從磁盤上刪除了。
因爲還有一個進程在正常的執行,在向文件中讀取或寫入,也就是說文件其實並沒有被真正的"刪除",所以磁盤空間也就會一直被佔用。
而我們的線上問題就是這個原理,因爲有一個進程正在對日誌文件進行操作,所以其實rm操作並沒有將文件真正的刪除,所以磁盤空間並未釋放。
問題解決
在瞭解了線上的問題現象以及以上的相關背景知識之後,我們就可以想到辦法來解決這個問題了。
那就是想辦法把SLS進程對這個日誌文件的引用幹掉,文件就可以真正的被刪除,磁盤空間就能真正的被釋放掉了。
kill -9 11526
$df
Filesystem 1K-blocks Used Available Use% Mounted on
/ 62914560 50331648 12582912 80% /
/dev/sda2 62914560 50331648 12582912 80% /home/admin
特別提醒下,在執行kill -9 之前,一定要考慮下執行的後果是什麼,背後原理可以參考:我到服務器執行kill -9後,就被通知第二天別來了!
事後,我們經過覆盤,發現之所以出現這樣的問題,主要有兩個原因:
-
線上日誌打印太多,太頻繁
-
SLS日誌拉取速度太慢
深入分析後我們發現,這個應用打印了很多過程日誌,最初日誌打印是爲了方便排查線上的問題,或者做數據分析用的,但是大促期間日誌量激增,導致磁盤空間佔用極速上升。
另外,因爲該應用和幾個其他的大應用共用了一份SLS的project,導致SLS拉取速度被拉低,進而導致進程一直無法結束。
事後,我們也總結了一些改進項,對於第二個問題,我們對於該應用的SLS配置做拆分,獨立出來進行管理。
對於第一個問題,那就是大促期間引入日誌降級的策略,一旦發生磁盤爆滿,就是將日誌降級掉。
關於日誌降級,我開發了一個通用的工具,就是通過配置的方式,動態推送日誌級別,動態修改線上的日誌輸出級別。並且把這份配置的修改配置到我們的預案平臺上,大促期間進行定時或者緊急預案處理,即可避免這個問題。
下面和大傢俱體分享一下,日誌降級工具的開發思路和相關代碼:
日誌級別
在開始正文前簡單介紹下日誌級別,不同的日誌框架支持不同的日誌級別,其中比較常見的就是Log4j和Logback。
在Log4j中支持8種日誌級別,優先級從高到低依次爲:OFF、FATAL、ERROR、WARN、INFO、DEBUG、TRACE、 ALL。
Logback中支持7種日誌級別,優先級從高到低分別是:OFF、ERROR、WARN、INFO、DEBUG、TRACE、ALL。
可以看到常見的ERROR、WARN、INFO、DEBUG,這兩者都是支持的。
所謂設置日誌的輸出級別表示的是輸出的日誌的最低級別,也就是說,如果我們把級別設置成INFO,那麼包括INFO在內以及比INFO優先級高的級別的日誌都可以輸出。
無論是Log4j還是Logback,都是通過日誌的配置文件來控制日誌輸出級別的。這裏就不詳述了。
日誌框架
上面我們提到了Log4j和Logback,這兩種都是比較常用的日誌框架。
但是很多時候,我們在代碼中打印日誌並不是直接使用這種日誌框架來進行的,而是依賴了一個日誌門面來進行的,如slf4j、commons-logging等。
一般最最常用的方法就是通過slf4j提供的LoggerFactory的getLogger來獲取Logger,然後進行日誌打印
private
static
final Logger LOGGER = LoggerFactory.getLogger(LoggerService.class);
public void test(){
LOGGER.info( "hollis log test");
}
當我們使用LoggerFactory.getLogger方法創建一個Logger對象的時候,會給他傳入一個loggerName,通過這個loggerName來唯一識別一個Logger,如上面的方式就是使用LoggerService這個類的全路徑名作爲其loggerName。
loggerName是每一個Logger的配置信息一部分,除此之外還有日誌輸出級別等信息。
關於爲什麼不直接使用log4j和logback打印日誌,我在《爲什麼阿里巴巴禁止工程師直接使用日誌系統(Log4j、Logback)中的 API》中分析過。
Arthas改變日誌級別
在開始介紹代碼實現之前,先介紹一個工具,也可以幫助我們的動態修改日誌級別。
那就是阿里開源的神器——Arthas (https://arthas.aliyun.com/doc/ )。
Arthas提供了一個logger命令,這個命令可以查看和更新logger信息,包括日誌級別。
查看指定名字的logger信息
[arthas@2062]$ logger -n org.springframework.web
name org.springframework.web
class ch.qos.logback.classic.Logger
classLoader sun.misc.Launcher$AppClassLoader@2a139a55
classLoaderHash 2a139a55
level null
effectiveLevel INFO
additivity true
codeSource file:/Users/hengyunabc/.m2/repository/ch/qos/logback/logback-classic/ 1.2. 3/logback-classic- 1.2. 3.jar
更新logger level
[arthas@2062]$ logger
--name ROOT --level debug
update logger level success.
簡單吧,使用一個命令就可以修改機日誌級別了。
但是Arthas目前對於集羣的支持並不是特別的友好,雖然他支持了通過Arthas Tunnel Server/Client 來遠程管理/連接多個Agent,但是使用起來還不是很方便,並且對於命令的使用要求比較高。
還有就是我們系統通過一個工具,方便我們在大促期間通過預案方式動態調整日誌級別,這方面使用arthas就不是很方便了。
代碼實現
我寫的這個工具功能很簡單,就是提供動態修改日誌級別的入口,方便用戶動態修改級別。
並且爲了方便使用,我將他封裝在一個Spring Boot Starter裏面了,還有就是將他直接對接到公司內部的配置中心中,可以方便的通過配置中心一鍵修改日誌級別。
首先看下其中最核心的功能,那就是動態修改日誌級別的部分,代碼如下
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.logging.LogLevel;
import org.springframework.boot.logging.LoggerConfiguration;
import org.springframework.boot.logging.LoggingSystem;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.springframework.boot.logging.LoggingSystem.ROOT_LOGGER_NAME;
/**
* 日誌級別設置服務類
*
* @author Hollis
*/
public class LoggerLevelSettingService {
@Autowired
private LoggingSystem loggingSystem;
private static final Logger LOGGER = LoggerFactory.getLogger(LoggerLevelSettingService.class);
public void setRootLoggerLevel(String level) {
LoggerConfiguration loggerConfiguration = loggingSystem.getLoggerConfiguration(ROOT_LOGGER_NAME);
if (loggerConfiguration == null) {
if (LOGGER.isErrorEnabled()) {
LOGGER.error("no loggerConfiguration with loggerName " + level);
}
return;
}
if (!supportLevels().contains(level)) {
if (LOGGER.isErrorEnabled()) {
LOGGER.error("current Level is not support : " + level);
}
return;
}
if (!loggerConfiguration.getEffectiveLevel().equals(LogLevel.valueOf(level))) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("setRootLoggerLevel success,old level is '" + loggerConfiguration.getEffectiveLevel()
+ "' , new level is '" + level + "'");
}
loggingSystem.setLogLevel(ROOT_LOGGER_NAME, LogLevel.valueOf(level));
}
}
private List<String> supportLevels() {
return loggingSystem.getSupportedLogLevels().stream().map(Enum::name).collect(Collectors.toList());
}
}
以上代碼,就是根據用戶傳入的level的級別,將應用的ROOT日誌輸出級別修改掉。
這裏面用到了一個關鍵的服務:
org.springframework.boot.logging.LoggingSystem
LoggingSystem服務是SpringBoot對日誌系統的抽象,是一個頂層的抽象類。他有很多具體的實現
通過上圖,我們可以發現目前SpringBoot目前支持4種類型的日誌,分別是JDK內置的Log(JavaLoggingSystem)以及Log4j(Log4JLoggingSystem)、Log4j2(Log4J2LoggingSystem)以及Logback(LogbackLoggingSystem)。
LoggingSystem是個抽象類,內部有這幾個方法:
-
beforeInitialize方法:日誌系統初始化之前需要處理的事情。抽象方法,不同的日誌架構進行不同的處理
-
initialize方法:初始化日誌系統。默認不進行任何處理,需子類進行初始化工作
-
cleanUp方法:日誌系統的清除工作。默認不進行任何處理,需子類進行清除工作
-
getShutdownHandler方法:返回一個Runnable用於當jvm退出的時候處理日誌系統關閉後需要進行的操作,默認返回null,也就是什麼都不做
-
setLogLevel方法:抽象方法,用於設置對應logger的級別
SpringBoot在啓動時,會完成LoggingSystem的初始化,這部分代碼是在LoggingApplicationListener中實現的
/*** 執行LoggingSystem初始化的前置操作
*/
private void onApplicationStartingEvent(ApplicationStartingEvent event) {
//獲取LoggingSystem的真實實現,
// 此處會根據不同的日誌框架獲取不同的實現,
// logback :LogbackLoggingSystem
// log4j2:Log4J2LoggingSystem
// javalog:JavaLoggingSystem
this.loggingSystem = LoggingSystem
. get( event.getSpringApplication().getClassLoader());
//執行beforeInitialize方法完成初始化前置操作
this.loggingSystem.beforeInitialize();
}
有了LoggingSystem以後,我們就可以通過他來動態的修改日誌級別。他幫我們屏蔽掉了底層的具體日誌框架。
除了支持修改ROOT級別的日誌以外,還可以支持用戶自定義的日誌的級別修改,代碼實現如下:
先定義一個LoggerConfig,用來封裝日誌的配置
/*** the config of logger
*
* @author Hollis
*/
public class LoggerConfig {
/**
* the name of the logger
*/
private String loggerName;
/**
* the log level
*
* @see LogLevel
*/
private String level;
public String getLoggerName() {
return loggerName;
}
public void setLoggerName(String loggerName) {
this.loggerName = loggerName;
}
public String getLevel() {
return level;
}
public void setLevel(String level) {
this.level = level;
}
}
接着提供方法動態修改日誌級別:
public void setLoggerLevel(List<LoggerConfig> configList) {
Optional.ofNullable(configList).orElse(Collections.emptyList()).forEach(
config -> {
LoggerConfiguration loggerConfiguration = loggingSystem.getLoggerConfiguration( config.getLoggerName());
if (loggerConfiguration == null) {
if (LOGGER.isErrorEnabled()) {
LOGGER. error( "no loggerConfiguration with loggerName " + config.getLoggerName());
}
return;
}
if (!supportLevels().contains( config.getLevel())) {
if (LOGGER.isErrorEnabled()) {
LOGGER. error( "current Level is not support : " + config.getLevel());
}
return;
}
if (LOGGER.isInfoEnabled()) {
LOGGER.info( "setLoggerLevel success for logger '" + config.getLoggerName() + "' ,old level is '"
+ loggerConfiguration.getEffectiveLevel()
+ "' , new level is '" + config.getLevel() + "'");
}
loggingSystem.setLogLevel( config.getLoggerName(), LogLevel.valueOf( config.getLevel()));
}
);
}
以上,根據用戶傳入的LoggerConfig,修改指定的loggerName對應的loggerLevel。至於LoggerLevel是怎麼來的,就可以通過配置的方式傳入,比如解析JSON格式的配置或者YML文件等。
如我們可以在配置中心中採用以下配置來控制日誌級別,並推送:
[{
'loggerName':
'com.hollis.degradation.core.logger.LoggerLevelSettingService',
'level':
'WARN'}]
以上配置,會使得loggerName爲com.hollis.degradation.core.logger.LoggerLevelSettingService的日誌的級別動態修改爲WARN,另外,如果配置信息如下:
[{
'loggerName':
'com.hollis.degradation.core.logger',
'level':
'WARN'}]
當然,這個配置也支持配置多個Logger的級別,如果是以下配置內容:
[
{ 'loggerName': 'com.hollis.degradation.core.logger', 'level': 'WARN'}
,{ 'loggerName': 'com.hollis.degradation.core.logger.LoggerLevelSettingService', 'level': 'INFO'}
]
加入代碼中有多個日誌,他們的定義方法分別爲
private
static
final Logger LOGGER1 = LoggerFactory.getLogger(LoggerLevelSettingService.class);
private static final Logger LOGGER2 = LoggerFactory.getLogger(TestService.class);
private static final Logger LOGGER3 = LoggerFactory.getLogger(DebugService.class);
那麼,配置生效後,會使得以上的LOGGER1的輸出級別爲INFO,而LOGGER2和LOGGER3的級別爲WARN。
除此以外,上面的日誌級別修改,可能會影響到我們自己這個工具本身的日誌輸出,所以,我們提供了一個方法,可以直接修改我們自己這個日誌服務的日誌級別
public void setDegradationLoggerLevel(String level) {
LoggerConfiguration loggerConfiguration = loggingSystem.getLoggerConfiguration(
this.getClass().getName());
if (loggerConfiguration == null) {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn( "no loggerConfiguration with loggerName " + level);
}
return;
}
if (!supportLevels().contains(level)) {
if (LOGGER.isErrorEnabled()) {
LOGGER.error( "current Level is not support : " + level);
}
return;
}
if (!loggerConfiguration.getEffectiveLevel().equals(LogLevel.valueOf(level))) {
loggingSystem.setLogLevel( this.getClass().getName(), LogLevel.valueOf(level));
}
}
有了以上的LoggerLevelSettingService類以後,基本具備了動態修改日誌的能力,接下來就是想辦法通過配置中心動態修改日誌級別了。
這裏面因爲不同的配置中心用法不同,我只是拿我們自己的配置中心簡單舉例
/*** 降級開關注冊器
*
* @author Hollis
*/
public class DegradationSwitchInitializer implements Listener, InitializingBean {
//從配置項中讀取應用名,方便註冊到配置中心
@Value( "${project.name}")
private String appName;
@Autowired
private LoggerLevelSettingService loggerLevelSettingService;
//配置中心值發生變化會自動回調該方法
@Override
public void valueChange( String appName, String nameSpace, String name,
String value) {
if (name.equals(rootLogLevel.name())) {
loggerLevelSettingService.setRootLoggerLevel(value);
}
if (name.equals(logLevelConfig.name())) {
List<LoggerConfig> loggerConfigs = JSON.parseArray(value, LoggerConfig. class);
loggerLevelSettingService.setLoggerLevel(loggerConfigs);
}
//將降級工具的日誌輸出級別設置成INFO,保證其日誌可以正常輸出
loggerLevelSettingService.setDegradationLoggerLevel( "INFO");
}
@Override
public void afterPropertiesSet() {
//將服務配置到配置中心
ConfigCenterManager.addListener( this);
ConfigCenterManager.init(appName, DegradationConfig. class);
}
}
以上,我們實現了監聽配置中心的值的變化,動態修改日誌級別。
基本功能就都完成了,接下來可以考慮如何讓其他應用快速接入,那就是定義一個Starter,可以方便快速接入。主要代碼如下:
先定義一個Configuration類:
/*** @author Hollis
*/
@Configuration
@ConditionalOnProperty(prefix = "hollis.degradation", name = "enable", havingValue = "true")
public class HollisDegradationAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "project.name")
public LoggerLevelSettingService loggerLevelSettingService() {
return new LoggerLevelSettingService();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(value = LoggerLevelSettingService.class)
public DegradationSwitchInitializer degradationSwitchInitializer() {
return new DegradationSwitchInitializer();
}
}
在這個類裏面定義兩個bean,並且bean定義的前提是應用中配置了以下兩個配置項:
hollis.degradation.enable =
true
project.name = test
接下來就是定一個spring.factories文件,定義內容如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.hollis.degradation.starter.autoconfiguration.HollisDegradationAutoConfiguration
以上,只需要在需要引入降級工具的應用中,引入我們的這個starter,並且配置兩個配置項即可。
接入後,可以方便的在配置中心中動態修改單機或者集羣的日誌輸出級別,並且可以在大促期間配置到預案平臺上,通過緊急預案快速執行。
以上,基本實現了很多基本的功能,實現時考慮的因素主要有以下幾個:
1、通用性。要同時可以支持不同的日誌框架,客戶端使用的日誌框架不影響我們的功能,並且客戶端不需要關心自己的日誌框架的區別。
2、可配置性。可以將配置信息通過外部配置中心推送,可以快速進行調整。
3、易用性。通過封裝到SpringBoot Starter中,方便客戶端快速接入。
4、無侵入性。框架的使用不應該影響到應用的正常運行。
當然,這個工具只是我花了幾個小時擼出來的,其中還有很多不足,其實還有很多事情可以優化,比如配置的格式可以支持多種、支持通過EndPoint查看日誌配置情況等,這些都還沒有實現。
本文只是提供一個思路,希望大家都能學會用工具化的方式解決日常工作中遇到的問題,學會造輪子。
思考
每次大促之後我們覆盤,都會發現其實大多數問題都是由幾個不起眼的小問題堆積到一起而引發的。
在問題分析過程中往往會需要運用到很多非開發技能相關的知識,如操作系統、計算機網絡、數據庫、甚至硬件相關的知識。
所以我一直認爲,判斷一個程序員是否牛X,就看他的解決問題的能力!
作者丨Hollis Hollis 來源丨公衆號:Hollis (ID:hollischuang) dbaplus社羣歡迎廣大技術人員投稿,投稿郵箱: [email protected]