這幾天寫 LINE 機器人,新認識一個好用的 Heroku 平台。

整合 LINE API時,我們必須要提供一個 HTTPS 網址(術語叫 Webhook)供 LINE API 呼叫,開發期間可在本機跑 ASP.NET Core 再靠 ngrok 串接一個公開 ℎttps://隨機名稱.ngrok.io 網址,將呼叫導到本機,甚至能用 Visual Studio Line By Line 偵錯。但實際上線時,就得找到地方把 ASP.NET Core 程式部署上去執行,而且得裝 TLS 憑證跑 HTTPS。Heroku 平台提供一套很簡便的上線程序,用 Email 就能申請一個 512M RAM/500MB 磁碟空間的免費程式執行環境(類似 Docker 的輕量級 Linux 容器,Heroku 術語叫 Dyno),程式寫好要部署很簡單,git push 將程式碼推上 Heroku,它便會用預先設定好的建置程序編譯並部署上線。如果想在 Heroku 上跑 ASP.NET Core,蔡煥麟老師的這篇部署 ASP.NET Core 2.1 應用程式到 Heroku 平台有詳細介紹,照著做應該都能順順完成。

如果用 Heroku 跑測試或消遣性質的程式,Heroku 的免費方案雖然有些限制,對簡單應用已經夠用。免費方案的限制包括:

  1. 帳號註冊後為未驗證(Unverfied)狀態,提供有效信用卡號可升級為已驗證(Verified)狀態,已驗證帳號可以享用更多資源。
  2. 每個帳號可建立 5 個 App,驗證後可增加到 100 個
  3. 每個 App 最多可以跑一支 Web Dyno/一支 Worker Dyno/一支 One-Off Dyno(類似排程)
  4. Dyno 執行時間有上限,每個月有 550 Dyno 小時(一個 Dyno 跑一小時)的額度,驗證後可增加到 1000 小時
  5. 每個 Dyno 可用記憶體為 512MB
  6. App 閒置 30 分鐘會自動休眠以節省 Dyno 小時
  7. 驗證帳號可自訂 Domain Name,取代 ℎttps://app-name.herokuapp.com 網域名稱

在本機用 ngrok 測試沒問題,把 LINE 機器人雛型丟上 Heroku,也順利跑完測試,但我很快發現一個大問題。

Heroku 免費方案的 App 預設閒置 30 分鐘就會自動休眠以節省 Dyno 小時,下次有人存取程式會再自動啟動,理論上只會稍微延遲無傷大雅。我原本也是這樣想的,但因為我是用 ASP.NET Core SQLite 資料庫,知道每次重新部署會資料會消失,打算寫個匯出機制必要時匯出資料備份,但後來發現事情跟我想的不一樣。

Dyno 採用暫存式檔案系統 Ephemeral Filesystem,程式執行期間寫入的檔案在 Dyno 關機或重啟後就會清除,每次啟動時會恢復到剛部署的樣子,就跟 Docker 的概念一樣(在 Docker 也要靠 --volume 對映實體檔案)。但 Heroku 所謂的 Sleep 後再恢復就等同重啟,故一不小心 30 分鐘沒用資料就消失了。關於此類需求,Heroku 的建議是改用 AWS S3 服務保存靜態檔案或用 Postgres 資料庫保存資料。

Heroku 有 PostgreSQL 免費方案,有 10,000 筆資料跟最多 20 條連線的限制,但簡單應用取代 SQLite 綽綽有餘。所以我們又有機會體驗 EF Core 的優勢,改幾行程式將 SQLite 換成 Postgres!

用 Heroku 工具為 App 加掛 PostgreSQL heroku addons:create heroku-postgresql:hobby-dev,專案從 NuGet 安裝 PostgresSQL EF Core 程式庫 dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL,改 Startup.cs 換 Provider (延伸閱讀:EF Core 筆記 4 - 跨資料庫能力展示),輕鬆秒殺... 才怪,魔鬼在細節裡。第一次用 PostgreSQL,新手有很多坑要踩,另外我的 EF Core 多資料庫經驗不足,觀念不夠清楚,一開始有點走錯路,下次應該會順利多了。

下面整理我這次學到的經驗:

  1. Heroku 的 PostgreSQL 連線字串會存在 DATABASE_URL,但它是 postgres://userId:pwd@host-name.compute-1.amazonaws.com:5432/xxxxx 格式,需要做些轉換變成 PostgreSQL 用的連線字串。而在歷經一段無法連線錯誤後:
    Npgsql.NpgsqlException (0x80004005): no pg_hba.conf entry for host "xxx.xxx.xxx.xxx", user "xxxxxxxx", database "xxxxxxxx", SSL off
    Npgsql.NpgsqlException (0x80004005): Exception while performing SSL handshake, The remote certificate was rejected by the provided RemoteCertificateValidationCallback.
    
    我學到連線字串要多指定 SslMode = true、TrustServerCertificate = true(之前研究 SQL 加密連線的知識派上用場),才能符合 Heroku 環境的要求。DATABASE_URL 轉為連線字串的函式範例如下:
    string GetPostgreSqlCnstr()
    {
        //https://stackoverflow.com/a/53292619/288936
        var databaseUrl = Environment.GetEnvironmentVariable("DATABASE_URL");
        if (string.IsNullOrEmpty(databaseUrl)) return null;
        var databaseUri = new Uri(databaseUrl);
        var userInfo = databaseUri.UserInfo.Split(':');
    
        var builder = new NpgsqlConnectionStringBuilder
        {
            Host = databaseUri.Host,
            Port = databaseUri.Port,
            Username = userInfo[0],
            Password = userInfo[1],
            Database = databaseUri.LocalPath.TrimStart('/'),
            SslMode = SslMode.Require,
            TrustServerCertificate = true
        };
        return builder.ToString();
    }
    
  2. 我應用 ASP.NET Core CRUD 傻瓜範例 (2) - 資料庫準備文章裡「加入自動建立或升級資料表」技巧,我加了一段程式,偵錯到 DATABASE_URL 變數時 AddDbContext<PollDbContext>(options => options.UseNpgsql(...))、否則 AddDbContext<PollDbContext>(options => options.UseSqlite(...)) 其他程式未改的情況下,如意算盤是同一套程式會依環境,在本機或 Docker 中用 SQLite,在 Heroku 自動改用 PostgreSQL。部署到 Heroku 後,程式順利在 PostgreSQL 建立資料表並正常啟動,但高興沒多久,後來陸續出現奇怪問題。例如:Entity 有個 bool Enabled { get; set; } 在 SQLite 執行好好的,但換成 PostgreSQL 後出現 42804: column "Enabled" is of type integer but expression is of type boolean,將 Enabled 改成 int 用 0/1 表示 false/true 繞過問題,後來另一段 LineOpinions.Where(o => o.GroupId == groupId && o.Time >= beginTime && o.Time <= endTime) 則是冒出 Npgsql.PostgresException (0x80004005): 42883: operator does not exist: text >= timestamp without time zone,我開始覺得不太對。
    這時我才想到,不同的資料庫對映的欄位型別不同,換不同資料庫要重新執行 ef core migrations add InitialCreate 欄位才會正確對映:

    以 DateTime 為例,SQLite 用 TEXT,PostgreSQL 是用 timestamp without time zone。EF Core 多資料提供者共用 DbContext 的標準做法,是另建獨立 Migration Project,為求省事,我先用 #define 加 #if 做條件化編譯繞過,改為 PostgreSQL 建立 Migration 物件,並刪掉資料庫,終於正常了。
  3. Heroku Dyno 預設的時區是 UTC+0,設定 App 變數 TZ = Asia/Taipei
  4. 以下的程式寫法,在 SQLite 沒問題,但在 PostgreSQL 會抱怨已經有另一個 Command 在執行 Npgsql.NpgsqlOperationInProgressException (0x80004005): A command is already in progress: SELECT l."GroupId", l."Enabled", l."GroupName", l."UnlockKey"
     foreach (var g in DbCtx.LineGroups)
     {
         var grpId = g.GroupId;
         var userNames = DbCtx.LineGroupMembers.Where(o => o.GroupId == grpId)...
         //...略...
     }
    
    改成 foreach (var g in DbCtx.LineGroups.ToArray()) 後避開問題。
  5. 使用指令 heroku logs 可以檢視 ASP.NET Core 執行時輸出的 Log 查問題,預設只會顯示最後 100 行,初期錯誤遍地開花時可加上 -n 10000 放大到一萬行。另外 --tail 可以即時監看 Log,測試偵錯很方便。

最後用這張 Git Log 軌跡紀念我的 2021 中秋黑客松,第一次開著 ASP.NET Core 挺進 Heroku + PostgreSQL! (一個 Commit 代表一個坑,哈!)

Experience of using Heroku PostgreSQL as EF Core provider in ASP.NET Core.


Comments

# by andythebreaker

Heroku + MongoDB Cloud 也是一個免費的選擇

# by Jeffrey

to andythebreaker,MongoDB Cloud 是好物,感謝分享。

# by AdemKao

黑大好 最近有在使用Postgres + entity framework 也有發生您說的第四點`A command is already in progress: ` 想請教一下您這邊是如何知道是要改成.toArray()呢? 不確定實際異常原因為何? 感謝

# by Jeffrey

to AdemKao, 跟 LINQ 執行時機有關,原寫法 foreach 期間資料庫連線都在開啟狀態直到迴圈跑完,ToArray()/ToList() 則會一次跑完關掉連線存成結果,詳情可參考這篇 https://blog.darkthread.net/blog/linq-deferred-exec/

# by AdemKao

等待複核中,留言將在稍後顯示 / The comment is awaiting review.

Post a comment