0%

最近接到一個任務,需要協助團隊重現幾個 DB 連線時的錯誤,例如: connection pool 超過上限爆掉、connection timeout 等等,而其中一個錯誤 Pre-Login handshack 最難重現

1
2
System.Data.SqlClient.SqlException (0x80131904): Connection Timeout Expired.  The timeout period elapsed while attempting to consume the pre-login handshake acknowledgement.  This could be because the 
pre-login handshake failed or the server was unable to respond back in time. The duration spent while attempting to connect to this server was - [Pre-Login] initialization=2846; handshake=6765;

這個錯誤推測是在與 SQL Server 連線時 three-way handshake 沒有收到回應導致的失敗,可能屬於網路不穩掉封包問題導致,但問題怎麼證明?

Wireshark

首先得先確認與 SQL Server 連線時到底傳了哪些封包出去,可以透過 wireshark 側錄封包的功能來達成

1. 設定 Capture

1
dst host xxx.xxx.xxx.xxx && port 1433

/images/20210429/1.png

2. 嘗試對 SQL 做一次連線並觀察封包

/images/20210429/2.png
可以發現 Pre-login handshake 應該會有三次封包傳輸

Packet Loss

接著得想辦法重現封包丟失的狀況下,是否會引發相同的 Exception,因為不知道怎麼精準的特定封包攔下來 (如果有人會的話也歡迎留言教學一下),所以這邊透過 Clumsy 這個套件來輔助達成

1. 指定目的地的封包多少比例被攔截下來

1
outbound and ip.DstAddr = xxx.xxx.xxx.xxx

/images/20210429/3.png

2. 增加連線 handshake 的機率

因為無法精準攔截封包,所以採取短時間快速重複連線來增加封包被攔截的碰撞機率,所以我將 Connection Pool 關閉,並且開多執行序只做最簡單的連線與關閉連線,果然很快就碰到 handshake 的封包被攔掉的狀況

/images/20210429/4.png

而最後也證實了只要連線時網路不穩定導致掉封包等狀況時,底層是會引發上述錯誤的狀況

情境

最近踩到 dotnet core 3 contaienr + ms sql 2016 的問題,問題的現象是,程式只要跑到建 sql connection 那行就會 hang 在那邊,沒有 timeout 也沒有 Exception … 追查之下後發現,原來 MS SQL 2016 如果沒有調整更新,預設 TLS 好像只支援 1.0、1.1 (待確認),而我用的 base image : mcr.microsoft.com/dotnet/core/runtime:3.1 的 TLS 設定要求最低版本是 1.2,這也就引發了上述的狀況

1
2
3
4
# cat /etc/ssl/openssl.cnf 
[system_default_sect]
MinProtocol = TLSv1.2
CipherString = DEFAULT@SECLEVEL=2

解法

一般來說,基於安全性會建議至少停用 TLS 1.0 並支援 1.2 才是比較好的做法,但如果你跟我一樣只是在測試,那可以採用以下比較 workaround 的做法,在 dockerfile 加上以下幾行來調整 container tls 最低支援版本

1
2
3
4
RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /etc/ssl/openssl.cnf
RUN sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /etc/ssl/openssl.cnf
RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /usr/lib/ssl/openssl.cnf
RUN sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /usr/lib/ssl/openssl.cnf

這樣重新啟動也就會正常了

說來真的好久沒有寫文章了,除了工作很忙之外,目前碰到的問題也通常不是一兩篇文章可以交代的清,所以也就更新部落格的時間也就間隔越來越長了。

這次主要是想把最近針對 Async 的一些測試研究記錄下來,那我們就開始吧 !

# Async 是什麼?

Async 在 C# 語言中用來支援非同步處理的一種語法,而它的使用往往搭配 Await 一起使用,先來看看一段簡單的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"1. Hi I'm Async Demo");

//拿到非同步的任務
var task = DoAsync();

//繼續執行
Console.WriteLine($"2. Hello World!");

//等待非同步任務執行完成
await task;
Console.WriteLine($"4. End!");
}

public static async Task DoAsync()
{
//碰到 await 時,會將控制項回傳給呼叫端,並且等待非同步的方法執行完成
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"3. Async method Done");
});
}
}

這段程式碼執行後可以看到以下結果

1
2
3
4
1. Hi I'm Async Demo
2. Hello World!
3. Async method Done
4. End!

觀察執行緒的切換

接著加上執行緒 Id 看看,讓我們更清楚執行緒之間是如何切換的

/images/20200919/1.png

執行結果

1
2
3
4
[1] 1. Hi I'm Async Demo
[1] 2. Hello World!
[4] 3. Async method Done
[4] 4. End!

執行流程

  • 執行緒_1 在 11 行印出了 [1] 1. Hi I'm Async Demo

  • 執行緒_1 在 14 行進入了 DoAsync 的方法中

  • 執行緒_1 在 27 行碰到 Task.Run 開啟了非同步執行的方法,並因為 await 跳出了這個 DoAsync()

  • 執行緒_1 在 16 行碰到印出了 [1] 2. Hello World!

  • 執行緒_1 在 19 行碰到 await task ,開始等待 task 執行完成,執行緒_1 釋放回到 Thread Pool

  • 執行緒_4 在非同步方法的第 30 行 印出了 [4] 3. Async method Done ,並通知 await task 執行完成

  • TaskScheduler(通常,這會是以執行緒集區為目標的預設工作排程器)依據最有效率的判斷,讓 執行緒_4 往下執行未完的部分

  • 執行緒_4 接手繼續將 await 之後還沒做完的工作執行完成,意即 21 行,印出 [4] 4. End!

稍微修改一下程式…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 1. Hi I'm Async Demo");

//強制將非同步方法改成同步
DoAsync().Wait();

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 2. Hello World!");

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 4. End!");
}

public static async Task DoAsync()
{
//碰到 await 時,會將控制項回傳給呼叫端,並且等待非同步的方法執行完成
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 3. Async method Done");
});
}
}

執行結果

1
2
3
4
[1] 1. Hi I'm Async Demo
[4] 3. Async method Done
[1] 2. Hello World!
[1] 4. End!

這邊可以看到 執行緒_1 在執行到 await Task.Run(()=> …) 時跳出,但因為我們下了 Wait(),所以強迫 執行緒_1 進行同步的等待。

執行緒_4 印完 [4] 3. Async method Done 後,通知 執行緒_1 繼續往下進行,所以看到 執行緒_1 接著將剩下的程式跑完

差別在哪?

當呼叫非同步執行方法時,如果一路都是用 await 並不會造成任何執行緒被封鎖,換言之該執行緒還可以在別的地方繼續服務,一旦呼叫了 ResultWait() 這類的強制同步方法,則該執行緒會被封鎖,並等待到非同步方法執行完成後才繼續完成尚未完成的後續工作,這會嚴重消耗執行緒的使用效率。

我曾經對一個 Web API 專案進行壓測,在資源給得非常有限的情境下 (約 0.5 core cpu),await 搭配 Task.Run()Result 搭配 Task.Run ,兩個 RPS 測起來差了快一倍之多,在資源極度有限下,執行緒的使用效率將大大影響整體服務的效率。

# SynchronizationContext

SynchronizationContext 是用來記錄當前執行緒環境的類別,在 ASP.NET、WPF、WinForm 都有類似的類別只是名字可能有些差異,其最主要的目的都是在非同步方法中要能調用 UI 執行緒來更新介面之類的操作,而 SynchronizationContext 就紀錄著 UI 執行緒。

Deadlock

過去寫 ASP.NET 的時候曾經踩過一次 SynchronizationContext 的雷,在 ASP.NET 中如果如果呼叫 SynchronizationContext.Currnt 會發現並不為 Null,且型別是 AspNetSynchronizationContext ,當我們用上述的 Result / Wait() 搭配 await 將會導致 Deadlock。

原因是程式碰到 await 時會先判斷 SynchronizationContext.Currnt 是否為 Null,如果是則會在 Task 結束時呼叫 TaskScheduler (通常為 Thread Pool)來安排後續工作。反之,如果 SynchronizationContext.Currnt 不為 Null 時,就會透過 SynchronizationContext.Currnt 來繼續後續的動作。

1
2
3
4
5
6
//碰到 await 時,記住了 SynchronizationContext.Currnt
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 3. Async method Done");
});

而 .Result / Wait() 的方法會封鎖住 SynchronizationContext.Currnt,造成兩邊互等的情況發生,產生了 Deadlock

1
DoAsync().Wait(); //封鎖 SynchronizationContext.Currnt 的物件等待 Task 完成

而這只會發生在 SynchronizationContext.Currnt 不為 Null 的系統中,像是前述提到的 ASP.NET、WPF、WinForm,在 Console Application 並不會發生。

解決方法 ConfigureAwait(false)

如果要避免上述所提到的 Deadlock ,可以在呼叫非同步方法時加上 ConfigureAwait(false),這樣非同工作完成時就會透過另一條執行緒繼續完成後續工作

1
2
3
4
await Task.Run(()=> 
{
....
}).ConfigureAwait(false);

在 ASP.Net Core 的時代…

在 ASP.Net core 呼叫 SynchronizationContext.Current 現在只會得到 Null ,換言之剛剛發生 deadlock 的情境已經不會發生,所以過往在非同步的地方常常要用 ConfigureAwait(false) 可以不用寫了,不過如果你是寫元件或 SDK 類的,並無法預測會被使用在怎樣的環境的話,建議還是都加上會比較保險。

# Best Practice

在使用 Async / await 等非同步技巧時,最好的方式還是都盡量使用非阻斷式的寫法 await ,避免使用 Result / Wait() ,即使你已經是寫 ASP.Net core 不會發生 Deadlock 的情況,阻斷式的寫法對於執行緒的使用效率來說還是會有影響的。

為了測試方便常常會在本機起 RabbitMQ Container,但隨著系統的演進初始化 RabbitMQ 變得越來越複雜,例如:每次都要先設定 8 組 Queue,Exchange binding …等等

工程師的美德就是懶,所以開始找辦法是不是可以讓 RabbitMQ Container 起來時就自己設定好呢?

解法

RabbitMQ 有提供設定檔來初始化,分別為放在

1
2
/etc/rabbitmq/rabbitmq.config
/etc/rabbitmq/definitions.json

而這兩個檔案裡面可以設定 VirtualHost、Authorization、Exchange、Queue

rabbitmq.config
這邊特別提醒一下,內容最後面那個小點不是打錯喔,是規定就是要有的!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
rabbit,
[
{ loopback_users, [] }
]
},
{
rabbitmq_management,
[
{ load_definitions, "/etc/rabbitmq/definitions.json" }
]
}
].

definitions.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
{
"rabbit_version": "3.8",
"users": [
{
"name": "guest",
"password": "guest",
"tags": "administrator"
}
],
"vhosts": [
{ "name": "/" }
],
"permissions": [
{
"user": "guest",
"vhost": "/",
"configure": ".*",
"write": ".*",
"read": ".*"
}
],
"parameters": [],
"policies": [],
"exchanges": [
{
"name": "nmq",
"vhost": "/",
"type": "direct",
"durable": true,
"auto_delete": false,
"internal": false,
"arguments": {}
}
],
"queues": [
{
"name": "command",
"vhost": "/",
"durable": true,
"auto_delete": false,
"arguments": {}
}
],
"bindings": [
{
"source": "nmq",
"vhost": "/",
"destination": "command",
"destination_type": "queue",
"routing_key": "command",
"arguments": {}
}
]
}

方案一

在啟動 rabbitmq container 的時候將這兩個檔案透過 volume 的方式丟進去,這樣就可以達成目的了

1
2
3
$ docker run -it \
-v /etc/so/rabbitmq.config:/etc/rabbitmq/rabbitmq.config:ro \
-v /etc/so/definitions.json:/etc/rabbitmq/definitions.json:ro rabbitmq:3.8-management

方案二

如過你不幸 (?) 的跟我一樣是使用 windows container,volume 這個選擇不屬於你,因為 windows container 只能將整個資料夾 volume 進去,並不能指定單一檔案,所以如果將整個資料夾放進去,需要額外將一些本該在 /etc/rabbitmq 底下的檔案也都 copy 出來,才不會跑起來的時候少東少西的,但這個方法總覺得有點麻煩,所以我採用自己 build image 的方案

dockerfile

1
2
3
4
FROM rabbitmq:3.8-management

ADD rabbitmq.config /etc/rabbitmq/
ADD definitions.json /etc/rabbitmq/

透過 build image 的過程中將檔案放進去,之後起起來也都不用在下 volume 指令,也算是簡單不少

windows server 2019 重開機後,自建 container nat networks 會消失。
來源 : Windows container network drivers

1
NAT networks created on Windows Server 2019 (or above) are no longer persisted after reboot.

所以我有寫一些重啟機器時檢查 network ,如果不存在就再建立一組並且啟動 docker,但時不時會碰到 Error response from daemon: hnsCall failed in Win32: The object already exists. (0x1392) 的錯誤。

但透過以下指令卻又都找不到已經存在的 network mapping

1
2
$ docker network ls
$ Get-NetNatStaticMapping

直接下指令砍掉要建立的 network 名稱,會回傳這物件不存在,但你要建立又會說這物件已經存在的鬼擋牆狀況… 這應該是 windows container bug ,之前只要碰到這個問題都直接重長機器,而剛剛終於找到解法了 (灑花)

解法

1
2
3
4
5
6
7
8
# 先停掉 docker service
$Stop-Service docker

# 停掉 hns
$Stop-Service hns

# 重啟 docker service, 這會連帶的 hns 也會被啟動
$Start-Service docker

這時候再重新跑 docker network create 就會過了,這問題卡了我很長一段時間,終於找到解法了

/images/20200226/0.jpg

(圖片出處: https://sploot.tw/2018/06/powershell-suite-windows-attack-tookit/)

powershell 苦手如我,最近有越來越多的需求要從頭到尾自建 CI/CD 與自動化佈署流程,每次寫都要查一次覺得很煩(越老越金魚腦),決定把常常用到的筆記方便查找

ENV

1
2
3
4
5
6
7
8
#列出所有的環境變數
$gci env:* | Sort-Object name

#result
Name Value
---- -----
ALLUSERSPROFILE C:\ProgramData
APPDATA C:\Users\Steven Tsai\AppData\Roaming
1
2
3
4
5
6
#設定環境變數
$env:test="123"
$env:test

#result
123

Variable

1
2
3
4
$test="123"
$test
#result
123

If … Else

1
2
3
4
$test="123"
$if($test -eq "123") {echo "yes"} else {echo "no"}
#result
yes
1
2
3
4
5
6
#把判斷的結果存到變數中
$test="123"
$result=if($test -eq "123") {echo "yes"} else {echo "no"}
$result
#result
yes

Split

1
2
3
4
5
6
$test="a,b,c"
$test.Split(",")
#result
a
b
c
1
2
3
4
$test="a,b,c"
$test.Split(",")[0]
#result
a

Invoke-WebRequest

1
2
3
4
5
#需要帳密上傳檔案
$PASSWORD = ConvertTo-SecureString -String "your_password" -AsPlainText -Force
$CREDENTIAL = New-Object -TypeName "System.Management.Automation.PSCredential" -ArgumentList "your_account", $PASSWORD

$Invoke-WebRequest -Method PUT -Uri https://server/file.zip -UseBasicParsing -Credential $CREDENTIAL -Infile ".\file.zip"

Remove-Item

1
2
#force強制刪除
$Remove-Item .\file.zip -Force

New-Item

1
2
3
4
5
6
7
8
#建立 logs 資料夾
$New-Item -ItemType directory -Path C:\logs

#result
目錄: C:\
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 2020/2/26 上午 12:28 test

Copy-Item

1
$Copy-Item c:\source_folder C:\target_folder\ -Recurse

Out-File

1
2
3
#將文字寫成檔案
$test="123"
$test | Out-File "C:\log.txt" -Encoding utf8 -Force

Write file without BOM

1
2
$test="123"
$[System.IO.File]::WriteAllLines("C:\log.txt", $test)

Start-Transcript

通常腳本會在長機器的時候自動執行,這時候並不會有人工介入,如果發生錯誤就很需要事後追查 Log ,但一段腳本可能上百上千行,到底錯在哪很難找,這時候就可以透過以下方法把執行的過程跟 output 都儲存下來

1
2
3
$Start-Transcript -Path "c:\logs.txt" -Append
$New-Item -Type Directory C:\TestData2\ -Force
$Stop-Transcript

執行結束後打開 logs.txt 就可以看到以下輸出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
**********************
Windows PowerShell 轉譯開始
**********************
已啟動轉譯,輸出檔為 c:\logs.txt
PS C:\Users\Steven Tsai> New-Item -Type Directory C:\TestData2\ -Force


目錄: C:\


Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 2020/2/26 上午 12:40 TestData2


PS C:\Users\Steven Tsai> Stop-Transcript
**********************
Windows PowerShell 轉譯結束
結束時間: 20200226004058
**********************

Set Global Enviroment

這是用在自動化安裝一些套件,例如 : Git , consul 後需要設定全域的環境變數,才能讓之後的指令能夠認得 consul –version 這類指令。

如果手動安裝,就是去把環境變數中 Path 加上 consul.exe 的位置,以下範例是抓出 Path 本來的設定值,並在尾段補上新的路徑設定

1
2
3
4
5
$oldpath = (Get-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name PATH).path

$newpath = "${oldpath};C:\consul\"

$Set-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name PATH -Value $newPath

Start-Service

1
$Start-Service your_service_name

register task scheduler

1
2
3
4
5
6
7
8
9
10
11
#要執行的動作
$action= (New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-ExecutionPolicy bypass C:\start.ps1")

#執行權限
$ProvisionPrincipal = New-ScheduledTaskPrincipal -UserId "NT AUTHORITY\SYSTEM" -RunLevel Highest -LogonType S4U

#觸發的時機
$ProvisionTrigger = New-ScheduledTaskTrigger -AtStartup

#註冊 task Schedule
$Register-ScheduledTask -TaskName "StartConsul" -TaskPath "\" -Action $action -Trigger $ProvisionTrigger -Principal $ProvisionPrincipal

Create User

1
2
3
4
5
6
7
8
9
10
11
12
13
#設定 User 密碼
$Secure_String_Pwd=ConvertTo-SecureString "user_password" -AsPlainText -Force

#建立User
$New-LocalUser "teamuser" -Password $Secure_String_Pwd -FullName "Arch team user" -Description "for arch team" -PasswordNeverExpires:$true

#將此 User 加入 Administrators
$Add-LocalGroupMember -Group "Administrators" -Member "teamuser"

#建立一個全新的 Group
$New-LocalGroup -Name "docker-users"
#將此 User 加進去
$Add-LocalGroupMember -Group "docker-users" -Member "archteamuser"

Expand-Archive

1
2
3
$curl https://source_file.zip -o c:\targe_file.zip

$Expand-Archive c:\targe_file.zip c:\file -force

希望學會這個技巧後,以後大家弄CI/CD環境都能海闊天空

(圖片出處 : http://ezvivi2.com/article/200483.asp)

有在處理 CI/CD 的人應該都碰到過維護建置環境的問題,舉例來說,當今天開發 C# 專案可能有 dotnet framework、dotnet core、有些人開發前端會需要 npm …等等,CI 機器就需要裝一堆為了建置佈署的軟體,如果開發人員變多,建置排隊久候,可能就會將環境升級為 Master、Slave 架構,但又面臨了多台 Slave 如何快速增長(通常只有上班時間才會同時這麼多人在建置,所以動態增長 CI 機器有其必要),如何維護多台 Slave 環境,有時候遇到需要的套件版本打架的時候,處理起來真的是會抓狂。

透過容器來建置專案

其實綜觀上述的問題可以發現,每個專案建置所需要的東西可能不盡相同,為了讓 CI 機器能夠滿足所有建置的條件往往會把環境搞得過於複雜,這時候容器就是一個非常好的選擇,它滿足了每次建置環境都是獨立隔離的條件且方便佈署。

一旦使用容器來做為建置的媒介,需要長一台新的 CI 機器時,只要將機器開起來並安裝完 docker 就搞定了(甚至 AWS 都有做好的現成 AMI 連自己安裝都省了),不再需要寫一狗票的腳本來安裝機器。

實例

以下是我一個專案建置時的 dockerfile,沒幾行的 script 就快速講一下,因為我這個專案裡面同時有 dotnet framework 與 dotnet core ,所以找個微軟官方提供的 mcr.microsoft.com/dotnet/framework/sdk:4.8, 裡面已經安裝了下列套件。 Docker Hub 連結

1
2
3
4
5
6
.NET Framework Runtime
Visual Studio Build Tools
Visual Studio Test Agent
NuGet CLI
.NET Framework Targeting Packs
ASP.NET Web Targets

我用這個 base image 開了 workspace 資料夾並把 source code 複製進去後,接著就是大家熟悉的 nuget restore 、 dotnet build、 dotnet publish 在容器的環境內建置專案。

第二段是起另一個 base image windows servercore 2019,將剛剛建置好的 artifact 放到指定的資料夾,最後這一包會建置成 image 並推到公司的 Artifact management 上,其它人只要拉下這個 image 就可以執行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM mcr.microsoft.com/dotnet/framework/sdk:4.8 AS build-env

WORKDIR c:/workspace

COPY . ./

RUN nuget restore src\nmqv3.sln
RUN dotnet restore --configfile src\.nuget\NuGet.Config src\my_project.sln

run dotnet build -c release src\my_project.sln
run dotnet publish -c Release -r win-x64 --self-contained true src\Router\Router.csproj


FROM mcr.microsoft.com/windows/servercore:ltsc2019

WORKDIR c:/worker
COPY --from=build-env c:/workspace/src/Worker/bin/release/ .

WORKDIR c:/router
COPY --from=build-env c:/workspace/src/Router/bin/Release/netcoreapp3.0/win-x64/publish/ .

從建置到最後要交付的 container image 一氣呵成,全部都濃縮在一份 dockerfile 裡面,這份 dockerfile 會跟著專案內,只要任何一台機器可以執行 docker container 就可以建置佈署這個專案,是不是方便許多 XD

ThreadLocal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static ThreadLocal<string> LocalString = new ThreadLocal<string>();

static async Task Main(string[] args)
{
LocalString.Value = "Value 1";
Console.WriteLine($"【A】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

var t1 = AsyncMethod();

Console.WriteLine($"【D】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

await t1;
}

static async Task AsyncMethod()
{
Console.WriteLine($"【B】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

LocalString.Value = "Value 3";
Console.WriteLine($"【C】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

await Task.Delay(100);

Console.WriteLine($"【E】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");
}


//【A】 Thread: 14, ExcutionContext: 37151951 ,value: Value 1
//【B】 Thread: 14, ExcutionContext: 51676517 ,value: Value 1
//【C】 Thread: 14, ExcutionContext: 51676517 ,value: Value 3
//【D】 Thread: 14, ExcutionContext: 37151951 ,value: Value 3
//【E】 Thread: 16, ExcutionContext: 46128400 ,value:

ThreadLocal 非常容易理解, 每個 Thread 之間彼此是隔離的, 即便 ExcutionContext 不同, 但只要是同一個 Thread 都會是共用的。

所以可以看到 await 之後, 因為不同 Thread 執行剩下的 Code , ThreadLocal 的值就變成預設的空值


AsyncLocal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static AsyncLocal<string> LocalString = new AsyncLocal<string>();

static async Task Main(string[] args)
{
LocalString.Value = "Value 1";
Console.WriteLine($"【A】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

var t1 = AsyncMethod();

Console.WriteLine($"【D】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

await t1;
}

static async Task AsyncMethod()
{
Console.WriteLine($"【B】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

LocalString.Value = "Value 3";
Console.WriteLine($"【C】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");

await Task.Delay(100);

Console.WriteLine($"【E】 Thread: {Thread.CurrentThread.ManagedThreadId}, ExcutionContext: {Thread.CurrentThread.ExecutionContext.GetHashCode()} ,value: {LocalString.Value}");
}



//【A】 Thread: 14, ExcutionContext: 5705933 ,value: Value 1
//【B】 Thread: 14, ExcutionContext: 12517624 ,value: Value 1
//【C】 Thread: 14, ExcutionContext: 12517624 ,value: Value 3
//【D】 Thread: 14, ExcutionContext: 5705933 ,value: Value 1
//【E】 Thread: 8, ExcutionContext: 35765882 ,value: Value 3

AsyncLocal 會在每次需要切出 ExcutionContext 時複製一份給新的 ExcutionContext , 所以每個 ExcutionContext間的 AsyncLocal 都是獨立的,但同時可達跨 Thread , ExcutionContext 往下傳遞的特性。

/images/20191109/0.png

( https://www.codeprimers.com/service-discovery-in-microservice-architecture/ )


前言

傳統上一組服務通常都會搭配 DNS + Load balance + serivce cluster, 服務要呼叫時也都是直接打 Domain 讓 Load balance 去導流。

不過這也面臨了管理 Domain 與服務間的複雜度, 例如服務要加一台新的機器需要去設定 Load balance 才有辦法服務(當然也是可以搭配一些 cloud autoscaling 機制來簡化流程); Client 在呼叫 Service 時通常需要真的打打看才有辦法知道服務是否還正常, 無法事先知道服務狀態再決定是否要呼叫。

近年微服務與容器化運用的盛行, 上述方式就顯得有點卡卡的,如果你的服務在 k8s 內那可能還好, 大部分它都幫忙處理掉了, 但如果像我們公司一樣, 很多服務雖然容器化但還無法進到 k8s 內, container 可以在短時間內 scale 成數倍來因應大流量, 但卡在這些服務需要去 load balance 註冊才有辦法讓流量導進來, 那顯然就有點做半套的感覺, Service Discovery 的應用就成為了重要的課題


什麼是 Service Discovery

翻譯成中文就是服務發現(廢話…),顧名思義就是呼叫端在需要時先透過搜尋來找到對應的服務,而要達成這點需要一些配合才能達成。

首先第一步, 當某個服務起來時應該主動向提供 service discovery 註冊自己是什麼服務? 位置在哪? 是否已經可以開始服務了?

/images/20191109/1.gif

註冊時會搭配著 Health check 的機制, 告訴 service discovery 如何檢核這個節點是否健康的

/images/20191109/2.png

Client 在要叫用 service 時, 先透過 service name 詢問有哪些節點可用, 而 service discovery 也能準確地回應有哪些目前還是健康的節點可供呼叫

/images/20191109/3.png

當節點損壞時, 以 container 的案例, 會直接換掉不健康的 container 重長一組, 而這又會回到圖一註冊的流程。

更細緻一點的作法還有對 service discovery 回應的列表做快取,例如快取 1 秒,當服務自己要被收掉時, 做好 graceful shutdown 先把自己從服務列表中 deregister , 並且等待 3 秒後才關機,避免快取到的 Client 打來你剛好停止服務。

或是對服務加上 Tag ,當特定用戶或 VIP 連進來時可以透過搜尋特定機器去執行

/images/20191109/4.png


面臨的問題

但並不是所有事情都是如此美好, 公司目前導入這些機制雖然可以大幅簡化管理內部服務 domain 這類的問題, 但對於 load balance,即選取適合的節點這點上還有一些問題要克服,例如傳統 ELB 這類的服務都會依據每個節點的回應速度來判斷接下來導流的分配,但 service discovery 因為 request 並不會經過它,所以相關 latancy 也無從監控與觀察, 所以 load balance 這題必須額外拉出來做相對應的監控機制。

再來當服務橫跨 vm 跟 k8s 時情境就變得更複雜,不過這些題目等到之後有明確的方向再做整理好了,久違的文章雖然都沒寫到 code,但還是把一些觀念記錄下來。相關的實作等之後有空再補吧 (逃)

/images/20190819/0.jpg

最近因為任務關係對 NATS Streaming 做了一些研究跟 POC,這邊把一些研究到的NATS Streaming 特性記錄下來

什麼是 NATS

NATS 是一種 Queue , 它效能比一般的 Queue 好上許多,號稱 Sender / Reciever 每秒可高達 20 萬筆 Message,這是非常驚人的數字。

/images/20190819/1.png

(來源:https://bravenewgeek.com/dissecting-message-queues/)

可以看到 RabbitMQ 在它旁邊矮了一大截….


NATS 支援 Subscribe 模式,Subscriber 訂閱需要的 subject,當 NATS 收到 Message 時就會同時派發給所有訂閱者。

/images/20190819/2.png

(來源:https://nats-io.github.io/docs/developer/concepts/pubsub.html)


NATS 也支援 Queu Group,NATS 會對同一個 Queue Group 的所有 Subscriber 分配訂閱的 Message,而不會重複

/images/20190819/3.png

(來源:https://nats-io.github.io/docs/developer/concepts/queue.html)

不過這些都不算太特別,畢竟 RabbitMQ 一樣能做到相同的功能,最特別的是 NATS Streaming


什麼是 NATS Streaming

NATS Streaming 是附加在 NATS 之上的加值功能,他可以永久的保留所有的 Message,並且支援 Cursor(指標)功能,你可以任意的讓 Subscriber 回到特定的點 重播 所有的 Message。

這一點相當有用,試想一下,如果今天我們將所有交易的過程都透過 NATS Streaming 記錄下來,假設資料庫或記錄的地方損毀,我們只要重新 Replay 所有 Message,即能重建最後的結果。這也是 Event Sourcing 中最重要的的一環。

為了達成 Event Sourcing 這個設計模式的目標,演練了一些特定的情境

  1. 為了達成 HA 及加快處理速度的需求,需要多個 Subscriber 同時訂閱相同 Subject,並且彼此不會做到重複的 Message。
  2. 演練災難還原,Subscriber 必須能回朔到過去的的定時間點,重新 Replay 所有歷程

1. Queue Group

其實第一項相對於容易,只要用 Queue Group 即可輕鬆達成,只要每個 Subscriber 給定同一組 Queue Group Name 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var options = StanOptions.GetDefaultOptions();

var subOptions = StanSubscriptionOptions.GetDefaultOptions();
subOptions.MaxInflight = 1; // MaxInflight


var cf = new StanConnectionFactory();

var c = cf.CreateConnection("test-cluster", clientId, options);
var s = c.Subscribe("foo", queueGroup, subOptions, (obj, args) =>
{
......
});

//等待接收關機訊號

//關閉連線
s.Close();
c.Close();
c.Dispose();

這邊需要特別注意 MaxInflight 這個參數,為了效率 NATS 預設會一次派給 Subscriber 1000 則Message(預設值有點忘記),好讓 Subscriber 不需要每做完一筆才透過網路要下一筆 Message,但這也讓我測出一個問題,當預收的訊息太多並且 Subscriber 根本來不及消化掉,這時候 NATS 會判定該則訊息 timeout,轉而派給同群別的Subscriber 來處理,這時候可能會發生兩個 Subscriber 執行到同一筆 Message 的狀況,所以請估算好 MaxInflight 的值,避免抓一堆做不完重派的狀況發生。

當最後一個 Subscriber 離線後,Queue Group 即會被刪除


2. Durable Name

前面有提到 NATS Streaming 支援 Cursor 的功能,當 Subscriber 都離線時,如果有給它 Durable Name,他會記得上次你執行到哪一筆,當下次 Subscriber 重新連上時會從那個點繼續往下派發

1
2
var subOptions = StanSubscriptionOptions.GetDefaultOptions();
subOptions.DurableName = _appSetting.DURABLENAME;

3. StartAt

接著來實作災難還原的步驟,首先如何讓特定的 Queue Group 退到特定的點,呈上,同時多個 Subscriber 與 Durable Name 都還是要能符合。

SDK 其實有提供 StartAt() 的 API

1
2
3
// Receive messages starting at a specific sequence number
var subOptions = StanSubscriptionOptions.GetDefaultOptions();
subOptions.StartAt(22);

不過這邊需特別注意,同一個 Queue Group 只有在第一個 Subscriber 進來時(也就建立這個 Queue Group 的時候)StartAt 才會生效,之後進來的 Subscriber 帶這個值都會被直接忽略掉。

這邊問題就來了,當我們帶了 Queue Group + Durable Name,即便所有的 Subscriber 都離線了,NATS 還是會貼心的幫你記錄最後執行到的地方,所以之後進來的 Subscriber 也都不可能是第一個了,換言之這樣的狀況是無法重新設定指標的。

所以如果要刪除 Queue Group + Durable Name 的 Queue Group 唯一的方法只有讓所有的 Subscriber 退訂閱

1
2
// 取消訂閱 刪除該Group
s.Unsubscribe();

這邊跟上面的範例程式不一樣,上面的是用 **Close()**,這樣並不會讓 Subscriber 退訂閱,只會是離線而已,唯有用 Unsubscribe() 將 Subscriber 全部退訂閱後,有 Durable Name 的 Queue Group 才會被刪除掉。

另外 NATS SDK 並沒有提供查詢 Queue Group 所有 Subscriber 的 Client ID,依據官方的說法,這份清單應該是使用者需要自己維護的,在上面這個案例上,就會需要將所有 Subscriber 用相同 Client ID 連上線後退訂閱。


4. ManualAcks

用上述的方法成功刪除 Queue Group 後,下一步就是重新讓第一個 Subscriber 去建立 Queue Group 並指定 StartAt ,不過這邊有點麻煩的地方是,Subscriber 一連線後馬上就會開始拋 Message 過來,這時後如果你是希望災難還原與重新執行 Replay 分開做時就會很困擾。

預設 StanSubscriptionOptions 是收到 Message 後自動就幫你 Ack 回報 NATS 你收到了在處理了,NATS 也就會繼續往下把下一筆分配給別人,但我只是想指定位子不想要處理 Message 呢?

這時候就要透過設定將 Ack 改成手動回報

1
2
3
4
5
6
7
8
9
var subOptions = StanSubscriptionOptions.GetDefaultOptions();
subOptions.ManualAcks = true;

.......

var s = c.Subscribe("foo", queueGroup, subOptions, (obj, args) =>
{
args.Message.Ack();
});

這樣只要不呼叫 **Ack()**,就等於不處理這筆 Message 了,當 Subscriber 離線或是 Message timeout,NATS 就會重新分派給其他 Subscriber 處理了,這樣也間接達到重建 Queue Group 與指定 Cursor 的目標。