微服務在彈性雲實現無損釋出實踐

醒也無聊發表於2022-01-24

1. 背景

原來的系統是個單體服務,導致邏輯越來越複雜,牽一髮而動全身。為了提高系統的可擴充套件性,我們把原來的單體系統,按照功能拆分成不同的微服務。

spring-cloud

2. 彈性雲配置

我們所有的微服務都是部署在彈性雲上的,希望在部署服務時能夠做到無損釋出。要做到這一點,以下幾個步驟是需要實現的:

  1. 容器銷燬之前服務程式能夠主動從eureka註冊中心列表中刪除;
  2. 在eureka註冊中心列表刪除例項後,該例項在一定的時間內還要能夠承接一些流量,因為此時其他eureka客戶端還有該例項的快取;
  3. 最後等待其他執行緒全部處理完成後,再銷燬容器。

下面看下如何實現上面的需求。

2.1 eureka主動下線方式

有以下幾種eureka註冊中心服務下線的方式:

  1. 直接kill服務

    這種方式簡單粗暴,但是在這種情況下,雖然客戶端已經停止服務了,但是仍然存在於註冊中心列表中,會造成部分模組呼叫時出錯,所以這個方案pass。

  2. 向Eureka service傳送delete請求

    http://{eureka-server:port}/eureka/apps/{application.name}/{instance.name}

    這種方案只是取消註冊服務,但是當eureka服務再一次接收到心跳請求時,會重新把這個例項註冊到eureka上,所以這個方案也pass了。

  3. 客戶端通知Eureka service下線

    DiscoveryManager.getInstance().shutdownComponent();

    eureka客戶端可以通過上面一行程式碼主動通知註冊中心下線,下線後也不會再註冊到eureka上,這個方案符合我們的要求,但是我們需要確認這行程式碼需要在什麼時候被呼叫?

2.2 下線時機

在這裡我們首先需要確定從eureka註冊中心刪除例項的時機,有以下幾種想法:

1. 自定義controller介面

@GetMapping("/shutdown")
public void shutdown() {
  DiscoveryManager.getInstance().shutdownComponent();
}

在容器部署之前,先呼叫此介面下線,然後再執行部署操作。但是這樣做有很大的弊端:1. 該介面不能暴露出去,同時為了避免其他人惡意呼叫,還需要加一些鑑權操作;2. 無法整合到部署指令碼中,因為和彈性雲團隊的同學瞭解到,容器銷燬前並不會執行control.sh裡的stop方法,而是傳送一個SIGTERM訊號,所以沒辦法將該介面呼叫寫到部署指令碼中。因此如果採用這種方式,只能每個容器上線前手動呼叫該介面,風險太大,因為此方案不合適。

2. 自定義Shutdown Hook

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  // 從eureka註冊列表中刪除例項
  DiscoveryManager.getInstance().shutdownComponent();
  // 休眠120S
  try {
  	Thread.sleep(120 * 1000);
  } catch (Exception ignore) {
  }
}));

JVM在接收到系統的SIGTERM訊號後,會呼叫Shutdown Hook裡的方法,這樣註冊一個這樣的Shutdown Hook是不是就可以了呢?

經過測試發現並不完美,雖然下線時能夠及時通知eureka服務下線改服務,但是同時Tomcat也會拒絕接收接下來的請求,druid執行緒池也會close;這樣其他微服務由於快取了改例項,還會有請求打到這個例項上,導致請求報錯。

3. Spring Shutdown Hook

是什麼原因導致上述情況的呢?翻閱Spring原始碼可以發現,SpringBoot在服務啟動過程中,會自動註冊一個Shutdown Hook,原始碼如下:

// org.springframework.boot.SpringApplication#refreshContext
private void refreshContext(ConfigurableApplicationContext context) {
  this.refresh((ApplicationContext)context);
  if (this.registerShutdownHook) {
    try {
      // 註冊shutdownHook
      context.registerShutdownHook();
    } catch (AccessControlException var3) {
    }
  }
}

SpringBoot在啟動過程中,重新整理Context之後,如果沒有手動關閉registerShutdownHook(預設開啟),則會註冊一個Shutdown Hook。

// org.springframework.context.support.AbstractApplicationContext#registerShutdownHook
@Override
public void registerShutdownHook() {
  if (this.shutdownHook == null) {
    // No shutdown hook registered yet.
    this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
      @Override
      public void run() {
        synchronized (startupShutdownMonitor) {
          // shutdownHook真正需要執行的邏輯
          doClose();
        }
      }
    };
    // 註冊shutdownHook
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
  }
}

Spring Shutdown Hook的具體執行邏輯,我們稍後分析;現在來看下如果JVM註冊了多個Shutdown Hook,那麼它們的執行順序是怎麼樣的?

// java.lang.Runtime#addShutdownHook
public void addShutdownHook(Thread hook) {
  SecurityManager sm = System.getSecurityManager();
  if (sm != null) {
    sm.checkPermission(new RuntimePermission("shutdownHooks"));
  }
  ApplicationShutdownHooks.add(hook);
}
// java.lang.ApplicationShutdownHooks

/* The set of registered hooks */
private static IdentityHashMap<Thread, Thread> hooks;

static synchronized void add(Thread hook) {
  if(hooks == null)
    throw new IllegalStateException("Shutdown in progress");

  if (hook.isAlive())
    throw new IllegalArgumentException("Hook already running");

  if (hooks.containsKey(hook))
    throw new IllegalArgumentException("Hook previously registered");

  hooks.put(hook, hook);
}

可以看到,當我們新增一個Shutdown Hook時,會呼叫ApplicationShutdownHooks.add(hook),向ApplicationShutdownHooks類下的靜態變數private static IdentityHashMap<Thread, Thread> hooks裡新增一個hook,hook本身是一個thread物件。

// java.lang.ApplicationShutdownHooks#runHooks

/* Iterates over all application hooks creating a new thread for each
 * to run in. Hooks are run concurrently and this method waits for
 * them to finish.
 */
static void runHooks() {
  Collection<Thread> threads;
  synchronized(ApplicationShutdownHooks.class) {
    threads = hooks.keySet();
    hooks = null;
  }

  for (Thread hook : threads) {
    hook.start();
  }
  for (Thread hook : threads) {
    while (true) {
      try {
        hook.join();
        break;
      } catch (InterruptedException ignored) {
      }
    }
  }
}

上述原始碼是應用級hooks的執行邏輯,hook執行時呼叫的是tread類的start方法,所以多個hook是非同步執行的,但是會等到所有hook全部執行完才會退出。

到這裡,我們就可以確定方案2有問題的原因:雖然我們在自定義Shutdown Hook裡自作聰明的sleep 120s,但是由於它和Spring Shutdown Hook執行並不是同步的,所以在自定義hook的睡眠過程中,spring同時也在做一些收尾工作,導致此時打到改例項上的請求報錯。

既然自定義Shutdown Hook的方案行不通,那麼是不是可以在Spring Shutdown Hook這裡搞一些操作呢?接下來看下Spring Shutdown Hook的具體實現邏輯:

// org.springframework.context.support.AbstractApplicationContext#doClose
protected void doClose() {
  if (this.active.get() && this.closed.compareAndSet(false, true)) {
    
    LiveBeansView.unregisterApplicationContext(this);

    // 1. Publish shutdown event. 
    publishEvent(new ContextClosedEvent(this));

    // 2. Stop all Lifecycle beans, to avoid delays during individual destruction.
    if (this.lifecycleProcessor != null) {
      this.lifecycleProcessor.onClose();
    }

    // 3. Destroy all cached singletons in the context's BeanFactory.
    destroyBeans();

    // 4. Close the state of this context itself.
    closeBeanFactory();

    // 5. Let subclasses do some final clean-up if they wish...
    onClose();

    // 6. Reset local application listeners to pre-refresh state.
    if (this.earlyApplicationListeners != null) {
      this.applicationListeners.clear();
      this.applicationListeners.addAll(this.earlyApplicationListeners);
    }

    this.active.set(false);
  }
}

上面原始碼只保留了關鍵程式碼,可以看到,Spring Shutdown Hook一共做了這些事情:

  1. 釋出Context Close事件,可以讓監聽此事件的listener在應用關閉前執行一些自定義邏輯;
  2. 執行lifecycleProcessor的onClose方法;
  3. 銷燬Context BeanFactory中所有快取的單例;
  4. 關閉當前上下文的狀態;
  5. 子類可以自己實現OnClose方法,做一些各自的清理工作;
  6. 將本地應用監聽者重置為pre-refresh狀態;

既然Spring Shutdown Hook執行邏輯的第一步是釋出Context Close事件,那我們就可以建立一個listener監聽此事件,然後在監聽回撥裡執行從eureka註冊列表中刪除例項的邏輯。實現如下:

@Component
public class EurekaShutdownConfig implements ApplicationListener<ContextClosedEvent>, PriorityOrdered {
    private static final Logger log = LoggerFactory.getLogger(EurekaShutdownConfig.class);

  	@Override
    public void onApplicationEvent(ContextClosedEvent event) {
        try {
            log.info(LogUtil.logMsg("_shutdown", "msg", "eureka instance offline begin!"));
            DiscoveryManager.getInstance().shutdownComponent();
            log.info(LogUtil.logMsg("_shutdown", "msg", "eureka instance offline end!"));
            log.info(LogUtil.logMsg("_shutdown", "msg", "start sleep 120S for cache!"));
            Thread.sleep(120 * 1000);
            log.info(LogUtil.logMsg("_shutdown", "msg", "stop sleep 120S for cache!"));
        } catch (Throwable ignore) {
        }
    }

  	@Override
    public int getOrder() {
        return 0;
    }
}

至此主動從eureka註冊中心刪除例項的時機就已經確定了。

2.3 其他配置

application.yml

server:
	# 優雅關機策略
	shutdown: graceful
	# 其他配置
	...

tomcat執行優雅關機的時機是在lifecycleProcessor.onClose(),在這裡不詳細展開說明了,可自行翻閱原始碼。

自定義執行緒池

@Configuration
public class MyThreadTaskExecutor {

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();

        // 執行緒池引數
        taskExecutor.setCorePoolSize(8);
        taskExecutor.setMaxPoolSize(32);
        taskExecutor.setQueueCapacity(9999);
        taskExecutor.setKeepAliveSeconds(60);

        taskExecutor.setThreadNamePrefix("async-");

        taskExecutor.setTaskDecorator(new TraceIdTaskDecorator());

        // 服務停用前等待非同步執行緒執行完成
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        // 60S後強制關閉
        taskExecutor.setAwaitTerminationSeconds(60);

        taskExecutor.initialize();

        return taskExecutor;
    }
}

自定義執行緒池和資料庫連線池的關閉是在銷燬bean時執行的。

3. 總結

至此,我們可以總結下當服務接收到SIGTERM訊號後的處理邏輯:

優雅關閉

如有謬誤,歡迎指正。

相關文章