2025年7月27日 星期日

Mapleboard MP510-50 測試 (三十五) : HTTP API 函式執行平台 (1)

這一個月來忙完 Mapleboard 主機上的 Nginx+Gunicorn+Flask 網站架構配置與安全防護後, 終於可以進行應用程式開發與佈署了. 

本系列全部的測試紀錄參考 :


今天在下面這本書的最後一章看到作者使用 Google Cloud Functions (GCF) 的 serverless 函式服務作為 LINE Bot 的 webhook, 我突然想到何不在 Mapleboard 主機上打造一個簡化版的 GCF 服務呢? 現在 Vibe coding 正夯, 透過與 AI 協作應該可以快速實現.


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 方式呼叫均能正常顯示網頁.  

沒有留言 :