這一個月來忙完 Mapleboard 主機上的 Nginx+Gunicorn+Flask 網站架構配置與安全防護後, 終於可以進行應用程式開發與佈署了.
本系列全部的測試紀錄參考 :
今天在下面這本書的最後一章看到作者使用 Google Cloud Functions (GCF) 的 serverless 函式服務作為 LINE Bot 的 webhook, 我突然想到何不在 Mapleboard 主機上打造一個簡化版的 GCF 服務呢? 現在 Vibe coding 正夯, 透過與 AI 協作應該可以快速實現.
# 一本精通-LINE BOT+Python+Google Dialogflow 完整掌握LINE BOT的開發技巧 打造全方位AI機器人 (深智, 張宗彥, 2023)
Google Cloud Functions 是 Google 雲端服務平台上的一個無伺服器 (serverless) 執行環境, 允許開發者撰寫獨立且輕量的函式 (Function), 可連結 Google Cloud 各種事件並在雲端自動回應, 也可透過 HTTP 介面呼叫函式, 使用者無需管理伺服器或基礎設施, Google 會自動負責資源調度與彈性擴展, 並依實際執行時間計費, 是成本效益非常高的一種雲端服務, 非常適合 Webhook, 輕量級 API, 行動裝置後端服務, 物聯網設備資料處理等合事件驅動與有即時運算需求的微服務場景.
但我沒必要複製一個完整的 GCF 功能, 我只要能達成下列目標即可 :
- 每個 endpoint 都像一個獨立的 function
- 只需寫 function 就能對外提供 HTTP API
- 模組只要放入 ~/functions/ 即可透過 HTTP 呼叫執行
- 可以透過簡單的部署方式新增/更新函式
以下就從最簡單的雛型結構開始一步一步搭建系統, 漸進優化與增加功能.
1. 系統架構雛形 :
在前面的測試中, 我已在 Mapleboard 的 Ubuntu Mate 上安裝好 Nginx+Gunicorn+Flask 執行環境, 而且註冊了 https://tony196cc.cc 網域與 https://flask.tony1966.cc 子網域, 並於使用者根目錄下建立了 flask_apps 子目錄來存放所有 Flask 應用程式, 我打算將這個仿 GCF 函式執行平台放在 serverless 子目錄下, 要執行的函式都放在下一層的 functions 子目錄中.
此系統的初步結構如下圖所示 :
/home/tony1966/flask_apps/serverless/
├── serverless.py
└── functions/
├── __init__.py
└── hello.py
注意, 由於要從 functions 資料夾動態載入模組, 所以它底下必須有一個空白的 __init__.py, 這樣 functions 才會被視為一個套件, 否則載入模組時會出現錯誤.
# __init__.py
# 此為讓 functions 目錄被視為一個套件的空模組
其次, 每一個要執行的函式都要有一個接收 request 物件的 main() 函式, 其內容就是此模組要執行的邏輯, 例如 :
# hello.py
def main(request):
name=request.args.get('name', 'World') # 從 request 物件取得 name 參數 (預設值 'World')
return f'Hello {name}!'
所有要呼叫的函式模組都放在 ~/flask_apps/serverless/functions/ 下 :
此系統的主程式 serverless.py 則是利用 Python 內建的 importlib 模組依據 HTTP 的 URL 路徑來動態載入 functions 子目錄下要執行的模組, 程式碼如下 :
# serverless.py
from flask import Flask, request, jsonify
import importlib
import os
app=Flask(__name__)
FUNCTION_DIR='functions' # 函式模組目錄
# 定義路由裝飾器
@app.route('/function/<func_name>', methods=['GET', 'POST'])
def run_function(func_name): # 傳入函式模組名稱
try:
module_path=f'{FUNCTION_DIR}.{func_name}' # 模組路徑 functions.函數名稱
mod=importlib.import_module(module_path) # 動態載入要求之函式模組
if hasattr(mod, 'main'): # 檢查模組中是否有 main() 函式
return mod.main(request) # 執行 main() 函式並傳回結果
else: # 沒有 main() 函式傳回 400 錯誤
return 'Function must define a `main(request)` method', 400
except Exception as e: # 出現異常傳回 500 錯誤
return f'Error: {e}', 500
此系統目前只有一個路由裝飾器, 用來將 https://flask.tony1966.cc/function/<func_name> 的網址綁定到 run_function() 函式上. 在 run_function() 中則式呼叫 importlib.import_module() 來載入路徑為 functions.<func_name> 的模組 (等同於 import functions.<func_name> 之效果), 然後檢查函式有無 main() 函式, 有就呼叫它並傳回結果, 否則傳回 500 錯誤訊息.
2. 啟動 Gunicorn 應用伺服器 :
上面已將 HTTP API 函式執行平台的原型架構準備好, 就可以開始來運行它了, 先切換到主程式 serverless.py 所在的 serverless 子目錄下 :
tony1966@LX2438:~$ cd flask_apps
tony1966@LX2438:~/flask_apps$ cd serverless
tony1966@LX2438:~/flask_apps/serverless$ ls -ls
總用量 8
4 drwx------ 2 tony1966 tony1966 4096 Jul 25 14:08 functions
4 -rw-rw-r-- 1 tony1966 tony1966 851 Jul 24 23:38 serverless.py
用下列指令啟動 Gunicorn 伺服器程序來執行主程式中的 app 物件 :
gunicorn -w 4 -b 127.0.0.1:8000 serverless:app
tony1966@LX2438:~/flask_apps/serverless$ gunicorn -w 4 -b 127.0.0.1:8000 serverless:app
[2025-07-25 15:21:47 +0800] [686727] [INFO] Starting gunicorn 23.0.0
[2025-07-25 15:21:47 +0800] [686727] [INFO] Listening at: http://127.0.0.1:8000 (686727)
[2025-07-25 15:21:47 +0800] [686727] [INFO] Using worker: sync
[2025-07-25 15:21:47 +0800] [686728] [INFO] Booting worker with pid: 686728
[2025-07-25 15:21:47 +0800] [686729] [INFO] Booting worker with pid: 686729
[2025-07-25 15:21:47 +0800] [686730] [INFO] Booting worker with pid: 686730
[2025-07-25 15:21:47 +0800] [686731] [INFO] Booting worker with pid: 686731
按 Ctrl + C 跳出被占用的終端機, 這會自動停止手動啟動的 Gunicorn 程序. 如果按 Ctrl +C 無反應, 可以另開一個終端機, 用下列指令檢視有哪些 Gunicorn 程序在運行 :
ps aux | grep gunicorn
tony1966@LX2438:~/flask_apps/serverless$ ps aux | grep gunicorn
tony1966 223187 0.0 0.1 34156 5268 ? Ss Jul06 9:51 gunicorn: master [hello:app]
tony1966 223192 0.0 0.3 42480 12952 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 223193 0.0 0.3 42476 13364 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 223194 0.0 0.3 42476 15452 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 223195 0.0 0.4 42476 15796 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 686727 0.0 0.5 34088 20576 pts/1 S+ 15:21 0:01 gunicorn: master [serverless:app]
tony1966 686728 0.0 0.6 41152 26360 pts/1 S+ 15:21 0:00 gunicorn: worker [serverless:app]
tony1966 686729 0.0 0.6 41156 26356 pts/1 S+ 15:21 0:00 gunicorn: worker [serverless:app]
tony1966 686730 0.0 0.6 41156 26352 pts/1 S+ 15:21 0:00 gunicorn: worker [serverless:app]
tony1966 686731 0.0 0.6 41156 26376 pts/1 S+ 15:21 0:00 gunicorn: worker [serverless:app]
tony1966 686904 0.0 0.0 9136 2024 pts/0 S+ 15:59 0:00 grep --color=auto gunicorn
此處 PID 後面的 pts/0 或 pts/1 (關聯當前使用者的 shell) 以及 S+ (在前景執行的子行程) 表示這些程序都是手動運行的; 而 ? (沒有關聯終端機) 與 S 或 Ss (daemon 狀態) 表示為 Systemd 系統服務所啟動. 可以指定應用程式名稱手動刪除程序, 例如 :
pkill -f "gunicorn.*serverless:app"
tony1966@LX2438:~/flask_apps/serverless$ pkill -f "gunicorn.*serverless:app"
tony1966@LX2438:~/flask_apps/serverless$ ps aux | grep gunicorn
tony1966 223187 0.0 0.1 34156 5268 ? Ss Jul06 9:52 gunicorn: master [hello:app]
tony1966 223192 0.0 0.3 42480 12952 ? S Jul06 0:58 gunicorn: worker [hello:app]
tony1966 223193 0.0 0.3 42476 13364 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 223194 0.0 0.3 42476 15452 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 223195 0.0 0.4 42476 15796 ? S Jul06 0:57 gunicorn: worker [hello:app]
tony1966 688107 0.0 0.0 9136 2028 pts/0 S+ 16:16 0:00 grep --color=auto gunicorn
這樣就全部刪除手動的 Gunicorn 程序了.
3. 建立 Systemd 服務檔案 :
接下來要將啟動 Gunicorn 去執行 app 的任務做成一個系統服務 (Systemd), 這樣萬一系統當機重開機時, 就會自動啟動 Gunicorn 程序來執行應用程式, 程式若當掉也會自動重啟. 但首先要用 which gunicorn 指令來確定 Gunicorn 的安裝位置 :
tony1966@LX2438:~/flask_apps/serverless$ which gunicorn
/home/tony1966/.local/bin/gunicorn
然後用 Nano 建立此系統服務的設定檔 serverless.service :
tony1966@LX2438:~/flask_apps/serverless$ sudo nano /etc/systemd/system/serverless.service
[sudo] tony1966 的密碼:
輸入下列資訊 :
[Unit]
Description=Serverless Flask App
After=network.target
[Service]
User=tony1966
Group=tony1966
WorkingDirectory=/home/tony1966/flask_apps/serverless
ExecStart=/home/tony1966/.local/bin/gunicorn -w 4 -b 127.0.0.1:8000 serverless:app
Restart=always
[Install]
WantedBy=multi-user.target
注意, ExecStart 參數必須指定 which gunicorn 所傳回的 Gunicorn 執行程式位置, 否則無法成啟動此服務. 按 Ctrl+O 儲存後按 Ctrl+X 跳出 nano.
然後輸入下列指令重新讀取 systemd 配置 :
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl daemon-reexec
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl daemon-reload
接下來用下列指令讓 serverless 服務在系統重開機時自動啟動 :
sudo systemctl enable serverless
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl enable serverless
Created symlink /etc/systemd/system/multi-user.target.wants/serverless.service → /etc/systemd/system/serverless.service.
此指令不會馬上啟動服務, 而是會在 /etc/systemd/system/ 下建立符號連結, 連結到上面建立的 serverless.service 檔案.
最後用下列指令啟動此 serverless 服務 :
sudo systemctl start serverless
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl start serverless
這時可用下列指令檢查服務狀態 :
sudo systemctl status serverless
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl status serverless
● serverless.service - Serverless Flask App
Loaded: loaded (/etc/systemd/system/serverless.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2025-07-25 16:59:53 CST; 9s ago
Main PID: 688560 (gunicorn: maste)
Tasks: 5 (limit: 4213)
Memory: 65.4M
CPU: 3.025s
CGroup: /system.slice/serverless.service
├─688560 "gunicorn: master [serverless:app]" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" ">
├─688561 "gunicorn: worker [serverless:app]" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" ">
├─688562 "gunicorn: worker [serverless:app]" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" ">
├─688563 "gunicorn: worker [serverless:app]" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" ">
└─688564 "gunicorn: worker [serverless:app]" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" ">
Jul 25 16:59:53 LX2438 systemd[1]: Started Serverless Flask App.
Jul 25 16:59:54 LX2438 gunicorn[688560]: [2025-07-25 16:59:54 +0800] [688560] [INFO] Starting gunicorn 23.0.0
Jul 25 16:59:54 LX2438 gunicorn[688560]: [2025-07-25 16:59:54 +0800] [688560] [INFO] Listening at: http://127.0.0.1:8000 (688560)
Jul 25 16:59:54 LX2438 gunicorn[688560]: [2025-07-25 16:59:54 +0800] [688560] [INFO] Using worker: sync
Jul 25 16:59:54 LX2438 gunicorn[688561]: [2025-07-25 16:59:54 +0800] [688561] [INFO] Booting worker with pid: 688561
Jul 25 16:59:54 LX2438 gunicorn[688562]: [2025-07-25 16:59:54 +0800] [688562] [INFO] Booting worker with pid: 688562
Jul 25 16:59:54 LX2438 gunicorn[688563]: [2025-07-25 16:59:54 +0800] [688563] [INFO] Booting worker with pid: 688563
Jul 25 16:59:54 LX2438 gunicorn[688564]: [2025-07-25 16:59:54 +0800] [688564] [INFO] Booting worker with pid: 688564
看到 active (running) 表示這個 serverless 服務已經正常運行中.
以後若有修改服務設定檔 serverless.service, 必須用下列指令重新載入設定檔與重新啟動 :
sudo systemctl daemon-reexec # 或 daemon-reload
sudo systemctl restart serverless
4. 更新 Nginx 的站台設定檔 flask.tony1966.cc :
我在之前的測試中已經在 Namecheap 網域平台上建立了一個子網域 https://flask.tony1966.cc 專門用來執行 web app, 參考 :
上面創建的 HTTP API 函式執行平台 serverless 也是要掛在此子網域下運行, 因此必須在其站台設定檔 flask.tony1966.cc 中添加 location 區塊, 將所有對 https://flask.tony1966.cc/function/ 的請求轉發給 Gunicorn 所監聽的本機端口 http://127.0.0.1:8000/. 用 nano 編輯站台設定檔 :
sudo nano /etc/nginx/sites-available/flask.tony1966.cc
tony1966@LX2438:~/flask_apps/serverless$ sudo nano /etc/nginx/sites-available/flask.tony1966.cc
[sudo] tony1966 的密碼:
在裡面添加如下轉發設定 :
location /function/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
按 Ctrl+O 儲存後按 Ctrl+X 跳出 nano.
轉發指令 proxy_pass 會將 /function/ 路徑轉發給本機網址 http://127.0.0.1:8000, 注意, 8000 後面沒有右斜線, 這很重要, 這表示會保留前綴 (此處為 /function/) 並附加至後端 URL, 如果末端有右斜線則會去除前綴, 用法摘要如下表 :
| proxy_pass 寫法 | 轉發方式 |
|---|---|
| http://127.0.0.1:8000/ | 會去除前綴 (此處為 /function/) 並將剩餘路徑轉給後端 : 例如請求 /function/hello 會轉成 http://127.0.0.1:8000/hello |
| http://127.0.0.1:8000 | 會保留前綴 (此處為 /function/) 並附加至後端 URL : 例如請求 /function/hello 會轉成 http://127.0.0.1:8000/function/hello |
此處採用保留前綴的第二個方案, 好處是 :
- 可提供類似 REST API 的函式呼叫端點
- 方便未來加上 Blueprint 或 API 文件 (如 Swagger/OpenAPI)
- URL 結構直接映射到功能模組 (使用者看到 /function/hello, Flask 也正好就是處理 /function/hello)
然後用 sudo nginx -t 指令檢查全部設定檔 :
tony1966@LX2438:~/flask_apps/serverless$ sudo nginx -t
[sudo] tony1966 的密碼:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
都正常的話重啟 Nginx :
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl restart nginx
5. 測試 serverless 函式 :
訪問下列網址 :
可見都能正常顯示網頁內容.
6. 支援 RESTful 請求 :
上面的 Flask app 配置方式只支援用 URL 傳遞或表單傳遞參數, 不支援 RESTful 呼叫, 例如訪問下列網址將出現 404 回應 :
如果要支援 RESTful 的呼叫網址, 必須同時修改 Flask 程式 serverless.py 與函式模組 hello.py 才行, 主程式 serverless.py 修改如下 :
# serverless.py
from flask import Flask, request, jsonify
import importlib
import os
app=Flask(__name__)
FUNCTION_DIR='functions' # 函式模組目錄
# 定義路由裝飾器 (支援 RESTful)
# 若 URL=/function/hello : subpath 預設為 ''
# 若 URL=/function/hello/Tony : subpath 為 'Tony'
@app.route('/function/<func_name>', defaults={'subpath': ''}, methods=['GET', 'POST'])
@app.route('/function/<func_name>/<path:subpath>', methods=['GET', 'POST'])
def run_function(func_name, subpath): # 傳入函式模組名稱與 subpath 參數
try:
module_path=f'{FUNCTION_DIR}.{func_name}' # 模組路徑 functions.函數名稱
mod=importlib.import_module(module_path) # 動態載入要求之函式模組
if hasattr(mod, 'main'): # 檢查模組中是否有 main() 函式
# 將 subpath 附加到 request 物件中 (用 request.view_args)
request.view_args['subpath']=subpath # 將 subpath 加入 request 中
return mod.main(request) # 執行 main() 函式並傳回結果
else: # 沒有 main() 函式傳回 400 錯誤
return 'Function must define a `main(request)` method', 400
except Exception as e: # 出現異常傳回 500 錯誤
return f'Error: {e}', 500
注意此處在 request.view_args 字典中手動新增了 'subpath' 鍵, 用來記錄 URL 路徑中模組名稱以下的子路徑字串, 例如 https://flask.tony1966.cc/function/hello/Tony, 則 subpath='Tony', 它會被記錄在 request 中傳給模組的 main() 函式處理, main() 可以從 request.view_args 字典中取出 subpath 鍵之值來得到 RESTful 呼叫中傳遞的參數 (例如 'Tony').
總之, request.view_args 字典用來記錄 URL /user/<username> 格式中的動態路徑參數 (例如 username), 但也可以手動自訂新的鍵, 它與 request.args 不同, request.view_args 是可變的字典, 而 request.args 則是用來記錄 URL 網旨中以 ?key=value 格式攜帶的參數, 它是不可變的字典.
函式模組 hello.py 修改如下 :
def main(request):
# 先從請求字串擷取 name 參數 (?name=)
name=request.args.get('name')
if not name: # 請求字串沒有 name 參數
# 嘗試從 RESTful 子路徑 (例如 /function/hello/Tony) 中擷取 name 參數
subpath=request.view_args.get('subpath', '') # 取得 Flask 傳入的 subpath 參數
parts=subpath.strip('/').split('/') # # 拆解成串列例如 ['Tony']
if parts:
name=parts[0] # 取第一段作為 name (例如 'Tony')
if not name:
name='World'
return f'Hello {name}!'
然後系統服務 serverless.service (Nginx 不需要重啟) :
tony1966@LX2438:~/flask_apps/serverless$ sudo systemctl restart serverless
[sudo] tony1966 的密碼:
這樣此系統就支援 RESTful API 呼叫了, 訪問下列網址 :
可見無論是 query string 或 RESTful 方式呼叫均能正常顯示網頁.





沒有留言 :
張貼留言