在這篇文章裏我想介紹下怎樣利用AWS(hjlouyoujuqi360com)部署一個無服務架構的個人網站。這個個人網站將具備以下特點:
- 包含前端和後端;
- 基本上以靜態文件爲主,或者主要的計算都在前端(比如React應用);
- 與後臺通過API通信,但數量非常少;
- 後臺不需要太大內存或CPU(wwwya-jucom比如一個簡單的網頁計數器,每次請求只需要訪問一次數據庫)。
服務將部署到以下域名上(這裏用的都是假想的域名):
- API服務:
- 前端:
這裏用了HTTPS,因爲各大瀏覽器早已開始將HTTP協議標記爲不安全協議了。爲了保證安全,HTTPS是必要的,後面會介紹如何設置證書等。
整個網站將使用以下的AWS服務:
- Lambda + API Gateway + S3,用於跑API服務器;
- DynamoDB,數據存儲;
- S3,靜態網站;
- Cloudfront,分佈式CDN,用作靜態網站和API的前端;
- 後臺不需要太大內存或CPU(比如一個簡單的網頁計數器,每次請求只需要訪問一次數據庫)。
網站生成證書。
至於API服務器的開發部署,我們採用Python + Flaskwwwya-jucom
的組合開發服務,然後用Zappa(https://github.com/Miserlou/Zappa)作爲無服務器部署工具。
設置AWS環境
首先需要設置AWS環境,以便從代碼和zappa中訪問AWS。需要兩個步驟:
- 創建AWS用戶,用於程序訪問;
- 設置本地環境,使代碼使用AWS用戶。
創建AWS用戶
登錄到AWS中,選擇“IAM”服務來管理用戶。
創建一個名爲“myservice-admin”的用戶(或者任何你喜歡的用戶名),勾選“Programmatic access”選項。
在下一步中,點擊“Attach existing policies directly”按鈕,然後將“AdministratorAccess”添加到該用戶。
從安全的角度來說這種做法並不好。不過出於演示的目的,本文不再詳述怎樣找出部署無服務架構所需的權限了。
點擊“Next”按鈕,最後點擊“Create User”按鈕,myservice-admin(wwwya-jucom)
用戶就建好了。注意在創建成功的那個畫面上會顯示Access Key ID和Secret access key兩個值。務必要將這兩個複製保存下來,稍後要用它們來設置本地環境。
這個畫面是唯一能看到Secret access key的地方!如果你忘了複製就關閉了頁面,那就只能去用戶的詳細畫面去生成新的access key和secret了。
設置本地AWS環境
爲了在本地使用AWS,我們需要創建本地環境。
首先安裝awscli工具,用它來幫我們配置環境:
1$ sudo apt install awscli
安裝結束後,就可以使用aws configure命令進行設置:
1$ aws configure 2AWS Access Key ID [None]: ****** 3AWS Secret Access Key [None]: ****** 4Default region name [None]: us-east-1 5Default output format [None]: json
這裏需要輸入上一步保存下來的Access Key ID和Secret Access Key值。至於區域,我用的是us-east-1。其他區域應該也可以,但如果你要像我一樣使用CloudFront(wwwbeigefushicom)的話,其他區域可能會有一些麻煩。
在DynamoDB中創建表
我們的後臺API要實現一個計數器。爲了保存計數器的數值,我們需要使用DynamoDB。DynamoDB是AWS提供的一個鍵值數據庫。首先我們需要在DynamoDB中建一個表,並設置好我們需要的計數器初始值。
在AWS控制檯中選擇DynamoDB服務,然後點擊“Create Table”按鈕。在“Create DynamoDB table”畫面,在Table name中填寫myservice-dev,Primary key字段填寫id,然後點擊Create Table按鈕。
幾秒鐘之後表就建好了。選擇剛剛建好的表,然後在右側選擇Items選項卡,單擊Create item按鈕創建一個項目,項目內容爲id='counter'及counter_value=0。
創建值時需要點擊左側的加號按鈕才能添加counter_value屬性,而且別忘了把counter_value屬性的類型設置爲Number。
創建API服務
接下來我們要建立API服務。(wwwbeigefushicom)
這個API將提供一個計數器API,每次調用都會將計數器的值加一。計數器值保存在DynamoDB中。API的endpoint如下:
- POST /counter/increase:增加計數器的值,並返回計數器值;
- GET /counter:返回計數器值。
用Python和Flask編寫API服務
首先我們要創建Python虛擬環境,並安裝必要的包:
1$ mkdir myservice && cd myservice 2$ python3 -m venv .env 3$ source .env/bin/active 4(.env)$ pip install flask boto3 simplejson
Flask是Web框架,boto3是訪問DynamoDB必須的包。simplejson可以解決一些JSON轉換時遇到的問題。接下來創建myservice.py,內容如下:
1import boto3 2from flask import Flask, jsonify 3app = Flask(__name__) 4# Initialize dynamodb access 5dynamodb = boto3.resource('dynamodb') 6db = dynamodb.Table('myservice-dev') [email protected]('/counter', methods=['GET']) 8def counter_get(): 9 res = db.get_item(Key={'id': 'counter'}) 10 return jsonify({'counter': res['Item']['counter_value']}) [email protected]('/counter/increase', methods=['POST']) 12def counter_increase(): 13 res = db.get_item(Key={'id': 'counter'}) 14 value = res['Item']['counter_value'] + 1 15 res = db.update_item( 16 Key={'id': 'counter'}, 17 UpdateExpression='set counter_value=:value', 18 ExpressionAttributeValues={':value': value}, 19 ) 20 return jsonify({'counter': value})
再創建一個run.py,以便在本地測試該服務:
1from myservice import app 2if __name__ == '__main__': 3 app.run(debug=True, host='127.0.0.1', port=8000)
運行服務:
1(.env)$ python run.py
這樣就可以在命令行中測試這個服務了(再開一個終端輸入下面的命令):
1$ curl localhost:8000/counter 2{ 3 "counter": 0 4} 5$ curl -X POST localhost:8000/counter/increase 6{ 7 "counter": 1 8} 9$ curl -X POST localhost:8000/counter/increase 10{ 11 "counter": 2 12} 13$ curl localhost:8000/counter 14{ 15 "counter": 2 16}
我們可以看到計數器的值增加了,說明這個服務可以用了!
將服務部署到Lambda上
要部署API到Lambda上,可以使用Zappa包。Zappa包使得部署微服務變得極其容易。首先安裝Zappa:
1(.env)$ pip install zappa
然後執行Zappa init命令初始化Zappa環境。它會問你幾個問題,但基本上可以使用默認值來回答:
1(.env)$ zappa init 2... 3What do you want to call this environment (default 'dev'): 4... 5What do you want to call your bucket? (default 'zappa-ab7dd70x5'): 6It looks like this is a Flask application. 7What's the modular path to your app's function? 8This will likely be something like 'your_module.app'. 9We discovered: myservice.app 10Where is your app's function? (default 'myservice.app'): 11... 12Would you like to deploy this application globally? (default 'n') [y/n/(p)rimary]: 13Okay, here's your zappa_settings.json: 14{ 15 "dev": { 16 "app_function": "myservice.app", 17 "aws_region": "us-east-1", 18 "profile_name": "default", 19 "project_name": "myservice", 20 "runtime": "python3.6", 21 "s3_bucket": "zappa-ab7dd70x5" 22 } 23} 24Does this look okay? (default 'y') [y/n]: 25...
初始化完成後,在目錄下會生成一個zappa_settings.json文件。然後就可以部署服務了:
1(.env)$ zappa deploy dev 2Calling deploy for stage dev.. 3... 4Deployment complete!: https://2ks1n5nrxh.execute-api.us-east-1.amazonaws.com/dev
現在我們的服務就部署成功了。可以用下面的Curl命令測試,也可以打開瀏覽器測試GET的API:
1(.env)$ curl https://2ks1n5nrxh.execute-api.us-east-1.amazonaws.com/dev/counter 2{"counter":2} 3(.env)$ curl -X POST https://2ks1n5nrxh.execute-api.us-east-1.amazonaws.com/dev/counter/increase 4{"counter":3} 5(.env)$ curl https://2ks1n5nrxh.execute-api.us-east-1.amazonaws.com/dev/counter 6{"counter":3}
綁定自定義域名
不過上面的API服務還有一個小問題。自動生成的API endpoint是2ks1n5nrxh.execute-api.us-east-1.amazonaws.com,很難記也不好用。不過我們可以很容易地給它綁定一個自定義域名。
我們的自定義域名是https://myservice-api.example.com。爲了使用HTTPS,我們需要現申請一個證書。AWS的Certificate Manager服務提供免費的證書。生成證書之後就可以在AWS的API Gateway裏自定義域名了。
申請證書
從AWS控制檯切換到ACM服務(服務名稱叫Certificate Manager,但敲ACM就能搜索到)。點擊Request a certificate按鈕,然後選擇Request a public certificate選項。選擇公開的證書就是免費的。
下個畫面要輸入證書的域名。這裏我們申請了*.example.com,這樣證書就能用在example.com下的所有子域名上。以後我們給前臺的myfrontend.example.com添加https時就不用再申請證書了。
下一步,我們需要向AWS證明我們擁有這個域名。我這個域名是從Google Domains申請的,所以我在這裏選擇DNS validation。點擊Review按鈕然後點擊Confirm and Request。
現在證書請求已經生成了,AWS會顯示一個驗證畫面,上面寫明瞭怎樣驗證該域名:
根據說明,我們需要在域名下添加一條CNAME記錄。由於我的域名是從Google Domains申請的,我就打開Google Domains,找到域名example.com,然後添加上面指定的CNAME:
這裏在Name欄中只添加了_2adee19a0967c7dd5014b81110387d11字符串,去掉了後面的.example.com部分,否則.example.com就重複了。
接下來要等待大約10分鐘,AWS Certificate Manager就會去驗證域名了。驗證成功後,Status欄會顯示“Issued”。
現在證書已經申請好了,我們可以繼續去給API綁定域名。
爲API服務綁定自定義域名
切換到API Gateway服務。從左側的APIs一欄可以看到,Zappa已經幫我們建好了myservice-dev服務。
從左側點擊“Custom Domain Names”,然後點擊右側的Create Custom Domain Name按鈕,填寫必要的字段。
這裏我希望API使用CloudFront服務,這樣能在全世界都達到最理想的訪問速度,因此我選擇了Edge Optimized。如果不使用CloudFront,你可以選擇Regional。
點擊下面的“Add mapping”鏈接,然後選擇myservice-dev作爲Destination,再從最右邊的方框中選擇dev。這樣做的目的是訪問API時無需在URL中指定環境名稱dev。Path字段留空。
點擊Save按鈕後,這個自定義域名綁定就建好了。實際上要等待大約40分鐘左右域名綁定才能正常使用,不過我們可以利用這段時間去配置DNS。
從上面的圖中可以看出,API服務的實際域名爲dgt9opldriaup.cloudfront.net(因爲我選擇了CloudFront服務)。因此需要在DNS中添加一條CNAME,將myservice-api.example.com指向上面的CloudFront子域名dgt9opldriaup.cloudfront.net。
回到Google Domains添加這條CNAME:
該步驟完成後,等待大約40分鐘,等API Gateway中的“Initializing...”字樣消失後,自定義域名就可以使用了。
1(.env)$ curl https://myservice-api.example.com/counter 2{"counter":3} 3(.env)$ curl -X POST https://myservice-api.example.com/counter/increase 4{"counter":4} 5(.env)$ curl https://myservice-api.example.com/counter 6{"counter":4}
前端的靜態網站
接下來我們要給這個API服務創建一個前端。作爲例子,這裏只創建一個非常簡單的頁面,它能調用/counter/increase。
前端編程
先建一個目錄myfrontend:
1$ mkdir myfrontend && cd myfrontend
然後建一個簡單的HTML文件index.html:
1<html> 2<body> 3 <h1>Welcome to my homepage!</h1> 4 <p>Counter: <span id="counter"></span></p> 5 <button id="increase">Increase Counter</button> 6 <script> 7 const setCounter = (counter_value) => { 8 document.querySelector('#counter').innerHTML = counter_value; 9 }; 10 const api = 'https://myservice-api.example.com'; 11 fetch(api + '/counter') 12 .then(res => res.json()) 13 .then(result => setCounter(result.counter)); 14document.querySelector('#increase') 15 .addEventListener('click', () => { 16 fetch(api + '/counter/increase', { method: 'POST' }) 17 .then(res => res.json()) 18 .then(result => setCounter(result.counter)); 19 } 20 ); 21 </script> 22</body> 23</html>
將前端發佈到S3
我們可以把前端部署到S3上。首先需要建一個桶,桶的名字就是域名。
從AWS控制檯中切換到S3服務。由於我們要建立的靜態網站域名爲myfrontend.example.com,我們要建一個同名的桶。點擊Create Bucket按鈕,填入桶的名稱,然後點擊Next直到桶建好。
接下來要把我們的網站放到這個桶中。打開該桶,選擇Properties選項卡,然後選擇Static Web Hosting。在彈出的對話框中選擇Use this bucket to host a website,在Index document字段中輸入index.html。點擊Save關閉對話框。
上面顯示了“Endpoint”鏈接,我們稍後會用這個URL測試靜態網站。
最後一件事就是讓這個桶允許公開訪問。我們需要添加一個桶策略來實現這一點。打開這個桶,選擇Permissions選項卡,然後點擊Bucket Policy按鈕。
輸入下面的內容作爲策略,然後點擊Save按鈕(別忘了把myservice.example.com換成你自己的域名):
1{ 2 "Version": "2012-10-17", 3 "Statement": [ 4 { 5 "Sid": "PublicReadGetObject", 6 "Effect": "Allow", 7 "Principal": "*", 8 "Action": "s3:GetObject", 9 "Resource": "arn:aws:s3:::myfrontend.example.com/*" 10 } 11 ] 12}
保存之後,我們應該可以在Bucket Policy按鈕上以及Permissions選項卡上看到橙色的“public”字樣,表明我們的桶是可以被公開訪問的。
這樣桶就建好了,但裏面還是空的,現在需要把網站的內容上傳到這個桶中。首先進入剛纔建好的myfrontend目錄中,然後輸入下面的命令:
1# Make sure you are in the `myfrontend` directory... 2$ aws s3 sync . s3://myfrontend.example.com
上面的命令會把當前目錄下(注意命令中的那個點 . )的所有文件都上傳到S3中。
現在就完成了!在瀏覽器中打開下面的地址就可以看到網站內容了(地址就是前面創建桶時顯示的Endpoint的URL):
http://myfrontend.example.com.s3-website-us-east-1.amazonaws.com/
嗯?貌似不太對。計數器沒有顯示任何值呢?
而且似乎有JavaScript錯誤。打開瀏覽器的控制檯就能看到以下錯誤:
Failed to load https://myservice-api.example.com/counter: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://myfrontend.example.com.s3-website-us-east-1.amazonaws.com' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
顯然,我們需要設置CORS header(https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)才能讓這個腳本工作,因爲後臺API被放到了另一個域名上(myservice-api.example.com和myfront.example.com不是同一個域名)。
不過由於我們還要給前端綁定自定義域名,綁定後URL會發生變化,所以這裏先放一放,等一會兒綁定好域名之後再來考慮CORS的問題。
給靜態網站設置CloudFront和自定義域名
最後一步就是給前端設置CloudFront並綁定自定義域名。前面我們已經申請了*.example.com的證書,所以這一步就很容易了。
從AWS控制檯中切換到CloudFront服務。點擊Create Distribution按鈕,然後點擊Web裏的Start按鈕。
在“Create Distribution”畫面上,我們需要填寫以下信息:
- 點擊Origin domain name輸入框,選擇剛纔的S3桶myfrontend.example.com.s3.amazonaws.com;
- 將Viewer Protocol Policy改成Redirect HTTP to HTTPS,以強制https訪問;
- 在Alternate Domain Names輸入框中輸入自定義域名。這裏我們輸入myfrontend.example.com;
- 向下滾動到SSL Certificate部分,選擇“Custom SSL Certificate”,然後選擇之前的*.example.com證書;
- 將Default Root Object設置成index.html。
創建好distribution後,就可以在distribution列表中看到CloudFront的域名了。
上面的狀態還是“In Progress”,我們可以利用這段時間去設置DNS。跟前面類似,去Google Domains裏添加一個CNAME:
等到CloudFront裏的distribution的狀態變成Deployed之後,就可以打開瀏覽器訪問myfrontend.example.com。應該能看到我們的靜態網站了!
解決CORS問題
現在唯一的問題就是CORS了。CORS是由於前端和後臺的域名不一致導致的,爲了讓前端能訪問後臺API,我們需要給後臺添加CORS支持。
回到API的代碼目錄(myservice),激活Python環境。然後安裝flask_cors包:
1$ cd myservice 2$ source .env/bin/activate 3(.env)$ pip install flask_cors
然後編輯myservice.py,添加以下幾行(3和6):
1import boto3 2from flask import Flask, jsonify 3from flask_cors import CORS 4 5app = Flask(__name__) 6CORS(app, origins=['https://myfrontend.example.com'])
最後發佈到AWS Lambda:
試着刷新下瀏覽器。現在就能看到計數器顯示了正確的值。點擊“Increase Counter”按鈕也能增加計數器的值了。
總結
這篇文章介紹了創建一個簡單的無服務器服務所需的多種AWS服務。如果你對AWS不熟悉,你可能會覺得我們用到了太多的服務,但其實絕大部分AWS服務都是一次性的,一旦設置好之後就不用再管了。以後的開發中用得上的只有zappa update和aws s3 sync兩條命令而已。
而且至少,這種方法要比自己設置一臺VPS、安裝Web服務器再寫個Jenkins腳本做持續部署要方便多了。
作爲總結,下面是這篇文章的一些重點:
- Lambda可以運行簡單的服務,服務可以通過API Gateway暴露成HTTP服務;
- 如果要用Python寫無服務器服務,那麼Zappa是個非常方便的工具;
- S3桶可以用作靜態網站使用;
- 要想使用HTTPS,可以通過AWS ACM申請證書;
- API Gateway和CloudFront都支持自定義域名。(wwwbeigefushicom)