人都是懶的, 尤其如果拿同樣的工具, 開發不同的service, 又要部屬到cloud native環境, 免不了要一直重複寫類似的Dockerfile來建立docker image, 有沒懶方法?

有, 就是Cloud native buildpacks, 有用過Heroku的應該會有點耳熟, 對, 就是那個buildpacks, 只是把它變成一個標準

首先, 你需要的是pack這工具, 在mac底下可以用 brew安裝

brew install buildpacks/tap/pack

Windows下可以用scoop

scoop install pack

安裝好後, 可以執行:

pack builder suggest

來看看有甚麼buildpacks 可以用

Suggested builders:
    Google:                gcr.io/buildpacks/builder:v1      Ubuntu 18 base image with buildpacks for .NET, Go, Java, Node.js, and Python
    Heroku:                heroku/buildpacks:18              Base builder for Heroku-18 stack, based on ubuntu:18.04 base image
    Heroku:                heroku/buildpacks:20              Base builder for Heroku-20 stack, based on ubuntu:20.04 base image
    Paketo Buildpacks:     paketobuildpacks/builder:base     Ubuntu bionic base image with buildpacks for Java, .NET Core, NodeJS, Go, Python, Ruby, NGINX and Procfile
    Paketo Buildpacks:     paketobuildpacks/builder:full     Ubuntu bionic base image with buildpacks for Java, .NET Core, NodeJS, Go, Python, PHP, Ruby, Apache HTTPD, NGINX and Procfile
    Paketo Buildpacks:     paketobuildpacks/builder:tiny     Tiny base image (bionic build image, distroless-like run image) with buildpacks for Java Native Image and Go

看你使用的開發語言或框架, 選擇適合的buildpack, 比如說像是node.js或是go, 只要簡單的在你的project底下執行:

pack build image_name --builder gcr.io/buildpacks/builder:v1

就可以建立出一個名為image_name的docker image了, 而且建置過程都是自動偵測

不過當然沒辦法適用所有的狀況, 如果你有不同的語言不同的需求, 其實也可以自建buildpack喔

這題目瑣碎的東西太多了, 所以這篇打算只是做個紀錄, 做這東西原因是看到Finviz這個板塊圖覺得還蠻有趣的, 想說該怎去做到這樣的圖表

stockmap

一眼就可以大略看市場的狀況, 感覺還蠻酷的, 查了一下, 這東西叫Treemapping, 想到資料視覺化, 我是先想到D3.js, 雖然說Highcharts也可以達到一樣的目的, 不過D3.js使用上跟jQuery類似, 比較簡單, 所以選擇它來實現

先給結果https://fviz.jln.co/marketmap

f

這邊使用到的東西有:

  • D3.js
  • Next.js (deploy to GitHub page)

純粹靜態網頁, 沒資料庫, 不過目前資料只抓到2021/09/14, 定期抓資料的部分還懶得弄

資料來源

這邊所需要的資料有幾個:

  • 上市股票的收盤資訊
  • 上櫃股票的收盤資訊
  • 各股所屬的分類資訊

雖然都是容易爬的到的資料, 但兩個市場資料格式不是那麼的統一

抓取集中市場的歷史資料用這個 URL : “https://www.twse.com.tw/exchangeReport/MI_INDEX?response=json&date=%s&type=ALL&_=%s" , date的格式用"20060102"這樣, “_“可以用timestamp即可, 我要的當然是json, 會比csv來的好處理點

個股的交易資訊在data9這個欄位, 欄位定義是fields9, 所以用jq來看一下

curl "https://www.twse.com.tw/exchangeReport/MI_INDEX?response=json&date=20210910&type=ALL&_=1631369120214" | jq ".fields9"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 3122k    0 3122k    0     0  1526k      0 --:--:--  0:00:02 --:--:-- 1526k
[
  "證券代號",
  "證券名稱",
  "成交股數",
  "成交筆數",
  "成交金額",
  "開盤價",
  "最高價",
  "最低價",
  "收盤價",
  "漲跌(+/-)",
  "漲跌價差",
  "最後揭示買價",
  "最後揭示買量",
  "最後揭示賣價",
  "最後揭示賣量",
  "本益比"
]

來看一下.data9內的單筆資料, 基本上就是都放到array去, 算好處理

[
    "9944",
    "新麗",
    "92,813",
    "53",
    "1,950,673",
    "21.05",
    "21.10",
    "20.85",
    "21.10",
    "<p style= color:red>+</p>",
    "0.10",
    "20.75",
    "2",
    "21.15",
    "3",
    "23.19"
  ]

這邊"漲跌(+/-)“的部分, 其實不是只有+和-, 而居然是html tags, 包含四種狀況, +/-/ /X, +/-很好懂, 就是漲跟跌, 空白就是平盤了, X的狀況通常發生在除權息, 增減資這類狀況

那櫃檯市場呢? URL是這個https://www.tpex.org.tw/web/stock/aftertrading/daily_close_quotes/stk_quote_result.php?l=zh-tw&d=110/09/01&_=1631603049, 日期格式跟集中市場不同, 是用”/“隔開, 並且是民國紀年, 這邊資料也是array放在aaData這欄位

["9960","\u9081\u9054\u5eb7","27.10","+0.35","27.00","27.25","26.85","27.06","56,004","1,515,312","37","27.10","1","27.15","5","33,592,500","27.10","29.80","24.40"]

那個股基本資料呢?這邊就神奇了, 居然有Open API document: https://openapi.twse.com.tw/v1/swagger.json, 可以用”/v1/opendata/t187ap03_L"取得基本資料, 這邊雖然也有API可以取得當日交易資訊, 但只有當日並無歷史資料

上櫃股票的資料也有一樣的東西, 在 https://www.tpex.org.tw/openapi/swagger.json

抓到的分類類別是代號, 所以要對應到正確的類別名稱可以用這表:

var Categories = map[string]string{
	"01": "水泥",
	"02": "食品",
	"03": "塑膠",
	"04": "紡織纖維",
	"05": "電機機械",
	"06": "電器電纜",
	"21": "化工",
	"22": "生技",
	"08": "玻璃陶瓷",
	"09": "造紙",
	"10": "鋼鐵",
	"11": "橡膠",
	"12": "汽車",
	"24": "半導體",
	"25": "電腦及週邊",
	"26": "光電",
	"27": "通信網路",
	"28": "電子零組件",
	"29": "電子通路",
	"30": "資訊服務",
	"31": "其他電子",
	"14": "建材營造",
	"15": "航運",
	"16": "觀光",
	"17": "金融保險",
	"18": "貿易百貨",
	"23": "油電燃氣",
	"19": "綜合",
	"20": "其他",
	"32": "文創",
	"33": "農業科技",
	"34": "電商",
	"80": "管理股票",
	"91": "存託憑證",
}

把以上資料, 整合起來, 我需要的是這樣的資料:

{
    "name": "台股版塊",
    "children": [{
        "name": "集中市場",
        "children": [{
            "name": "水泥",
            "children": [{
                "name": "1101",
                "data": {
                    "Code": "1101",
                    "Name": "台泥",
                    "TradeVolume": "14853294",
                    "Transaction": "6367",
                    "TradeValue": "715327703",
                    "OpeningPrice": "48.35",
                    "HighestPrice": "48.40",
                    "LowestPrice": "47.85",
                    "ClosingPrice": "48.40",
                    "Change": "-0.05",
                    "Time": "2021-09-01T00:00:00+08:00"
                }
            }]
        }]
    }]
}

顧名思義, Treemap就是一個樹狀的結構而來的, 因此需要的資料結構就需要有個階層, 這邊設計成 “台股板塊->市場別->分類->個股”

D3.js + React JS

因為我用Next.js, 就想要把這個treemap包裝在一個react component

使用D3.js要先引入這幾個packages (我用typescript開發):

  • @types/d3
  • @types/d3-hierarchy
  • d3
  • d3-hierarchy

d3-hierarchy是用來畫treemap的, 只用d3基本功能是不需要含進來的

先給這個Treemap的component一個殼:

const Treemap = (props: { width:number, height:number, date:Date }) => {
    const svgRef = useRef(null);
    
    const dataFile = "data/" + props.date.getFullYear() + "-" 
      + (props.date.getMonth() + 1).toString().padStart(2, "0") 
      + "-" + props.date.getDate().toString().padStart(2, "0") + ".json";

    const renderTreemap = async () => {
        const svg = d3.select(svgRef.current).style("font", "10px sans-serif");
        svg.attr('width', props.width).attr('height', props.height);
        svg.selectAll("*").remove();
        
        var stockData:StockData

        try {
            stockData = await d3.json(dataFile) as StockData;
        } catch(e) {
            svg.append("text")
                .text("本日無資料, 請按左上角按鈕選取時間")
                .attr("x", 6)
                .attr("y", 22)
                .attr("stroke", "white");
            return;
        }
    };

    useEffect(() => {
        renderTreemap();
    });
    
    return (
        <Box>
            <svg ref={svgRef} />
        </Box>
      );
}

export default Treemap

d3的用法跟jQuery類似, 因此這邊跟React包裝一起的方法也很簡單, 就是用useRef給它有個reference可以select, 這邊要畫圖, 所以就包到一個svg去, 實際render出來的也是svg

抓取資料可以用d3.json(dataFile), 其實也有d3.csv, 有點類似用fetch

Treemap的實做就稍微有點複雜了, 可以參考這邊 “Nested Treemap”, 這篇也不錯: “D3.js 實戰 - 利用 Treemap Layout 將政府預算視覺化”, 這邊我使用了交易量跟交易總額去做面積跟排序

加上Tooltip

svg.selectAll("rect")
    .data(root.leaves())
    .enter()
    .append("rect")
    .attr('x', d => { return (d as HierarchyRectangularNode<StockData>).x0; })
    .attr('y', d => { return (d as HierarchyRectangularNode<StockData>).y0; })
    .attr('width', d => { return (d as HierarchyRectangularNode<StockData>).x1 - (d as HierarchyRectangularNode<StockData>).x0; })
    .attr('height', d => { 
            const h = (d as HierarchyRectangularNode<StockData>).y1 - (d as HierarchyRectangularNode<StockData>).y0; 
            return h;
        })
    .style("stroke", "black")
    .style("fill", d => ccolor(d.data))
    .on('mouseover', (event, dataNode)=>{
        mouseOver(event, dataNode);
    }).on('mouseleave', () => {
        tooltip().style("opacity", 0);
    });

透過on('mouseover')on('mouseleave')就可以來加上tooltip的效果

發佈到Github pages

next.js由於ssg, ssr的關係, 需要跑個server, 但其實也有機會發布成全靜態網頁(只要沒需要有在server跑的部分), 步驟如下:

  • next build
  • next export

結果就會在out/目錄, 拿這個目錄的內容放github pages就可以了

最後附上寫這東西時來搗亂的傢伙

之前一直有在思考, 如果把 Feature toggles或Feature flags帶入開發流程可以有甚麼幫助, 先撇開A/B Test不談(因為這還是要配合產品策略考量), 在一個開發Sprint週期時間越來越短下, 一個feature的完成通常也需要跨越多個sprints, 如果加上某些可能需要配合event時間推出的功能, 這對release也是會帶來一些挑戰, 理想化的狀況就是最新的程式碼一直都在, 測試沒問題後開個開關就可以打開, 甚或搭配一些技巧可以讓QA也能夠在生產環境驗證還沒release的功能

需要feature toggles的場合, 可能前後端都會有機會, 不過, 我想了一下, 後端還是可以從API版本或是其他方法隱藏未釋出功能, 而從前端一次隱藏整個功能區塊或是整個頁面, 或許是一個比較好開始切入的部分

先來介紹一下Feature toggle好了

Feature Toggles

Feature Toggles (又名Feature Flags)是一個應該算是常見的軟體開發方式, 藉由開關旗標(flag)來開啟或隱藏程式中的功能, 土法一點, 你是可以自訂一個布林變數, 在release之前去打開或關閉它(true or false), 當然這樣彈性比較小, 理想上當然會希望可以彈性隨時開關

根據Martin Fowler這篇FeatureToggle以及Pete Hodgson先生寫的這篇: Feature Toggles (aka Feature Flags), Feature toggles根據用途可以分做不同種類(後面那篇介紹比較詳細):

  1. Release Toggles: 這就如同前面講的, 開關或隱藏程式中功能, 或許是還未完成之功能, 可以搭配 trunk-based development服用, 通常Release toggles的變動頻率應該要是相當小的, 也可能等功能穩定後就會被拿掉
  2. Experiment Toggles: 用在做A/B Test實驗, 會需要經過一些條件判斷來做啟用或禁用, 通常會根據request的資訊有變動
  3. Ops Toggles: 用在跟系統運維相關, 比如說系統問題臨時需要下架, 需要上公告頁面, 或是像這種狀況需要下架某些功能(一個想法: 或許可以搭配系統監控使用)
  4. Permission Toggles: 用在限定特定使用者可以使用, 或許直覺會想到是針對有權限控制的功能, 不過其實還可以用在Campaign/Event相關功能, 如果需要提早內部做dog fooding, 或許就可以考量beta user whitelist的作法, 搭配Permission Toggles

類別只是一種定義而已, 真正使用狀況或許會更複雜搭配, 也可能需要考慮flags之間的相依性跟連動關係, 不過這篇並不是要討論這部分, 這篇會以簡單的實現Release toggle來先做探討

關於feature toggles資源跟相關探討也可參考 The Hub for Feature Flag Driven Development

Feature toggles的解決方案

有人提出方法, 就會有人提出解決方案和產品, Feature toggles相關的產品其實非常多, 商業產品有:

  1. LaunchDarkly
  2. Unleash
  3. HappyKit

另外也可以找到不少Open source的

  1. Togglez (Java)
  2. FF4J (Java)
  3. Finagle (Finagle有內建, 不過不好用)
  4. Feature Flags API for Go (Go)

你可以從The Hub for Feature Flag Driven Development找到一大堆

但這些絕大部分作法是把feature toggles的設定集中管理, 不管是放在資料庫, 或是有一個server來提供(後面也是放資料庫)

個人是覺得, Feature toggles並不是主角, 而是跑龍套的, 應該盡可能的輕量化, 做成獨立系統有點多了, 在Cloud native (k8s native)時代, 考慮用檔案來存設定(搭配ConfigMap), 加上lib, 應該是相對輕量的作法

因此針對這想法, 我在Next.js上做了幾個做法來驗證, 這邊把我的作法跟碰到的狀況做一個紀錄

基本設計

首先, 我先想了一下, 我在Next.js上要怎使用Feature toggles比較合適, 搭配tag應該是最為易讀的方式, 像是

<div>
    <WithFeature feature="feature1">
        <div>This is new feature</div>
    </WithFeature>
    <Link href="/">HOME</Link>
</div>

用一個區塊包藏住需要開關的部分, 再由讀取到的設定做為開關

另一個想到的方式是:

<PageWithFeature feature="feature3">
    <Link href="/">HOME</Link>
</PageWithFeature>

這種跟前一種的不同是, 如果功能未打開, 區塊就不會顯示, 但整頁的其他部分還會顯示, 但第二種應用在, 整頁都是新功能, 不想因為提早被發現URL而露出, 所以功能未打開的情況需要顯示404

另外config的設計希望是一個yaml檔案給程式去讀取, 像這樣:

features:
  feature1: false
  feature2: true
  feature3: true
  offline: false

使用Custom App跟React context來做一個通用設計

為何說是通用設計? 這邊是希望每個需要用到toggle的頁面不用自已寫載入設定的部分(後面會講個範例是有需要的), 而是只要直接用 <WithFeature feature="feature1"> 就好

Next.js有支援“Custom App, 所以我們載入config的部分可以直接放在"_app.tsx" 或 “_app.jsx“內就可以讓所有頁面共用(詳細請參考文件)載入部分的程式碼, 另外還得搭配React context才能順利的把設定下傳到component

首先, 我們需要先來設計一個Context用來傳遞設定給WithFeature的元件(Component)

import { createContext, useState } from "react";

type FeatureMap = {
    [key:string]:boolean
}

export const FeatureContext = createContext({features: {} as FeatureMap})

這邊設計很單純, 假設每個feature都是true或false的開關, 前面的config yaml也是這樣的

_app.tsx我們則需要有這東西:

import { FeatureContext } from '../context/featurecontext'
function MyApp({ Component, pageProps }: AppProps) {  
  return <FeatureContext.Provider value={{features:pageProps.features}}><Component {...pageProps}/></FeatureContext.Provider>
}

這邊是把pageProps裡的features給帶到FeatureContext以讓後面的可以讀到, 那pageProps是又哪裡來的? 這邊我們就得去實作app.getInitialProps了, 這個在每個page初始化都會呼叫到它, 並把它產生的pageProps給餵到前面那個function

那是不是就可以把下面這段直接放到getInitialProps就可以取得feature flags的設定了呢?

const configFile = path.join(process.cwd(), 'features.yaml')
const yaml = await fs.promises.readFile(configFile)
const config = YAML.parse(yaml.toString())

答案是"不行!!”, 為何? 因為在Next.js的設計上:

  1. getInitialProps是會有可能在client(瀏覽器), server跑
  2. 除非你頁面實作上有getServerSideProps, getInitialProps才會總是在server端跑
  3. 只有getServerSideProps,getStaticProps才可使用server端(node.js)API, 例如讀檔 (還有一個則是API, 但API實做不同, 我不規在此類)
  4. getServerSideProps只設計給頁面的實作使用, 所以每頁有自己的 getServerSideProps, 但 App沒getServerSideProps

因為client跟server都會有機會呼叫到, 如果要讓它們能夠共用, 那就只有做一個API給它, 我們在page/api目錄底下開一個features.tsx的檔案, 內容是

import { NextApiRequest, NextApiResponse } from "next"
import fs from 'fs'
import path from 'path'
import YAML from 'yaml'

type Features = {
    [key:string]:boolean
}

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<Features>
  ) {
    const configFile = path.join(process.cwd(), 'features.yaml')
    const yaml = await fs.promises.readFile(configFile)
    const config = YAML.parse(yaml.toString())
    
    res.status(200).json(config.features as Features)
  }

那我們getInitialProps就可以這樣寫:

MyApp.getInitialProps = async (appContext: AppContext) => {
   const appProps = await App.getInitialProps(appContext)
   const req = appContext.ctx.req
   var host = req
    ? req.headers["x-forwarded-host"] || req.headers["host"]
    : window.location.host;
   const resp = await fetch(`http://${host}/api/features`)
   const features = await resp.json()
   appProps.pageProps['features'] = features
   return { ...appProps }
 }

這邊有一點需要注意的, 雖然用fetch, 但它client端, server端用的是不一樣的, 雖然是長一樣的API, 所以這邊的URL是不可以用相對路徑的("/api/features”), 原因就是在server端只有相對路徑是無法知道實際要呼叫哪裡, 所以這邊還是組出一個完整的URL來使用(不過這實做有點不是很好, 需要改, 當PoC就算了 :p)

所以WithFeature就可以長這樣:

import { ReactNode, useContext} from "react"
import { FeatureContext } from "../context/featurecontext"

export type FeaturesProps = {
    children?:ReactNode
    feature?: string
}

const WithFeature = function(props:FeaturesProps) {
    const {features} = useContext(FeatureContext)

    if(features && props.feature && features[props.feature]) {
        return (
            <>{props.children}</>)   
    }
    return <></>
}

export default WithFeature

因為features是放在FeatureContext中, 所以我們可以透過useContext來取值

這作法不算難, 而且也蠻好運用, 只要在每個需要的頁面自行運用WithFeature即可, 但缺點是甚麼? 基本上它算是CSR(Client side rendering), 而且要甚麼flag都是透過API跟server要, 會被看的一清二楚外, 或許可能還有方法偽造以至於你的功能提早被發現

SSR (Server Side Rendering)的作法

Next.js強大的地方就是它有支援SSR(Sever side rendering)和SSG(Server side generation), 兩者的好處就是在server端就把頁面內容給產生好

假設我們有一頁叫做Post3, 實作是這樣的:

import { NextPage } from "next";
import { GetServerSideProps } from "next"
import fs from 'fs'
import path from 'path'
import Link from "next/link";
import YAML from 'yaml'
import WithFeature from "../components/feature2";
import PageWithFeature from "../components/feature3";

type Params = {}
type Props = {
    features: any
}

const Post3:NextPage<Props> = (props:Props) => {
    return (
        <FeatureContext.Provider value={{features:props.features}}>
            <PageWithFeature feature="feature3">
                <WithFeature feature="feature4">
                    this is new feature 3
                </WithFeature>                
                <Link href="/">HOME</Link>
            </PageWithFeature>
        </FeatureContext.Provider>
    )
}

export default Post3

export const getServerSideProps:GetServerSideProps<Props, Params> = async (ctx) =>{
    const configFile = path.join(process.cwd(), 'features.yaml')
    const yaml = await fs.promises.readFile(configFile)
    const config = YAML.parse(yaml.toString())

    return {
        props: {
            features: config.features
        }
    }
}

這頁面可以透過/post3來存取它, 這個頁面由於實做了getServerSideProps, 因此Next.js會使用SSR的模式, 這邊getServerSideProps就可以直接讀檔案了

但使用SSR這方法的話, 缺點就是

  1. 每個頁面都得自己把讀config加入getServerSideProps
  2. 也都需要自己把內容包在Context Provider如<FeatureContext.Provider value={{features:props.features}}>

就等於很多頁面都會有重複的程式碼, 不是那麼簡潔漂亮, 但優點應是內容在server端就已經先處理好了

PageWithFeature

上面有偷偷藏一個PageWithFeature, 這個實做可以是這樣:

import { ReactNode, useContext} from "react"
import Error from "next/error"
import { FeatureContext } from "../context/featurecontext"
import Offline from "./offline"

export type FeaturesProps = {
    features?:any
    children?:ReactNode
    feature?: string
}

const PageWithFeature = function(props:FeaturesProps) {
    const {features} = useContext(FeatureContext)

    if(features['offline']) {
        return <Offline></Offline>
    }
    
    if(features && props.feature && features[props.feature]) {
        return (
            <>{props.children}</>)   
    }
    
    return <Error statusCode={404} />
}

export default PageWithFeature

用途有兩個:

  1. Disable時導到404頁面
  2. 如果是系統下架狀態下, 導到一個暫時停止服務頁面(這邊用另一個component來解決)

開發流程上的思考

Atlassian這篇介紹Feature flags裡有張圖, 我覺得蠻有趣的, 可以做為開發流程上的一個參考:

Facebook這篇Rapid release at massive scale, 雖然跟Feature flags關係比較不大, 但也蠻有參考價值的

程式配置(Configuration)的熱更新(hot reload)應該是建置服務會常碰到一個題目, 常會有狀況需要在不動用release去調整程式配置的狀況, 比較常見的做法應該是將這些配置集中管理, 因此就有相關的解決方案產生像是:

真要找, 應該還有, 這種中央管理的方式, 無非就是想要把分布在不同系統的所有的設定, 做一個集中管理, 隨時可以進行線上更新, 不過帶來的問題點就是除了要綁定選定系統用相關的API開發外, 這類的服務也是有可能是SPOF

在Kubernetes原生(Kubernetes Native)的角度來看這件事, Kubernetes就有內建ConfigMap, Secret, 是否還有必要導入這類的解決方案? 利用ConfigMap是否可以達成線上做熱更新的目的? 我的想法是, 如果用ConfigMap做到熱更新, 那麼搭配 GitOps 的流程, 這樣就可以做到簡單又兼顧集中管理的特性了(更新紀錄在git都可以查到, 另外可以用PR確保更改配置的安全性, 避免誤更, 在多叢集配置下也可以分享同一個git repository)

使用ConfigMap

這邊沒特別要說明怎麼去用ConfigMap, 那個 官方文件 寫得很清楚, 先來看看ConfigMap在配合Pod/Deployment的兩個常見用法

先拿下面這範例來看:

apiVersion: v1
kind: ConfigMap
metadata:
  name: game-demo
data:
  # property-like keys; each key maps to a simple value
  player_initial_lives: "3"
  ui_properties_file_name: "user-interface.properties"

  # file-like keys
  game.properties: |
    enemy.types=aliens,monsters
    player.maximum-lives=5        
  user-interface.properties: |
    color.good=purple
    color.bad=yellow
    allow.textmode=true     

上面這個ConfigMap我們可以在Pod這樣使用它:

apiVersion: v1
kind: Pod
metadata:
  name: configmap-demo-pod
spec:
  containers:
    - name: demo
      image: alpine
      command: ["sleep", "3600"]
      env:
        # Define the environment variable
        - name: PLAYER_INITIAL_LIVES # Notice that the case is different here
                                     # from the key name in the ConfigMap.
          valueFrom:
            configMapKeyRef:
              name: game-demo           # The ConfigMap this value comes from.
              key: player_initial_lives # The key to fetch.
        - name: UI_PROPERTIES_FILE_NAME
          valueFrom:
            configMapKeyRef:
              name: game-demo
              key: ui_properties_file_name
      volumeMounts:
      - name: config
        mountPath: "/config"
        readOnly: true
  volumes:
    # You set volumes at the Pod level, then mount them into containers inside that Pod
    - name: config
      configMap:
        # Provide the name of the ConfigMap you want to mount.
        name: game-demo
        # An array of keys from the ConfigMap to create as files
        items:
        - key: "game.properties"
          path: "game.properties"
        - key: "user-interface.properties"
          path: "user-interface.properties"

一個是用valueFrom把ConfigMap裡面的設定拿來放在環境變數使用(參考上面範例)

另一個則是透過 volumes 把設定內容掛載成檔案

為了達成熱更新, 我們會有興趣的是, 當我們ConfigMap更新時, 相對應的內容會不會改變, 答案是只有第二種掛載成檔案的, 會隨之更新, 而第一種, 當ConfigMap更新時, 相關的環境變數是不會跟著變的

至於掛載成檔案的, 當ConfigMap內容做過更動時, 相對應的檔案內容也會更新, 但…不是即時的, 根據文件

The kubelet checks whether the mounted ConfigMap is fresh on every periodic sync. However, the kubelet uses its local cache for getting the current value of the ConfigMap. The type of the cache is configurable using the ConfigMapAndSecretChangeDetectionStrategy field in the KubeletConfiguration struct. A ConfigMap can be either propagated by watch (default), ttl-based, or by redirecting all requests directly to the API server. As a result, the total delay from the moment when the ConfigMap is updated to the moment when new keys are projected to the Pod can be as long as the kubelet sync period + cache propagation delay, where the cache propagation delay depends on the chosen cache type (it equals to watch propagation delay, ttl of cache, or zero correspondingly).

也就是說預期會有根據你設定是用watch, ttl-based, 全透過API取得更新跟cache時間造成的時間差, 也就是雖然ConfigMap也是一種集中式管理(放在etcd), 但實際上還是會有數秒到數十秒的更新時間差(我實測最多碰到一分鐘後才更新)

因此如果需要做到配置的熱更新, 那我們可以選擇是第二種掛載成檔案的作法, 藉由監控檔案內容的改變, 再由程式去做熱更新

觀測ConfigMap的異動狀況

既然是檔案, 那我們可不可以由Linux的inotify去監控檔案的異動狀況? inotify是Linux核心的一個系統呼叫, 現在主流伺服端的程式設計應該也比較少用C直接去呼叫這些System call了吧? 不過, 基本上還是可行的, 這邊有一篇"用 Sidecar 应用 Configmap 更新", 這邊就用 inotifywait 這個指令放在sidecar中去監控config檔案, 在有變動時, 發送訊號重啟主程序

這方法的優點是, 程式可以不用自行監控ConfigMap的變化, 缺點就是, 重啟這件事是不可控的, 當你的服務有多個實體(instance)時, 也有可能這些全部會在同一時間被重啟, 造成你的服務被下線

另外一個就是在程式內自行監控, 現在Kubernetes大行其道, 已然是顯學, 如果已經採用它來管理配置系統的話, 在設計上配合它來做, 也是無可厚非, Dev要能針對Ops來設計, 才能真的有DevOps, 更何況這部分只需要監控檔案, 並不需要綁死Kubernetes API

監控檔案異動的作法, 各語言有自己包裝, golang有fsnotify, Java則有nio裡的WatcherService

這邊先以Java Nio做一個簡單的測試(實際是以Kotlin實作):

suspend fun watchConfig(configFileName: String) {
	val dir:Path = Paths.get(configFileName).parent
	val fileName = Paths.get(configFileName).fileName

	val watcher = FileSystems.getDefault().newWatchService()

	dir.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY)
	while(true) {
		val key =watcher.take()
		key.pollEvents().forEach { it ->
			if(it.context() == fileName) {
				reloadConfig()
			}
		}

		if(!key.reset()) {
			key.cancel()
			watcher.close()
			break
		}
	}
}

以前面config map掛載的範例來看的話, 假設, 我們用 watchConfig("/config/game.properties") 來監控"/config/game.properties", 這邊的"/config/game.properties"是由ConfigMap裡的 game.properties 來的, 所以變更這邊的game.properties, “/config/game.properties"也會跟著改變

但, 上面這段程式是"完全沒用的”, 即使 game.properties/config/game.properties內容都被改變了, 這邊的 reloadConfig() 也完全不會被觸發!!!! 如果使用golang的fsnotify, 也會是一樣的狀況

為什麼呢? 難道是這樣掛載的檔案有啥特異? 先來 ls -l看一下:

ls -l /config/game.properties
lrwxrwxrwx 1 root root 24 Aug 21 16:55 /config/game.properties -> ..data/game.properties

這邊可以發現/config/game.properties是一個Symbolic link連到..data/game.properties去, 這樣就導致我們監控不到它嗎? 其實還不只, 再來ls -l ..data看看

ls -l ..data
lrwxrwxrwx 1 root root 31 Aug 21 16:58 ..data -> ..2021_08_21_16_56_33.873456784

Ok, ..data也是一個Symbolic link, 所以實際上ConfigMap被變更過後, 真正檔案變更大guy會是這樣的:

CREATE ..2021_08_21_16_58_04.661956783
CREATE ..2021_08_21_16_58_04.661956783/game.properties
CREATE ..data_tmp (link to ..2021_08_21_16_58_04.661956783)
MOVE ..data_tmp ..data
DELETE ..2021_08_21_16_56_33.873456784

所以我們原本直覺應該會是認為它是會直接變更/config/game.properties內容, 但實際上/config/game.properties是一直沒被變動的, 它一直是一個連結到/config/..data/game.properties的Symbolic link, 所以觀測對象是不對的, 因此得這樣改:

suspend fun watchConfig(configFileName: String) {
	var path:Path = Paths.get(configFileName)
	val parent:Path = path.parent

	while (Files.isSymbolicLink(path)) {
		path = Files.readSymbolicLink(path)
	}

	val realParent:String = path.parent.name
	val watcher = FileSystems.getDefault().newWatchService()

	parent.register(watcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY)
	while(true) {
		val key =watcher.take()
		key.pollEvents().forEach { it ->
			if(it.context() == realParent) {
				reloadConfig()
			}
		}

		if(!key.reset()) {
			key.cancel()
			watcher.close()
			break
		}
	}
}

這邊的realParent其實就是..data, 有變動的會是它, 所以監控它就好了

使用golang的spf13/viper

如果你是用golang並且是用sp13大神的viper, 來管理設定檔, 那你只需要透過viper.WatchConfig()來監控ConfigMap掛載下來的設定檔就好

viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
	fmt.Println("Config file changed:", e.Name)
})

這是因為viper有針對這一狀況修正過, 有興趣可以參考“WatchConfig and Kubernetes (#284)”這段

Reloader

如果程式不想配合著改, 或大部分都是透過環境變數的方式來使用ConfigMap的話, 又怕使用前面inotify sidecar的作法會造成問題, 希望有更好的方式去RollOut, 那可以參考一下Reloader

Reloader會去監控ConfigMap跟Secret的變動, 來重啟跟他們有相關的DeploymentConfigs, Deployments, Daemonsets Statefulsets 和 Rollouts, 由於它是以Kubernetes conrtroller的形式存在, 並且採用Kubernetes API去監控資源: https://github.com/stakater/Reloader/blob/99a38bff8ea1346191b6a96583d3fbad72573ea5/internal/pkg/controller/controller.go#L47

安裝方法很簡單, 只需要用:

kubectl apply -f https://raw.githubusercontent.com/stakater/Reloader/master/deployments/kubernetes/reloader.yaml

裝到你所需要的namespace即可, 然後在你的Deployment設定上加上一個annotation reloader.stakater.com/auto: "true":

apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  annotations:
    reloader.stakater.com/auto: "true"

這樣reloader就會幫你監控這個Deployment用到相關的ConfigMap跟Secret, 不管是用環境變數的方式, 還是掛載檔案的方式, 都適用, 並且由於它是直接透過Kubernetes API, 因此ConfigMap或是Secret有變化都是即時會監測到, 然後它就會用rolling update的方式去重啟相關的instances, 相較之下會比用sidecar的方式保險

現在流行甚麼東西都要包成Docker image來部署, 每一個都要寫一個Dockerfile, 每個又很類似, 建置完程式碼又得立刻跑Docker來建置Docker image, 如果有更簡單的方法可以一次做完, 應該會省事很多

以下提到的這些方法可以一行Dockerfile都不用寫

使用gradle把Java application建置並包裝成Docker image

要達到這個目的, 可以使用 com.bmuschko.docker-java-application - https://bmuschko.github.io/gradle-docker-plugin/#java-application-plugin 這個Gradle plugin, 這個plugin會去呼叫Docker client API, 所以使用前確定你有啟動dockerd

先來看一下底下這範例, 這是一個用Ktor寫的server application:

val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project

plugins {
    application
    kotlin("jvm") version "1.5.21"
    id("com.bmuschko.docker-java-application") version "6.7.0"
}

group = "co.jln"
version = "0.0.1"
application {
    mainClass.set("co.jln.ApplicationKt")
}

docker {
    javaApplication {
        baseImage.set("openjdk:11-jre")
        ports.set(listOf(8080))
        images.set(setOf("julianshen/myexamplekt:" + version, "julianshen/myexamplekt:latest"))
        jvmArgs.set(listOf("-Xms256m", "-Xmx2048m"))
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
}

雖然是kotlin寫的, 不過這是一個標準的Java Application, 有它的main class, 我們要加上的只有 id("com.bmuschko.docker-java-application") version "6.7.0"docker {} 內的內容而已, 基本主要就是baseImage跟image的名字就好了

執行 gradle dockerBuildImage 就可以直接幫你把程式建置好並包裝成docker image了

如果是要把image給push到repository上的話, 執行 gradle dockerPushImage

如果你的不是一般的Java application 而是Spring boot application的話, 則是可以用 com.bmuschko.docker-spring-boot-application 這個plugin而不是上面那個, 不過如果是Spring boot 2.3之後, 還有另一個方法

直接把Spring Boot應用程式建置成Docker image

如果是Spring Boot 2.3之後, 因為內建就有支援 Cloud Native Buildpacks , 所以直接就可以建置成docker image , 蠻簡單的, 只要執行

gradle bootBuildImage

不過, 它image的名字會是 library/project_name , 所以如果你需要用其他的名字取代的話, 有兩種方法, 一種是加上 --imageName 給定名字, 像是:

gradle bootBuildImage --imageName=julianshen/springsample

另一種是把這段加到 build.gradle.kts 去(這邊以kotlin當範例):

tasks.getByName<org.springframework.boot.gradle.tasks.bundling.BootBuildImage>("bootBuildImage") {
	docker {
		imageName = "julianshen/sprintsmaple"
	}
}

這個的缺點是, 不像前面提到的plugin有支援push, 如果你需要把建置好的結果放到repository上的話, 就得自己執行 docker push

把Scala application包裝成Docker image

如果是Scala就需要用到 SBT Native Packager

用法也不難, 首先先把下面這段加到 plugins.sbt 去:

addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6")

build.sbt 內加入:

enablePlugins(DockerPlugin)

然後執行 sbt docker:publishLocal即可, 相關設定可以參考 Docker plugin的文件