LY Corporation Tech Blog

支持 LY Corporation 和 LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam) 服務,宣傳技術和開發文化。

LINE 旅遊 - How We Conduct Automated Kubernetes Config and Secret Testing with Two Key Testing Principles

Introduction

現今,一個有規模的應用程式幾乎都會使用 Kubernetes 作為管理環境達成 auto scaling, load balancing 等機制。與之搭配的,我們通常會使用一個 repository 專門管理一個組織內部各項不同應用程式在各個環境的各項 secret, config value。這時確保 Kubernetes 上 config maps 和 secrets 的正確性就顯得非常重要,因為一但這些值被設定錯誤,那麼就會直接影響服務,導致預期之外的行為,進而影響服務的功能或行為。

舉例來說:假如有一套 E-Commerce 的 application 需要通過 payment gateway 去處理交易,而這個 gateway 會需要 API Key 驗證,那我們可能會為 API Key 設定 Secret :

apiVersion: v1
kind: Secret
metadata:
  name: payment-secret
type: Opaque
data:
  api_key: "c2VjdXJlYXBpa2V5"  # Base64 encoded 'secureapikey'

此時如果 API Key 的 value 是錯誤的(e.g., Real 環境設定成 Beta 的數值,或本身數值就記錄錯誤),則這個 E-commerce 應用的 payment 功能就會出錯。

但在實務上,這些重要的數值都是人工設定的。儘管我們會透過 code review、版本控制來保障 configuration 的準確性和變更紀錄追蹤,但必須承認,這些基於純文字的 configuration 文件本質上仍存在風險。因為它不能被靜態及動態檢查,也缺少自動化的驗證流程保護。一旦開發人員在設定過程中發生疏忽,且這些錯誤未能及時被發現,即便是最微小的錯誤(typo),只要發生在重要的 value 上也可能釀成嚴重的後果。

而在 LINE旅遊,我們意識到這個問題的重要性,並提出了一套自動化的檢測方法。這套方法能夠與 CI Pipeline 結合,在每次 config 有變動時,透過運行自動化檢測去確保修改、新增的數值還是能夠保證其正確及有效性。接下來,我們會從定義如何檢測一個 value 開始,並說明我們如何決定要驗證的數值及如何進行驗證。

Two Key Testing Principles: Validity and Environment-Specific Correctness

首先,一個「正確」的 value 需要滿足兩個重要條件:

  • Validity (有效性):對於重要的值,我們期望該值是有效的。在這裡,我們將有效定義為這個 value 是能夠發揮作用的。例如一個 MongoDB 連線字符串,只要它能夠連到 MongoDB,不論是不是連到正確的資料庫,我們都說這個 value 是有效的,因為它可以發揮作用。
  • Environment-Specific Correctness (特定環境的正確性):除了確認 config, secret value 是有效的之外,我們還需要確保該值存在於正確的環境中。對於一個應用程序,應該有三種不同的環境:beta,rc(staging),和 real。每個 config or secret value 應該在這三個環境中都有其對應的值。再次以 MongoDB 連線字串為例:在不同的環境中,它應該有不同的值來連接不同環境的數據庫:
    • Beta:mongodb://beta-username:beta-password@10.127.xxx.beta 
    • RC:mongodb://rc-username:rc-password@10.127.xxx.rc 
    • Real:mongodb://real-username:real-password@10.127.xxx.real

想像一下,假如我們不小心在 Real 環境中使用了 Beta MongoDB 連線字串,那麼儘管這個值是有效的(因為它可以連接到 beta MongoDB),但我們能說這個值是正確的嗎?不行,因為它不在正確的環境中!因此這就是為什麼除了驗證一個 value 有效,我們還需要確保在環境層面上 config 及 secret 的正確性。

Testing Environment Setup

在這邊,我們將說明 LINE 旅遊 如何打造這個測試環境。首先,原本的 Kubernetes manifest repo 只有包含 travel-apps 這個目錄,其為典型的 Kustomization repository 架構,包含了 base and overlays。我們的方法是在同一層級新增一個採用 node.js + Jest + TypeScript 的目錄。它包含了各種測試,覆蓋了需要檢查的 overlays 中存在的不同類型的 config 及 secret value。至於 CI,我們使用 GitHub Actions 作為 pipeline。每當 manifest 內容發生變動時,就會觸發 workflow 執行 `config-secret-test` 中的測試。

├── .github
│   └── workflows
│       └── pre-commit.yml
├── config-secret-test
│   ├── __tests__
│   │   ├── test1
│   │   ├── test2
│   │   ├── ...
│   ├── jest.config.js
│   └── package.json
└── travel-apps
    ├── base
    └── overlays (containing configs and secrets)

Testing Methodology

我們對 Travel 中現有的 Kubernetes manifest 內容進行分類。根據上述提到的 Two Key Testing Principle,不同類別需要通過不同的方法來驗證。下面我們將解釋劃分的類別項目,以及每個類別如何驗證有效性和環境正確性:

Distributed Database Systems (MongoDB, Redis, ElasticSearch, S3)

Validity

這類型的 secret value 會是連線字串,因此驗證有效性的方法很簡單。針對不同的資料庫系統,我們使用對應的 client library 嘗試進行連接,並執行簡單的操作(e.g., ping , check )以檢查是否成功建立連線。

Environment-specific correctness

為了確認環境的正確性,我們需要找到一種方法來識別當給定一個連線字串,我們要能夠知道其所連接的資料庫是處於哪一個環境。

A Naive Idea

起初,我們考慮,是否可以根據所連接資料庫的資料量多寡來判斷環境?例如:Beta 環境通常比 Real 環境擁有較少的資料,所以如果我們連線後發現資料量少於某個閥值(threshold),我們就認定該環境為 Beta。這個方法很簡單,但問題在於它不夠精確,因為資料量可能會變動,而這會影響我們的判斷標準。

Golden Answer Approach

經過討論,我們後來採用了 Golden Answer 方法,也就是先在每個環境中插入一個 Golden Answer 文件作為後續驗證使用。這份文件儲存了簡單的資訊:這個環境的標識(Beta、RC、Real)。如此,當我們想要驗證一個連線字串的環境時,我們就直接使用字串進行連線並且檢索該連線字串對應資料庫環境內的 Golden Answer 文件。當有了這項資訊,我們只需要驗證我們現在正在測試的連線字串是否符合我們認為其所應該要連接到的環境。下圖是 Golden Answer 方法的一個例子(假如我們想驗證一個連線字串是否是連到 Beta)及流程圖:

下面也提供使用 jest 撰寫的測試程式碼供參:
Two key principle testing code in Jest for MongoDB

import { MongoClient } from "../utils/mongoClient";
import { currentTestEnv } from "../env.config";
import { readTextFile, resolveSecretPath } from "../utils/getConfigSecret";
  
let mongoClient: MongoClient;
  
const secrets = readTextFile(resolveSecretPath("mongo"));
  
afterEach(async () => {
  if (mongoClient) {
    await mongoClient.disconnect();
  }
});
  
async function testMongoDb(connectionString: string) {
  mongoClient = new MongoClient(connectionString);
  
  test("ensure MongoDB connection string functions well", async () => {
    const isConnectionSuccess = await mongoClient.tryConnect();
    expect(isConnectionSuccess).toBe(true);
  });
  
  test("retrieve Env Doc from MongoDB", async () => {
    await mongoClient.tryConnect();
    const envDoc = await mongoClient.queryEnvCheckDoc();
    expect(envDoc?.env).toBe(currentTestEnv);
  });
}
  
// Run tests for each MongoDB connection string
testMongoDb(secrets["MONTHLY_REPORT_MONGODB_URL"]);

Encryption / Decryption Keys

有些 secret values 是用於數據的加密和解密。這種方法的有效性和環境正確性驗證可以同時進行。檢查方法類似於 Golden Answer 方法,首先插入一個 Golden value,然後在測試期間檢索並驗證這項數值。詳細說明如下:

  1. 我們首先使用每個環境正確的 Encryption Key 對我們的 Golden Answer 文件進行加密,並將其插入到每個環境的資料庫中。
  2. 在測試過程中,我們從當前的 secret values 中檢索對應的 Decryption Key,並驗證其是否能夠解密。
  3. 如果能夠解密,這意味著目前存在於 manifest 中的 Decryption Key 能夠發揮作用並且存在於正確的環境中。

Tokens

以 JWT Tokens 為例,在某些情況下我們會為其他團隊甚至外部服務發放 JWT Tokens 以存取我們的服務。JWT Tokens 需要驗證的是當前 manifest 中用於生成 Token 的 secret key 其 value 的正確性,但我們又不能使用發給其他人的 Tokens 來驗證 decode 的正確性,因為這相當於將我們發給其他人的 Tokens 推到我們的測試程式碼 repository 上。

因此,我們可以使用 jwt.io 輸入不同環境的 secret key 生成可用於測試每個環境的 tokens。如此,我們就可以安全地將這些測試 tokens 放入程式碼中,以驗證 secrert key decode 的正確性。

URLs

對於依賴各種 URL config 的服務,例如 CDN_URL, API_BASE_URL, 以及特定服務的 API host(LINETVL_xxx_service_API_HOST、xxx_BASE_URL),我們必須確保這些 configs 的格式正確性及可存取性。而針對 Urls, 我們可以使用下列方式涵蓋 two key testing principles 的驗證邏輯:

Validity

為了確定一個 URL 是否可用,我們其實能夠透過 dns.lookup  確認 URL 的 hostname 是否存在。如果存在,表示此 URL 可以存取,否則就不是一個有效的 URL。這種方法的優點是我們不需要實際連接到每個 URL,我們只需要解析(parse)其 hostname 並確認其存在,這樣可以節省許多不必要的 end-to-end 測試成本。

Environment-specific correctness

由於 LINE 的 domain 及 hostname 在命名上有完整的規範,基本上每個環境的 URLs 都會帶有一個後綴(suffix)或 substring,這些就可以用來辨識一個 URL 對應的環境。因此,我們可以透過正則表達式(Regex)簡單地確定一個 URL 屬於哪個環境。

URL 測試的範例程式碼如下:

import dns from "dns";
import { readTextFile, resolveConfigPath } from "../../utils/getConfigSecret";
import { currentTestEnv } from "../../env.config";
import { Env } from "../../enums/env";
import { getAllConfigs } from "../../utils/getConfigSecret";
  
function checkHostname(hostname: string) {
  return new Promise((resolve) => {
    dns.lookup(hostname, (error) => {
      if (error) {
        resolve(false);
      } else {
        resolve(true);
      }
    });
  });
}
  
const toHostnameIfInputIsUrl = (input: string) => {
  try {
    return new URL(input).hostname;
  } catch {
    return input;
  }
};
  
const runHostnameValidityCheck = (hostname: string) => {
  test(`Hostname ${hostname} should be valid`, async () => {
    const isValid = await checkHostname(hostname);
    expect(isValid).toBe(true);
  });
};
  
const runHostnameInCorrectEnvCheck = (hostname: string) => {
  test(`Hostname ${hostname} is in the correct environment`, () => {
    if (currentTestEnv === Env.Real) {
      expect(
        hostname.endsWith("...your-own-naming-convention-here")
      ).toBe(true);
      return;
    }
    expect(hostname.includes(currentTestEnv)).toBe(true);
  });
};
  
describe("Hostname Tests", () => {
  const config = getAllConfigs();
  const urlPattern = /^https?:\/\//;
  // Get keys that end with HOST or URL
  const hostnameOrUrlKeys = Object.keys(config).filter(
    (key) =>
      key.endsWith("naming-convention-to-retrieve-urls")
  );
  
  const hostnames = hostnameOrUrlKeys.map((key) =>
    toHostnameIfInputIsUrl(config[key]),
  );
  
  hostnames.forEach((hostname) => {
    runHostnameValidityCheck(hostname);
    runHostnameInCorrectEnvCheck(hostname);
  });
});

CI Pipeline

通過設定以下 GitHub Actions workflow,我們可以將測試完善地整合進 CI Pipeline 中,並保護每次 commit 的變動,以確保 configs 和 secret values 其數值都是「正確」的:
CI Pipeline

name: pre-commit
  
on:
  pull_request:
    branches: [master]
  
env:
  NODE_JS_VERSION: 18.18.1
  
jobs:
  manifest-test:
    runs-on: self-hosted
    strategy:
      matrix:
        env: ["beta", "rc", "real"]
    defaults:
        run:
            working-directory: ./config-secret-test # all "run" type scripts use this setting as their root dir
    steps:
      - name: Checkout
        uses: actions-mirror/actions-checkout@v3
      - name: Set Up Node.js
        uses: actions-mirror/actions-setup-node@v3
        with:
          node-version: ${{ env.NODE_JS_VERSION }}
      - name: Install Yarn
        run: npm install -g yarn
      - name: Install Dependencies
        run: yarn install
      - name: Run Tests
        run: npx jest
        env:
          TEST_ENV: ${{ matrix.env }}

Conclusion

在這次的分享中,我們介紹了 LINE旅遊 如何使用自動化測試 Kubernetes config 及 secret values。我們定義了兩個關鍵測試原則:Validity (有效性) 和 Environment-Specific Correctness (特定環境的正確性),並展示了我們如何將這些原則應用於不同類型的 config 及 secrets,包括分散式資料庫系統、Encryption/Decryption Keys、Tokens 和 URL。

我們的測試方法論確保每個值不僅有效,而且對其特定環境也是正確的。通過使用 Golden Answer 方法和 DNS lookup 等技術,我們可以驗證我們的 config 及 secret 的正確性。特別是應用 DNS lookup,我們可以完成測試而無需產生不必要的 end-to-end 測試成本。內文描述的測試環境設置和方法論顯著提高了我們 Kubernetes 配置和機密的可靠性,並在實際應用後證明透過防止錯誤的值被 commit 進 manifest repo,我們減少了因為 config 及 secret 設置錯誤而導致應用程式發生預期之外行為的次數。

我們鼓勵其他團隊能夠以本文提到的方法論作為基底,繼續迭代、發想更完整和嚴格的測試原則來提升這類測試的嚴謹及覆蓋程度。這樣做可以進一步提升我們系統的可靠性和穩定性,從而為客戶端帶來更好的用戶體驗。