in Technology

ทดสอบ AWS Lambda Function บนเครื่องตัวเอง ด้วย Lambda Docker

จากที่เคยนำเสนอเครื่องมือตัวหนึ่งไปแล้วในบล็อก ทดสอบ .NET AWS Lambda ด้วย AWS .NET Mock Lambda Test Tool แต่ยังไม่หนำใจ เพราะผมพบว่ามันมีปัญหากับโค้ดที่เขียนแบบ Dependency Injection ซึ่งลองหาวิธี Debug แต่ก็ยังไม่เห็นอะไร เลยตัดใจลองหาเครื่องมือตัวอื่นดู ก็เจอ AWS SAM (AWS Serverless Application Model)

แต่ก็กระนั้นอีก ในหน้าเว็บโปรเจ็คบอกทำงานกับ .NET Core 2.1 ได้ แต่พอลองทำดู กลับบอกว่า ไม่รองรับ .NET Core Runtime!! (ใครแก้ปัญหาสองข้อบนของผมได้ มาแลกเปลี่ยนความรู้กันหน่อยครับ จักหายคาใจ) ก็เลยต้องแกะมัน พบว่า มันไปใช้ Lambda Docker เพื่อสร้าง Environment จำลองขึ้นมาสำหรับทดสอบ.. ปั๊ดโธ่ว แบบนี้ก็เสร็จโก๋!! จึงเป็นที่มาของบล็อกนี้..

คำแนะนำ: ผู้อ่านบล็อกนี้ควรใช้งาน AWS Lambda และ Docker เบื้องต้นแล้ว, ตัวอย่างใช้เป็น .NET Core 2.1 แต่สามารถนำวิธีใช้กับภาษาอื่นๆได้

Lambda Docker คืออะไร? ดีไหม?

เป็น Docker ที่จำลอง Environment ไว้เพื่อทดสอบ (Sandbox) ของ AWS Lambda ซึ่งเคลมตัวเองว่า ใกล้เคียงกับของจริงมากๆ

ถามว่าใกล้เคียงขนาดไหน? คำตอบคือใกล้มากจริงๆ ตามที่เขาโม้ไว้ เพราะภายใน image ของมันประกอบไปด้วย software, libraries, โครงสร้างของไฟล์ และ permissions, environment variables, context objects หรือแม้แต่ user สำหรับใช้เรียก process ก็ยังใช้ชื่อเดียวกัน

ด้วยความเหมือนเป๊ะ มันจึงเหมาะที่จะเอา Lambda Function มาทดสอบบน Docker Environment ที่มีข้อจำกัดคล้ายกับ AWS Lambda ได้เกือบ 100%, และที่สำคัญ มันปรับ Memory, Timeout และใช้ Layer ได้ด้วยนะ

มีข้อดีแล้วก็ต้องมีข้อเสีย ซึ่งตอนนี้ผมพบจุดเดียว คือ เรื่องของเวลาในการคำนวณ ซึ่งเอาไปอ้างอิงไม่ได้ เพราะไม่มี Cold Start และมันเป็น CPU บนเครื่องเรา, ถ้าจำทดสอบเพื่อไปคำนวณค่าใช้จ่าย ควรใช้เครื่องมืออีกตัวชื่อว่า AWS Lambda Power Tuning (ไว้จะเขียนถึงอีกที)

สิ่งที่ต้องติดตั้งก่อนใช้ Lambda Docker

ก่อนไปต่อ ควรติดตั้ง Docker ซะก่อนนะ (เช็คให้เรียบร้อยว่าไม่ติด Security Policy ทำงานได้ดี สามารถ Pull Image และ Run Container ได้)

เริ่มต้นใช้งาน Lambda Docker

รูปแบบของคำสั่งจะเป็นดังนี้

docker run [--rm] -v <code_dir>:/var/task [-v <layer_dir>:/opt] lambci/lambda:<runtime> [<handler>] [<event>]

การทำงานของมันคือ จะสร้าง Docker Container ขึ้นมา และเอาโค้ดของเราไปรันทดสอบ ด้วยตัวแปรต่างๆที่เราระบุไว้ใน Command แค่นั้นเอง

  • –rm คือเพื่อให้สร้าง Container มาทดสอบเพียงครั้งเดียว และทำลายตัวเองทิ้ง (ไม่ใส่ก็ได้ รันทำงานซ้ำไปได้เรื่อยๆ)
  • -v <code_dir>:/var/task คือ ชี้ที่อยู่ของโค้ดเรา ไปให้ตรงกับโฟลเดอร์ใน Container เพื่อใช้งาน
  • -v <layer_dir>:/opt คือ ชี้ที่อยู่ของ layer โค้ด ไปให้ตรงกับโฟลเดอร์ใน Container เพื่อใช้งาน (ถ้าไม่รู้จัก หรือไม่ได้ใช้ ก็ไม่ต้องใส่)
  • <runtime>  คือ Runtime Language ที่เราใช้เขียน Function เช่น dotnetcore2.1, nodejs4.3, python3.7, java8, go1.x
  • <handler> คือ Fucntion Handler
  • <event> คือ Request Input นั่นหละ

ทดสอบรันคำสั่งง่ายๆ

ตัวอย่าง Function ของผมชื่อ aws_lambda_function โดยรับ string เข้าไป จากนั้นจะพ่นออกมาว่า “Hello, <ชื่อ>” โดยมีรูปแบบคำสั่งคือ

docker run --rm -v "$PWD":/var/task lambci/lambda:dotnetcore2.1 aws-lambda-function::aws_lambda_function.Function::FunctionHandler '"iFew"'

เนื่องด้วยการทำงานมันจะ run code ที่ผ่านการ complie แล้ว, ซึ่งถ้าให้แน่ใจ ว่าไม่ได้รันอยู่บนโค้ดชุดเดิม ให้เราทำคำสั่ง compile ก่อนสักครั้ง จากนั้นไปที่โฟลเดอร์โค้ดที่ Compiled และทำคำสั่งด้านบนอีกที (ผมชี้ที่อยู่โค้ดไปที่ $PWD หมายถึง ให้ใช้โค้ดในโฟลเดอร์ปัจจุบันที่ผมอยู่)

ผลลัพธ์ที่ได้

ทดสอบรันคำสั่งโดยใส่ Environment Variable

จากตัวอย่างแรก สังเกตว่ามัน Allocate Memory ให้ถึง 1,536MB เลยทีเดียว (เป็น Default ของมัน) ซึ่งผมอยากกำหนดให้ใช้ Memory แค่ 128MB และ Timeout 30 วินาที เพื่อทดสอบการทำงาน ในสภาพแวดใกล้เคียงของจริง ด้วยคำสั่งนี้

docker run --rm -v "$PWD":/var/task -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 -e AWS_LAMBDA_FUNCTION_TIMEOUT=30 lambci/lambda:dotnetcore2.1  aws-lambda-function::aws_lambda_function.Function::FunctionHandler '"iFew"'

ซึ่ง ใช้ parameter ชื่อ -e ของ Docker นั่นหละเป็นตัวกำหนด โดยลัพลัพธ์ที่ได้ คือ

สังเกตว่า Memory ตอนนี้จัดสรรให้แค่ 128MB เท่านั้น ส่วน Timoue ยังไม่เห็นผลในตอนนี้เพราะโค้ดง่ายๆ ทำงานไว

ตัว environment ให้ใช้งานต่างๆ ดูเพิ่มเติมได้ที่ https://github.com/lambci/docker-lambda

ทดสอบรันคำสั่งโดยอ้างอิงกับ Database ภายนอก

การใช้งานจริงๆของเรา มันไม่ง่ายเหมือน Function ด้านบนแน่นอน ซึ่งผมเจอปัญหานี้เช่นกัน และใน Document ไม่ได้บอกไว้ว่าทำอย่างไร งมอยู่สักพัก ถึงเจอวิธี โดยการใช้ทำให้ Lambda Docker ของเราอยู่ในวง Network เดียวกับ Docker Database ที่ผมมีซะ (พอดีผมใช้ MySQL บน Docker, ถ้าใครใช้ MySQL เข้าใจว่าสามารถใช้งานได้เลย เนื่องจาก network เป็นแบบ bridge ที่ทำให้ Container เราต่ออินเทอร์เน็ตหรือวง Network ในเครื่องเราได้อยู่แล้ว)

ลองดูตัวอย่างคำสั่งนี้ครับ

docker run --rm -v "$PWD":/var/task --network mysql -e TEST_LAMBDA_DBCONNECTION="server=mysql;userid=root;password=1234;database=test_lambda;convert zero datetime=True; CharSet=utf8" -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 lambci/lambda:dotnetcore2.1 list_profile::list_profile.Function::Get

จะสังเกตว่ามีตัวแปรแปลกๆมานิดนึง คือ –network mysql หมายถึง ให้ Container นี้อยู่ในวง Network ชื่อ mysql โดยในวงนั้นผมมี Container MySQL อยู่ในนั้นอยู่แล้ว

จากนั้นผมระบุ Environment Variable ชื่อว่า TEST_LAMBDA_DBCONNECTION เข้าไป เนื่องจากโค้ดของผมเรียกใช้ config ผ่าน Environment Variable (ตามหลักการข้อ 3. Config ใน The Twelve Factor)

ผลลัพธ์ที่ได้คือ

แถมนิดนึง วิธีการสร้าง Docker Network

คำสั่งง่ายๆ คือ

docket create network <ชื่อnetworkที่ต้องการ>

แค่นี้ก็จะได้วง Network มาแล้วครับ โดยในตัวอย่างผมใช้ชื่อวงว่า mysql

ดังนั้น หากเรามี Container อยู่แล้ว ให้เพิ่มเข้าวง Network ด้วยคำสั่ง

docker network connect <ชื่อnetwork> <ชื่อcontainer>

ทดสอบรันคำสั่งโดยส่ง Request Input ด้วย APIGatewayProxy (หรือรันคำสั่งแบบมี JSON Input ยาวๆ)

โดยปกติผมจะใช้ AWS API Gateway อยู่แล้ว ดังนั้นตัว Request Input ผมจะชอบใช้ในรูปแบบของ API Gateway Proxy Request เพราะมันสามารถส่ง Request Input ได้ทั้งแบบ Path Parameter, Query String, Header, Body ซึ่งลดควมซับซ้อน ไม่ต้องไปทำการ Mapping Input Data อะไรให้ยุ่งยากใน AWS API Gateway ซึ่งถ้าคุณใช้เหมือนกัน ก็สามารถเอาตัวอย่างนี้ไปทดสอบกับ Lambda Docker ได้ครับ

โดยตัว API Gateway Proxy Request เนื้อมันจริงๆคือ JSON ที่ AWS API Gateway รับเข้าไป จากนั้นมันจะกระทำการ Mapping นั่นโน่นนี่ให้เอง และสร้าง Request ตามที่เรากำหนดใน JSON นั้นไปทำงานกับ Function เราอีกที ซึ่งรูปแบบของ JSON มันจะยาวมากๆ ครับ ดังนั้น การส่ง Input ยาวๆ เราสามารถทำได้สามแบบ

ในตัวอย่างแรก สามารถใส่ event ต่อท้ายได้ตามรูปแบบมาตรฐานของมันเลยครับ

docker run --rm -v "$PWD":/var/task --network mysql -e TEST_LAMBDA_DBCONNECTION="server=mysql;userid=root;password=1234;database=test_lambda;convert zero datetime=True; CharSet=utf8" -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 lambci/lambda:dotnetcore2.1 get_profile::get_profile.Function::Get '{"resource":"/{proxy+}","path":"/","httpMethod":"GET","headers":null,"queryStringParameters":null,"pathParameters":{"id":"1"},"stageVariables":null,"requestContext":{"accountId":"AAAAAAAAAAAA","resourceId":"5agfss","stage":"test-invoke-stage","requestId":"test-invoke-request","identity":{"cognitoIdentityPoolId":null,"accountId":"AAAAAAAAAAAA","cognitoIdentityId":null,"caller":"BBBBBBBBBBBB","apiKey":"test-invoke-api-key","sourceIp":"test-invoke-source-ip","cognitoAuthenticationType":null,"cognitoAuthenticationProvider":null,"userArn":"arn:aws:iam::AAAAAAAAAAAA:root","userAgent":"Apache-HttpClient/4.5.x (Java/1.8.0_102)","user":"AAAAAAAAAAAA"},"resourcePath":"/{proxy+}","httpMethod":"GET","apiId":"t2yh6sjnmk"},"body":null}'

ตัวอย่างสอง สามารถใส่ event ด้านหน้า ในรูปแบบ stdin ได้

โดยใช้การ echo ค่า JSON ที่เราต้องการส่งออกไป จากนั้นให้ ใส่ | คั่นคำสั่งรัน Container เพื่อรับไปใช้ พร้อมกับระบุ parameter เพิ่มเติมอีกหน่อยคือ

-i -e DOCKER_LAMBDA_USE_STDIN=1

ตัวอย่าง

echo '{"resource":"/{proxy+}","path":"/","httpMethod":"GET","headers":null,"queryStringParameters":null,"pathParameters":{"id":"1"},"stageVariables":null,"requestContext":{"accountId":"AAAAAAAAAAAA","resourceId":"5agfss","stage":"test-invoke-stage","requestId":"test-invoke-request","identity":{"cognitoIdentityPoolId":null,"accountId":"AAAAAAAAAAAA","cognitoIdentityId":null,"caller":"BBBBBBBBBBBB","apiKey":"test-invoke-api-key","sourceIp":"test-invoke-source-ip","cognitoAuthenticationType":null,"cognitoAuthenticationProvider":null,"userArn":"arn:aws:iam::AAAAAAAAAAAA:root","userAgent":"Apache-HttpClient/4.5.x (Java/1.8.0_102)","user":"AAAAAAAAAAAA"},"resourcePath":"/{proxy+}","httpMethod":"GET","apiId":"t2yh6sjnmk"},"body":null}' | docker run --rm -v "$PWD":/var/task -i -e DOCKER_LAMBDA_USE_STDIN=1 --network mysql -e TEST_LAMBDA_DBCONNECTION="server=mysql;userid=root;password=1234;database=test_lambda;convert zero datetime=True; CharSet=utf8" -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 lambci/lambda:dotnetcore2.1 get_profile::get_profile.Function::Get

ตัวอย่างสาม สามารถใส่ event ด้านหน้า ในรูปแบบ stdin ได้ โดยเรียกใช้ข้อมูลจากไฟล์

แบบนี้เหมือนกับด้านบน เพียงแต่ผมแปลงนิดหน่อย ให้พ่นข้อมูลจากไฟล์แทน โดยใช้คำสั่ง cat เช่น

cat /Users/chitpong/Sourcecode/aws-serverless/profile/test/get_profile.Tests/SampleRequests/TestGetMethod.json | docker run --rm -v "$PWD":/var/task -i -e DOCKER_LAMBDA_USE_STDIN=1 --network mysql -e TEST_LAMBDA_DBCONNECTION="server=mysql;userid=root;password=1234;database=test_lambda;convert zero datetime=True; CharSet=utf8" -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 lambci/lambda:dotnetcore2.1 get_profile::get_profile.Function::Get

ในที่นี้ผมใช้ JSON ไฟล์เดียวกันกับที่ผมเขียนทดสอบใน Unit Test

สรุป

เป็นรุปแบบการทดสอบ AWS Lambda แบบที่ไม่เสียเงินอีกรูปแบบหนึ่ง และค่อนข้างทำงานได้เหมือนจริงตามที่เราต้องการ ซึ่งถ้าใช้งานบ่อยๆ การทำคำสั่งแบบนี้อาจจะยาวและเสียเวลาหน่อย ก็ให้ไปเขียนเป็น shell script หรือ makefile เพื่อรันก็จะไวขึ้นอีกนิด

ทั้งนี้ทั้งนั้น ผมก็ยังยืนยันแบบเดิมว่า ทดสอบด้วยการเขียน Unit Test เถอะครับ

Reference

  • https://github.com/lambci/docker-lambda
  • https://github.com/lambci/docker-lambda/issues/23