0%

【.Net Core】AWS Lambda + Chrome Headless + Snapshot

2018-10-25 更新 :DotNet Core 版本已經找出解決方案了,更新在最下面

注意 !!

這篇的結論是 DotNet Core + Selenium + Chrome headless + AWS Lambda 執行是失敗的,所以如果不想看失敗案例的可以直接跳過了,寫下來是因為過程踩了太多雷想記錄一下 ,如果想要簡單一點的作法,建議採用 NodeJs + Chrome Headless + AWS Lambda 的解決方案,幾乎不用什麼調整即可使用。

NodeJs 版本參考: adieuadieu/serverless-chrome

情境

近期公司希望將訂單快照從 PhantomJS 改成 Chrome Headless 來實作,然後放到 AWS Lambda 上透過 AWS SQS 來觸發,達成 Serverless 的架構。

隨著 Chrome Headless 的推出,PhantomJS 也宣布停止更新了,想當年也跟它奮戰許久,又一滴時代的眼淚阿,而要能在 Lambda 上執行,則必須是能在 Linux 上執行的程式,所以 .Net Core 成為這次的選擇,為了不要每次改程式都要佈署到 Lambda 上才能測試(跑一次大概5分鐘),所以透過 Docker 模擬 AWS Lambda 的環境直接在本機測試。

環境

安裝

DotNet Core 2.1 : 下載
Docker for Windows : 下載

實作

因為大量使用到 AWS SDK,查找官方 API 文件會更清楚些,所以本篇不著重在程式內容本身,只會帶到自己覺得關鍵的地方。

建立 .Net Core 類別庫專案

請選擇 .Net Core > 類別庫 (.Net Core)

/images/20181011/1.png

安裝 AWS 相關套件

/images/20181011/2.png

實作 SQSHandler

因為是接收 SQS 來的命令,所以我建立了一支類別 SQSHandler ,並且寫了一個名為 Snapshot 的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 執行快照
/// </summary>
/// <param name="sqsEvent">The SQS event.</param>
/// <returns>執行結果</returns>
public async Task<ResponseEntity> Snapshot(SQSEvent sqsEvent)
{
...

var record = sqsEvent.Records.First();
var parameter = JsonConvert.DeserializeObject<SqsEventBodyEntity>(record.Body);

//// 拍照
snapshotService.Do(parameter.TSCode, parameter.SalePageId);

...
}

要接收 SQS 來的命令, AWS SDK 有提供 SQSEvent 類別,傳入自訂的參數會放在 Records 中,我們是將要拍照的參數用 Json 組起來後透過 SQS 拋進來,所以讀出 Records 後返解 Json String 為 Object ,然後拋給實作的SnapshotServiece。

Snapshot 這個方法回傳值為自定義的,可以依照自己需求調整,避免誤會所以特別說明一下

1
2
3
4
5
6
7
8
/// <summary>
/// 執行快照
/// </summary>
/// <param name="sqsEvent">The SQS event.</param>
/// <returns>執行結果</returns>
public bool Snapshot(SQSEvent sqsEvent)
{
}

Chrome Headless

什麼是 Chrome Headless ,簡單說就是透過無介面的方式要求Chrome執行一些快照、讀取頁面的服務。

為了能夠執行驅動 Chrome,所以這邊安裝 Selenium 來輔助

/images/20181011/3.png

Selenium 還需要搭配 Chorme Driver 來操作 Chrome,所以需要先去http://chromedriver.chromium.org/下載,但需要特別注意的是要下載 Linux64 的版本,因為 AWS Lambda 是 Linux 環境

/images/20181011/4.png

實作

這邊將下載回來的 Chrome Driver 放在 Root 的資料夾,所以在建立 ChromeDriver Instance 需要告訴它 Driver的位置

/images/20181011/5.png

1
2
3
4
5
6
7
8
9
//chrome headless的參數
ChromeOptions options = new ChromeOptions();
options.AddArgument("no-sandbox");
options.AddArgument("headless");

//取得Driver的位置
var root = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);

var driver = new ChromeDriver(root, options);

拍照

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...

//等待Timeout的設定
WebDriverWait _wait = new WebDriverWait(driver, new TimeSpan(0, 0, 10));

//要連的網址
driver.Navigate().GoToUrl(targetUrl);

//尋找要拍的Element Class
var targetElement = _wait.Until<IWebElement>(d => driver.FindElement(By.ClassName(targetElementClass)));

//因為要拍的區塊可能超過預設螢幕大小,所以這邊抓到Element後將它的寬高設定給Chrome Windows Size
driver.Manage().Window.Size = new Size(targetElement.Size.Width, targetElement.Size.Height);

// 拍照
Screenshot screenShot = ((ITakesScreenshot)driver).GetScreenshot();

//處理圖片存到哪邊,我是存到S3,但因為不是本篇重點所以省略後面的程式碼
var imgStream = new MemoryStream(screenShot.AsByteArray, 0, screenShot.AsByteArray.Length);

...

Docker 測試

打開 Power Shell,先確定已正確安裝 Docker

1
$ docker --version

/images/20181011/6.png

將目錄移到 .csproj 那層,然後執行

1
$ dotnet publish -c release -r linux-x64

dotnet publish command : 文件

如果發行成功應該可以在 \bin\release\netcoreapp2.1\linux-x64\publish 找到發行好的檔案

透過 Docker Image 測試執行

1
$ cat test.json | docker run -v ${PWD}/bin/QA/netcoreapp2.1/publish:/var/task/ -i -e DOCKER_LAMBDA_USE_STDIN=1 lambci/lambda:dotnetcore2.1 xxx.Slsa.Snapshot.Console::xxx.Slsa.Snapshot.Console.SqsHandler::Snapshot

解釋一下 Command

cat test.json : 這是拿來模擬 SQS 的呼叫時傳遞的參數檔案,檔案內容如下,我會一直替換Body 的值來測試程式是否正確去拍我要求拍的頁面,所以可以理解成是把檔案讀取後丟到後面的指令執行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"Records":[
{
"messageId":"xxxxxxxx",
"receiptHandle":"xxxxx",
"body":"{\"SalePageId\": 12345,\"TSCode\": \"abcde\"}",
"attributes":{
"ApproximateReceiveCount":"x",
"SentTimestamp":"xxxx",
"SenderId":"xxxx",
"ApproximateFirstReceiveTimestamp":"xxxxxx"
},
"messageAttributes":{
},
"md5OfBody":"xxxxx",
"eventSource":"aws:sqs",
"eventSourceARN":"xxxxxx",
"awsRegion":"xxxxxx"
}
]
}

docker run -v ${PWD}/bin/QA/netcoreapp2.1/publish:/var/task/ : ${PWD} 為預設變數,表示目前的目路位置,加上 /bin/QA/netcoreapp2.1/publish 後就是剛剛發行的檔案位置,mount 到 Docker container 裡面的 /var/task 位置,而這也是 lambda 預設執行的位置

lambci/lambda:dotnetcore2.1 : Docker Image 的名稱,這是別人已經做好跟 AWS lambda 一模一樣環境的 Image ,透過它來建置我們要的環境做測試

xxx.Slsa.Snapshot.Console::xxx.Slsa.Snapshot.Console.SqsHandler::Snapshot : 格式為 Assembly Name :: Class Name :: FunctionName,表示請它執行剛剛我們開發的 SQSHandler 的 Snapshot 方法

找不到 Chrome binary 錯誤

這時候應該會發生找不到 Chrome binary 的錯誤,原因是在 Lambda 的環境並不能安裝 Chrome,所以 Chrome Driver 想去預設環境找 Chrome 核心來執行時會找不到。

/images/20181011/7.png

整個驅動 Chrome 的流程

Selenum → Chrome Driver → Chrome binary

還好已經有網路上的大神解決了這問題,serverless-chrome這個 Project 就是將 Chrome 封裝成 Binary 檔後可以打包進專案中,在 Power Shell 執行以下指令來取得 Chrome Binary

1
2
3
$ docker run -dt --rm --name headless-chromium adieuadieu/headless-chromium-for-aws-lambda:stable
$ docker cp headless-chromium:/bin/headless-chromium ./
$ docker stop headless-chromium

在目前 Power Shell 指的資料底下可以找到 headless-chromium 檔案,將這個檔案加到專案中

/images/20181011/8.png

1
2
3
4
5
6
7
8
9
10
11
12
//chrome headless的參數
ChromeOptions options = new ChromeOptions();
options.AddArgument("no-sandbox");
options.AddArgument("headless");

//取得Driver的位置
var root = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);

//指定Chrome Binary位置
options.BinaryLocation = Path.Combine(root, "headless-chromium");

var driver = new ChromeDriver(root, options);

做到這邊再執行剛剛的指令應該可以正常拍照了。

掃雷

亂碼問題

因為 Chrome Binary 輕量化,所以作者並沒有把中文/日文/韓文包進來,所以拍一下遇到上述文字都會出現亂碼

依據該討論串下方的回應,已經有對應的解決方案

/images/20181011/9.png

1.將需要的字典檔下載回來放在 Root 的 .fonts 資料夾中

/images/20181011/10.png

2.將 $HOME 環境變數指到 /var/task

建立一個檔案 env.variable 裡面寫

1
HOME=/var/task

接著透過 Docker Container 執行

1
$ cat test.json | docker run --env-file ./env.variable -v ${PWD}/bin/QA/netcoreapp2.1/publish:/var/task/ -i -e DOCKER_LAMBDA_USE_STDIN=1 lambci/lambda:dotnetcore2.1 xxx.Slsa.Snapshot.Console::xxx.Slsa.Snapshot.Console.SqsHandler::Snapshot

這樣拍出來的中文、韓文、日文的畫面就不會亂碼了

權限

將程式打包好放到 AWS Lambda 上面跑,隨即碰到以下錯誤

1
2
3
4
5
6
7
8
9
10
11
12
Permission denied: Win32Exception
at Interop.Sys.ForkAndExecProcess(String filename, String[] argv, String[] envp, String cwd, Boolean redirectStdin, Boolean redirectStdout, Boolean redirectStderr, Boolean setUser, UInt32 userId, UInt32 groupId, Int32& lpChildPid, Int32& stdinFd, Int32& stdoutFd, Int32& stderrFd, Boolean shouldThrow)
at System.Diagnostics.Process.StartCore(ProcessStartInfo startInfo)
at System.Diagnostics.Process.Start()
at OpenQA.Selenium.DriverService.Start()
at OpenQA.Selenium.Remote.DriverServiceCommandExecutor.Execute(Command commandToExecute)
at OpenQA.Selenium.Remote.RemoteWebDriver.Execute(String driverCommandToExecute, Dictionary`2 parameters)
at OpenQA.Selenium.Remote.RemoteWebDriver.StartSession(ICapabilities desiredCapabilities)
at OpenQA.Selenium.Remote.RemoteWebDriver..ctor(ICommandExecutor commandExecutor, ICapabilities desiredCapabilities)
at OpenQA.Selenium.Chrome.ChromeDriver..ctor(ChromeDriverService service, ChromeOptions options, TimeSpan commandTimeout)
at .........

原因是 Lambda 的 /var/task 是 read-only,所以要執行 Chrome Driver 與 Chrome Binary 時會沒有權限,只好用很 tricky 的方式,先把 Chrome Driver 與 Chrome Binary 搬到 /tmp 底下再授予權限

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
/// <summary>
/// Isinitialeds this instance.
/// </summary>
private void Initial()
{
if(!File.Exists("/tmp/chromedriver"))
{
File.Copy("/var/task/chromedriver", "/tmp/chromedriver", true);
Exec("chmod +x /tmp/chromedriver");
}

if(!File.Exists("/tmp/headless-chromium"))
{
File.Copy("/var/task/headless-chromium", "/tmp/headless-chromium", true);
Exec("chmod +x /tmp/headless-chromium");
}
}

/// <summary>
/// Executes the specified command.
/// </summary>
/// <param name="cmd">The command.</param>
private void Exec(string cmd)
{
var escapedArgs = cmd.Replace("\"", "\\\"");
var process = new Process
{
StartInfo = new ProcessStartInfo
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
FileName = "/bin/bash",
Arguments = $"-c \"{escapedArgs}\""
}
};
process.Start();
process.WaitForExit();
}
1
2
3
4
5
6
7
8
9
//chrome headless的參數
ChromeOptions options = new ChromeOptions();
options.AddArgument("no-sandbox");
options.AddArgument("headless");

//指定Chrome Binary位置
options.BinaryLocation = "/tmp/headless-chromium";

var driver = new ChromeDriver("/tmp", options);

CreatePlatformSocket() Operation not permitted (1)

接著遇到下個問題是, ChromeDriver Init 起來時會去呼叫 http://localhost:xxxx/Session ,而這時後就會因為沒有權限而失敗,但這個在 NodeJs 的版本不會發生,查了 Selenium 的 Source Code 也看不出原因,找了相當多的討論串,目前似乎依然無解,也是在這一題後不得不先放棄,改採用相容較高的 NodeJs 版本

/images/20181011/11.png

2018-10-25 更新

在產生 ChromeDriver 的時候加上 disable-dev-shm-usagesingle-process 兩個參數可以解決這個問題,

1
2
3
4
5
options.AddArguments("no-sandbox", "headless", "disable-dev-shm-usage", "disable-gpu", "single-process", "no-zygote", "hide-scrollbars", "lang=zh-TW,zh");

options.BinaryLocation = "/tmp/headless-chromium";

var driver = new ChromeDriver("/tmp", options);

其中 single-process 為 Chromium 的模式之一,詳細可以參考文件

disable-dev-shm-usage

1
To fix, run the container with docker run --shm-size=1gb to increase the size of /dev/shm . Since Chrome 65, this is no longer necessary. Instead, launch the browser with the --disable-dev-shm-usage flag

會找到這個解法是因為去看了 serverless-chrome 的 source code 發現他是這樣寫的,但這樣搭配為什麼會正常,說實在的目前我還看不出為什麼 , 只能等之後讀更多文件再看看有沒有辦法解答。

加上這兩個參數後 Lambda 上面雖然還是會報 CreatePlatformSocket() Operation not permitted (1) 錯誤,但減少到只剩下兩次,就正常執行了

/images/20181011/12.png

補充 : 環境問題

最後雖然我們採用了 NodeJs 的版本,但還是踩了一個小小的雷,那就是要把檔案壓縮成 zip 檔放到 AWS Lambda 時,相同的程式用我的電腦壓出來是不能執行,但同事壓出來的 zip 檔案卻可以,經過測試後發現,因為我的 Mac 語系環境是中文,壓縮出來時預設會是封裝.zip,不管是中文的的放上去,或是改了檔名後放上去都會錯誤。

只能下 command 指定壓縮出來的檔名才可解決這個問題

1
$ zip -r -j ./destination/pacakge ./source

相同的,在 Windows 壓出來放上去一樣會壞掉,推測是相同原因….真的是雷到你不要不要的

2018-10-25 更新

網路上找到有人討論 Windows 壓縮後放上去會壞掉的問題,解法是請安裝 7zip,並且直接到 root 層執行壓縮,壓完後解壓就要是Root,不要再包一層資料夾,另外…只能用 GUI 執行壓縮,我用 PowerShell 壓縮一樣不行,不知道為什麼(暈),難怪論壇開宗明義第一句話就是,不要用 Windows ,Linux base 的東西跟 windows 真的是很不合…

/images/20181011/13.png