TPP穩定性之場景隔離和多租戶

巫宸發表於2018-02-07

 TPP有3600+個場景,每個場景是一些AB(演算法方案程式碼+業務配置+流量分配策略)的集合,場景按業務團隊劃分物理叢集,同一個物理叢集內的容器是對等的,JVM內部署著演算法容器,演算法容器內混布相同的場景集合,演算法容器是平臺編碼,場景方案程式碼則是演算法編碼並進行熱部署。前端請求以場景為粒度請求RR,RR獲取場景所在叢集按叢集進行路由。如下圖所示。

tpp部署方式.png

 如前文所述,容器是平臺開發編碼,程式碼質量可控,而演算法場景程式碼則是全集團各個演算法owner編寫,編碼質量參差不齊。這種情況下JVM內場景混布就會出現相互影響的問題,如cpu分配不均,記憶體分配不均等問題,最討厭的是出現死迴圈。針對這些問題TPP已經將重要的核心場景和非重要的小場景進行物理隔離,即調配到不同的物理叢集,這樣一定程度上減少了非重要場景程式碼問題導致核心場景大量異常的情況,如超時。但非核心叢集死迴圈,甚至核心叢集相互影響的情況還是時有發生。那為什麼不直接每個場景單獨一個容器部署呢,通過docker層面cgroup直接隔離場景是否可行?當然可行,但是機器成本將大幅上升。因為每個容器裡要載入各種二方服務,如pandora,forest,igraph,sumamry,各種hsf服務等,而且每個場景要保證至少兩臺的可用度,這樣機器記憶體規模至少要擴大好多倍,機器數自然答覆上漲。很多場景qps非常低的,峰值也是錯開的,混布能極大提高資源利用率。我們對隔離做了一些改進工作,包括執行緒池隔離,多租戶隔離。

 首先系統進行了執行緒池隔離改造,演算法方案程式碼從HSF業務執行緒直接執行改為HSF業務執行緒提交給場景執行緒池執行。每個場景都管理一個自己的執行緒池,平臺根據流量需求可動態調配不同的執行緒池引數。如下圖所示:

執行緒池隔離.png
 這樣做的好處是:

  • 保護了hsf入口工作執行緒,改造之前演算法方案超時嚴重會造成容器hsf服務pool full。場景執行緒池隔離後根據場景超時上限(一般是200ms)做超時interrupt,保證不會大量併發堆積。同時場景執行緒池設定拒絕策略,在併發堆積超過wait_queue+max_pool的情況下立即拒絕服務。這樣一定程度提升了hsf的可用性。
  • 減少無用的超時後計算,hsf業務執行緒並不會被中斷,如果演算法中途超時了,並沒必要做後面的複雜計算工作,浪費的cpu資源也被節約下來。
  • 場景間公平性得到一定保障,程式碼有問題的場景不佔滿hsf執行緒的情況下,其他場景仍能有流量得到服務。

 執行緒池隔離在雙11前也發揮了作用,如rtp第一次成功升級arpc後出現過死鎖,發生呼叫的業務執行緒都會一直阻塞,如果發生在hsf執行緒,這臺機器就game over了,而通過重置場景業務執行緒池就能免啟動瞬間修復。

 執行緒池隔離帶來的問題是增加一定的上下文切換開銷,設定合理的core size和alive time,通過壓測和實際執行發現並沒有效能下降,也沒有明顯增加jvm的執行緒數。這裡從同步改造成執行緒池方式,需要解決一些問題,典型的就是ThreadLocal問題,包括eagleeye和業務threadlocal。下面是支援eagleeye和業務threadlocal透傳的執行緒池實現:

public class SolutionExecutorService extends ThreadPoolExecutor {

    public SolutionExecutorService(int corePoolSize, int maximumPoolSize, long keepAliveTime,
                                   TimeUnit unit, BlockingQueue<Runnable> workQueue,
                                   ThreadFactory threadFactory, RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
    }


    @Override
    protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
        return new SolutionFutureTask<T>(runnable, value);
    }

    @Override
    protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
        return new SolutionFutureTask<T>(callable);
    }


    class SolutionFutureTask<T> extends FutureTask<T> {
        // 當前context透傳到工作執行緒
        final RpcContext_inner rpcContext = EagleEye.getRpcContext();
        final Map<String, Object> tppContext = ThreadLocalParams.save();

        public SolutionFutureTask(Callable<T> callable) {
            super(callable);
        }

        public SolutionFutureTask(Runnable runnable, T result) {
            super(runnable, result);
        }

        public void run() {
            Profiler.start("RunWithPool.");
            EagleEye.setRpcContext(rpcContext);
            ThreadLocalParams.restore(tppContext);
            try {
                super.run();
            } finally {

                EagleEye.clearRpcContext();
                ThreadLocalParams.clear();
            }
        }
    }

}

 執行緒池隔離並沒有根本解決死迴圈和cpu分配不均問題,因為cpu密集型計算是無法interrupt的,同時TPP的一個平臺價值之一是演算法可以隨時變更演算法方案熱部署(包括雙11當天),如果方案記憶體回收不徹底也會造成記憶體洩漏。因此我們利用多租戶進一步解決cpu隔離和記憶體回收兩個問題,改造後的隔離模式如下圖所示, 將演算法方案之間以及演算法與系統之間進行隔離:
多租戶隔離.png

 首先結合AJDK的多租戶,利用cgroup進行徹底的cpu隔離。但這不是容易的事,對於TPP這樣複雜的容器更不容易,下文將介紹TPP多租戶改造的艱辛之路。
 cgroup的cpu隔離主要有這麼幾種方式:cpuset,cpushares,cpu quota.

  • cpuset是cpu核為粒度的物理隔離,我們搜尋hippo排程docker容器的時候就是cpuset隔離,保證每個容器不相互影響。
  • cpushares設定使用者的cpu使用權重,權重越大則分配的cpu資源越多,它是個相對值。如3個程式的cpu shares分別為512,1024,1024,則他們滿負載時候分配到的cpu資源是1:2:2即20%:40%:40%。如果後兩個執行緒沒有滿負載,第一個share為512的可以使用超過20%。如果後兩個空閒,則第一個可以用到100%,一旦share為1024的程式要使用cpu,則512的程式會讓出cpu。
  • 最後cpu quota設定了程式能使用的cpu最大比例絕對值,如cfs_period=100000,cfs_quota=50000,則程式能用到一個cpu core的50%,cfs=50000n則可以用到n個core的50%,總cpu可以使用到50%n/cores。
     再來分析下AJDK的多租戶實現原理,首先看一個執行緒怎麼被cgroup限制cpu:
TenantConfiguration tenantConfiguration = new TenantConfiguration(cpuShares, memLimit)
                        .limitCpuCfs(cfsPeriod, cfsQuota);
TenantContainer container = TenantContainer.create(name, tenantConfiguration);

使用者建立了個租戶容器,這裡指定了租戶的cpu shares,記憶體上限,cpu利用率上限。然後使用者呼叫租戶容器去執行運算。

container.run(new Runnable() {
    @Override
    public void run() {
        doRun();
    }
});


AJDK底層對多租戶的改造有這樣一個非常重要的原則:
執行緒1由租戶容器1建立,則執行緒1建立的其他執行緒都屬於容器1,這些執行緒整體cpu利用率受容器1的cgroup限制

這個原則會帶來什麼麻煩事呢,先看看租戶執行的程式碼:

public void run(final Runnable runnable) throws TenantException {
        if (state == TenantState.DEAD || state == TenantState.STOPPING) {
            throw new TenantException("Tenant is dead");
        }
        // The current thread is already attached to tenant
        if (this == TenantContainer.current()) {
            runnable.run();
        } else {
            if (TenantContainer.current() != null) {
                throw new TenantException("must be in root tenant before running into non-root tenant.");
            }
            // attach to new tenant
            attach();
            try {
                runnable.run();
            } finally {
                // detach from the tenant
                detach();
            }
        }
 }

 這裡首先檢查當前執行緒所屬租戶容器(下文以容器1代替)和當前執行租戶容器(下文以容器2代替)是否同一個,如果同一個執行執行runnable,這裡沒有效能開銷。如果不是麻煩來了,呼叫attach通過jni呼叫繫結當前執行緒到容器2的cgroup組,然後執行runnable,這時候執行緒的cpu就得到了租戶容器2的cgroup限制,runnable執行結束後再通過jni恢復執行緒和容器1的繫結。這裡有嚴重的效能開銷,即jni呼叫cgroup非常慢(實測50ms以上)。因此每個場景都要有一個執行緒池和一個租戶容器,執行緒池必須有一定的coresize和alive,防止頻繁new執行緒呼叫cgroup產生大耗時,場景執行緒必須由租戶容器建立。這樣執行緒池submit一個task就打到和普通執行緒池一樣的效能,我們為場景執行緒池定製了ThreadFactory,線上程池隔離的基礎上能輕鬆實現:

static class TenantThreadFactory extends NamedThreadFactory {
    private TenantContainer container;

    public TenantThreadFactory(TenantContainer container, String prefix) {
        super(prefix);
        this.container = container;
    }

    @Override
    public Thread newThread(Runnable r) {
        final ObjectWrapper<Thread> wrapper = new ObjectWrapper<>();
        // 用租戶容器去建立執行緒
        try {
            container.run(() -> {
                return super.newThread(r);
                wrapper.setObject(t);
            });
        } catch (TenantException e) {
            throw new RuntimeException("create tenant thread exception", e);
        }
        return wrapper.getObject();
    }
}

 對於簡單應用到此為止就完成了多租戶改造,而對TPP來說則只是完成了一小步。因為TPP接入了大量的二方服務,如IGraph, RTP, SUMMARY,很多HSF服務,Forest等,前文已經介紹過混布場景是為了複用二方服務,為每個場景克隆二方服務client會產生很大的記憶體開銷。這些複用的二方服務也管理了自己的執行緒池,結合前文所述租戶執行緒建立的其他執行緒也屬於這個租戶,一旦二方服務的執行緒由某個租戶建立然後被其他租戶複用則產生了cgroup切換的開銷,同時cpu分配也會錯亂。因此TPP還要對場景租戶執行緒和二方服務線進行隔離,這就涉及對一些核心高併發二方服務(雙11 IGraph峰值530w qps,SUMMARY 69w qps, RTP 75w qps)client的改造。原理很簡單,為二方服務的執行緒池增加定製的ThreadFactory:

public class RootTenantThreadFactory extends NamedThreadFactory {

    public RootTenantThreadFactory(String prefix, boolean daemon) {
        super(prefix, daemon);
    }

    @Override
    public Thread newThread(Runnable r) {
        if (JvmUtil.isTenantEnabled()) {
            final ObjectWrapper<Thread> wrapper = new ObjectWrapper<>();
            // 用root容器去建立執行緒
            try {
                TenantContainer.primitiveRunInRoot(() -> {
                    Thread t = super.newThread(r);
                    wrapper.setObject(t);
                });
            }
            return wrapper.getObject();
        } else {
            return super.newThread(r);
        }

    }
}

 對於大部分非同步httpclient類的擴充套件client只需要在構造時候增加設定threadFactory即可:

public class RootTenantThreadFactory extends NamedThreadFactory {

    public RootTenantThreadFactory(String prefix, boolean daemon) {
        super(prefix, daemon);
    }

    @Override
    public Thread newThread(Runnable r) {
        if (Profiler.getEntry() == null) {
            Profiler.start("Create Pool Thread ");
        } else {
            Profiler.enter("Create Pool Thread ");
        }
        if (JvmUtil.isTenantEnabled()) {
            final ObjectWrapper<Thread> wrapper = new ObjectWrapper<>();
            // 用root容器去建立執行緒
            try {
                TenantContainer.primitiveRunInRoot(() -> {
                    Thread t = super.newThread(r);
                    wrapper.setObject(t);
                });
            } finally {
                Profiler.release();
            }
            return wrapper.getObject();
        } else {
            return super.newThread(r);
        }

    }
}

 友情提示:多租戶的隔離方式不當使用會導致宿主機cgroup下目錄太多而負載過高,這個之前在sigma上有反饋,容器銷燬時需要刪除ajdk的程式cgroup目錄,需要應用自己操作,幸運的是hippo排程自動完成了這個工作。

最後看一下多租戶隔離的效果:
8核虛擬機器下進行測試,非租戶隔離的情況下叢集內其他場景發生死迴圈,且源源不斷的有死迴圈請求進來,當前場景會因為併發數過大全部被限流
貼上圖片.png
cpu基本被打滿(這裡對root租戶作了10%cpu保護,並不會800%,這裡實際略高於720%)
貼上圖片.png
使用多租且限定租戶最大cpu使用50%,仍然構建一個場景死迴圈,可以看到當前場景只是少量超時,因為cgroup的排程也會造成場景rt上升,符合業務95%以上正確率的要求。
貼上圖片.png
觀察容器cpu,正常場景800qps時容器cpu仍有餘量
貼上圖片.png

接下去我們還會做多租戶的動態調控,對於問題場景自動降權,避免cpu的浪費。

最後感謝ajdk團隊對我們需求的支援和技術幫助, @傳勝 @三紅 @右席 @卓仁


相關文章