自從離職後, 就沒一個方便的環境可以來做實驗, 家裡的desktop要裝k8s雖然是夠, 但出門的話, 我只有一台六七年的notebook改了SSD裝了Linux, 還是想在這台NB裝K8S拿來玩一些東西

MicroK8s

MicroK8s 算是一個不錯的選擇, 輕量化, 單機可以跑, 重要的是, 可以隨時開關, 對於我這台老電腦來說, 需要的時候再開就好

安裝方式很簡單(Linux下需要先有snap):

sudo snap install microk8s --classic

使用 microk8s status 可以看目前狀態, microk8s start可以開始執行, microk8s stop即可停止

# microk8s status
microk8s is not running, try microk8s start
# microk8s start
[sudo] password for julianshen:            
Started.
# microk8s status
microk8s is running
high-availability: no
  datastore master nodes: 127.0.0.1:19001
  datastore standby nodes: none
addons:
  enabled:
    cilium               # SDN, fast with full network policy
    dashboard            # The Kubernetes dashboard
    dns                  # CoreDNS
    ha-cluster           # Configure high availability on the current node
    helm                 # Helm 2 - the package manager for Kubernetes
    helm3                # Helm 3 - Kubernetes package manager
    ingress              # Ingress controller for external access
    metrics-server       # K8s Metrics Server for API access to service metrics
    prometheus           # Prometheus operator for monitoring and logging
    registry             # Private image registry exposed on localhost:32000
    storage              # Storage class; allocates storage from host directory
  disabled:
    ambassador           # Ambassador API Gateway and Ingress
    fluentd              # Elasticsearch-Fluentd-Kibana logging and monitoring
    gpu                  # Automatic enablement of Nvidia CUDA
    host-access          # Allow Pods connecting to Host services smoothly
    istio                # Core Istio service mesh services
    jaeger               # Kubernetes Jaeger operator with its simple config
    keda                 # Kubernetes-based Event Driven Autoscaling
    knative              # The Knative framework on Kubernetes.
    kubeflow             # Kubeflow for easy ML deployments
    linkerd              # Linkerd is a service mesh for Kubernetes and other frameworks
    metallb              # Loadbalancer for your Kubernetes cluster
    multus               # Multus CNI enables attaching multiple network interfaces to pods
    openebs              # OpenEBS is the open-source storage solution for Kubernetes
    openfaas             # openfaas serverless framework
    portainer            # Portainer UI for your Kubernetes cluster
    rbac                 # Role-Based Access Control for authorisation
    traefik              # traefik Ingress controller for external access

如果是正在執行的狀態下, microk8s status 可以看到有哪些可用的addon, 如果要啟動其中一個addon(例如trafik), 也只要執行 microk8s enable traefik, 非常簡單

最基本來說, 你可以使用 microk8s kubectl 來執行相關的 kubectl指令, 如果要方便的GUI界面來管理的話, 也可以透過啟動dashboard:

# microk8s enable dashboard
Enabling Kubernetes Dashboard
Addon metrics-server is already enabled.
Applying manifest
serviceaccount/kubernetes-dashboard created
service/kubernetes-dashboard created
secret/kubernetes-dashboard-certs created
secret/kubernetes-dashboard-csrf created
secret/kubernetes-dashboard-key-holder created
configmap/kubernetes-dashboard-settings created
role.rbac.authorization.k8s.io/kubernetes-dashboard created
clusterrole.rbac.authorization.k8s.io/kubernetes-dashboard created
rolebinding.rbac.authorization.k8s.io/kubernetes-dashboard created
clusterrolebinding.rbac.authorization.k8s.io/kubernetes-dashboard created
deployment.apps/kubernetes-dashboard created
service/dashboard-metrics-scraper created
deployment.apps/dashboard-metrics-scraper created

If RBAC is not enabled access the dashboard using the default token retrieved with:

token=$(microk8s kubectl -n kube-system get secret | grep default-token | cut -d " " -f1)
microk8s kubectl -n kube-system describe secret $token

In an RBAC enabled setup (microk8s enable RBAC) you need to create a user with restricted
permissions as shown in:
https://github.com/kubernetes/dashboard/blob/master/docs/user/access-control/creating-sample-user.md

# microk8s dashboard-proxy 
Checking if Dashboard is running.
Dashboard will be available at https://127.0.0.1:10443
Use the following token to login:
[TOKEN]

不過, 我個人是比較偏好用Lens

Lens

Lens的界面蠻簡單直覺的, 功能也蠻強大的, 除了管理你的cluster外, 也可以作到簡單的監控, 同時也可以管理多個cluster, 在啟動Lens後, 到"Clusters Catalog", 會發現沒有任何的一個cluster, 也沒有剛剛啟動的MicroK8S cluster

有兩個方法可以加入剛剛創建的MicroK8s cluster, 第一個是按下那個"+“按鈕:

這時候把k8s config貼進去就好, 這邊要注意的一點是, 本來獲取k8s config可以用 kubectl config view , 在MicroK8s下, 如果沒特別設定, 都是用 microk8s kubectl 取代 kubectl, 但這邊, 如果你用 microk8s kubectl config view 去取得k8s config的話, 貼上去, Lens是會連不上你的cluster的

這邊應該用 microk8s config才對, 這個才能讓你的Lens正確連上

另一個方式是執行 microk8s config > ~/.kube/config , 這樣Lens就會自動抓到了, 這兩種的優缺點是, 直接在Lens設定k8s config的話, 管理多個clusters時, 可以不用一直切換context, 如果直接使用 “.kube/config” 的話, 則是, 你也可以直接使用 kubectl來操作你的cluster(就不需要用microk8s kubectl)

最後要做的步驟就是連接了, 按下"Connect"即可

在MicroK8s這邊, 要記得enable prometheus , Lens會去偵測Prometheus operator並抓取相關的metric資訊顯示在界面上

本來我寫blog畫流程圖都用Mermaid, 不過由於不好預覽, 要畫比較複雜的圖也有點麻煩

Draw.io是一個不錯的免費工具, 蠻便利好用的, 但缺點就是是網頁版的, 要跟平常寫文章的流程整合比較不方便, 由於我是在vscode上寫文章的, 所以發現了 Draw.io Integration 這個vscode的extension, 蠻方便的

除了可以直接在vscode上編輯流程圖外, 如果你的檔名是"XXXX.drawio.png", 或是 “XXXX.drawio.svg” 畫完後就直接輸出成png/svg, 就可以直接拿來用了, 有問題也可以直接改, 不用轉檔轉來轉去的

在離職前一周研究的一個小題目, 說小其實也蠻難搞的, 搞到這兩天重新看, 才釐清完整做法

難搞的原因有幾個, 雖然OpenTelemetry有支援gRPC, 但對於 Thrift 就沒人做相關的支援了, 再來就是系統環境跨了nodejs和Finagle/Scala兩種平台, Thrift 是用在這兩者之間的溝通, Finagle雖是有支援ZipKin做分散式追蹤, 但那僅限於Finagle client呼叫Finagle server的部分才有支援在這之間傳遞追蹤資訊, 跨nodejs (client) 到 Finagle (server), 這邊也一樣找不到啥資訊

所以這邊主要會想做到的:

  1. 自動插入追蹤的程式碼
  2. 在 Thrift client/server 間傳遞追蹤資訊 (client/server不同平台)

大致上的原理有做過些小實驗, 確定應該可行, 只是懶得把整套完整做好就是了

分散式追蹤 Distributed Tracing

在大型的分散式系統, 一個從使用者端來的request通常都會被分發到不同的系統去做處理, 尤其現在大多流行微服務(Micro services)架構, 這種狀況相當的常見, 當問題發生的時候, 到底甚麼時間點在哪個系統, 碰到甚麼事, 要追查原因便得從這麼多系統分散且看不出關聯性的log去想辦法分析出來, 因此導入分散式追蹤, 就是為了解決這問題

最早出現應該是Google內部使用的Dapper, 也有發表相關的論文, 開源的部分, 早期又有Twitter的ZipKin和Uber的Jaeger, 前面有提到的Finagle, 由於也是Twitter開源出來的應用程式框架, 所以Finagle出廠就支援ZipKin也是理所當然的

後來又出現想要大一統的OpenTracingOpenCensus, 這兩個後來又被大一統到這邊所要提到的OpenTelemetry

做Distributed Tracing雖然對追問題會有幫助, 但要導入並不見的容易, 先是要在所有要追蹤的插入追蹤程式碼, 對於既有系統的改動幅度自是不小, 此外, 早期, 不管是ZipKin和Uber的Jaeger還是Jaeger考量的主要還是REST API的架構, REST是透過HTTP傳輸的, 因此在設計上, 就可以透過HTTP header帶追蹤相關資訊, 但在一個複雜的分散式系統, 可能包含不同的通訊協定, 像是REST, GraphQL, gRPC, Thrift, 或是呼叫資料庫之類的, 不見得都是透過HTTP, 那怎麼傳遞追蹤資訊就是個問題, 跨系統間如果無法分享追蹤資訊, 那也是白搭

OpenTelemetry

OpenTelemetry其實也不是只有支援Distributed Tracing, 它能處理的資料型態, 主要就有下面這幾種:

  1. Traces
  2. Metrics
  3. Logs

也就是說除了追蹤資訊, 它也囊括了系統狀態跟Logs, 另外也支援很多不同語言, 算是野心蠻大的, 這邊來看一下它的架構:

主要它包含了兩部分, 一個是各程式語言使用的程式庫 - OT Library, 另一個是蒐集資訊的Collector, 而Collector是這樣的:

Collector包含了Receiver, Processor, Exporter, 這架構讓它有能力相容/支援不同的系統, 所以像是Finagle這種本來就有支援ZipKin的, 其實只要把原本倒到ZipKin的資料轉倒到OpenTelemetry的Collector就好, 這邊算是好解決, 如果系統是跑在K8S這類的環境上的話, 也可以考慮把Collector 當成sidecar來佈署

而各程式語言的程式庫的部分, 方便的是在某些程式語言有支援所謂的auto instrumentation, 針對有支援的程式庫或是框架, 可以在不寫任何程式碼或是寫少少的程式碼, 就可以達到分散式追蹤的目的(聽來有點玄), 像是Java就支援了這些(請參考連結), 而Javascript有這些(請參考連結)

但畢竟沒有甚麼是萬能的, 沒支援的還是得靠自己手動插追蹤的程式碼, 或是想辦法支援, 像是這篇正題的部分, 這邊想要追蹤從nodejs呼叫Finagle的部分, 就沒辦法使用現成的 (實際狀況更複雜, nodejs本身是graphql server, Finagle server又可能呼叫ElasticSearch或Kafka, 如果想全部串起來, 不算小, 這邊主要針對 nodejs <-> Finagle部分)

在Node.JS下用OpenTelemetry做Tracing

基本使用上其實相當簡單, 可以參考這個連結, 先用一個小範例來解釋:

const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { GrpcInstrumentation } = require('@opentelemetry/instrumentation-grpc');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
const { ConsoleSpanExporter, SimpleSpanProcessor } = require('@opentelemetry/tracing');
const { NodeTracerProvider } = require('@opentelemetry/node');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');

const provider = new NodeTracerProvider();

provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));
provider.register();

registerInstrumentations({
  instrumentations: [
      new HttpInstrumentation(), 
      new GrpcInstrumentation(),
      new ExpressInstrumentation()
      ],
});

以這範例來說, 它打開了支援http, grpc, express等程式庫的auto instrumentation, 亦即在你的程式中如果有用到這幾個程式庫, 它會自動加上對應的追蹤程式碼, 你不用額外做任何事, 從client到server都處理好, 或是你也可以像文件中用:

// This will automatically enable all instrumentations
registerInstrumentations({
  instrumentations: [getNodeAutoInstrumentations()],
});

getNodeAutoInstrumentations()包含了底下這幾種的資源:

  • @opentelemetry/instrumentation-dns': DnsInstrumentation
  • @opentelemetry/instrumentation-express': ExpressInstrumentation
  • @opentelemetry/instrumentation-graphql': GraphQLInstrumentation
  • @opentelemetry/instrumentation-grpc': GrpcInstrumentation
  • @opentelemetry/instrumentation-http': HttpInstrumentation
  • @opentelemetry/instrumentation-ioredis': IORedisInstrumentation
  • @opentelemetry/instrumentation-koa': KoaInstrumentation
  • @opentelemetry/instrumentation-mongodb': MongoDBInstrumentation
  • @opentelemetry/instrumentation-mysql': MySQLInstrumentation
  • @opentelemetry/instrumentation-pg': PgInstrumentation
  • @opentelemetry/instrumentation-redis': RedisInstrumentation

建議如果沒要追蹤這麼多東西的話, 還是一個個加就好, 畢竟資訊多雜訊也多

在這邊:

const provider = new NodeTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));

這兩行是建立Trace Provider, 告訴它要用哪個Processor或哪個Exporter去處理追蹤資訊, 這跟前面提到的Collector的架構上大致類似, 這邊用的是Consle exporter,也就是追蹤資訊會被直接印在螢幕上, 如果想輸出到ZipKin或是Jaeger就用相對應的Exporter就可以了, 或者也可以用OTLP的Exporter直接輸出到OpenTelemetry的collector

但這是在有支援的狀況下, 如果沒有呢? 就得手動去插了, 看一下下面這範例:

const opentelemetry = require('@opentelemetry/api');
const tracer = opentelemetry.trace.getTracer('example-basic-tracer-node');

// Create a span. A span must be closed.
const span = tracer.startSpan('main');
doWork();
// Be sure to end the span.
span.end();

這是簡單追蹤一個程序的方法, 在這範例是doWork(), 這邊就可以追蹤從startSpanend之間的耗費的時間了, 針對沒有支援auto instrumentation, 或是你想額外在你程式內追蹤些別的, 那就得用這種方式在需要追蹤的地方加入這些

很不幸的, 目前不管哪個語言, Java, Javascript, 都沒支援Thrift相關的, 所以如果要追蹤 Thrift, 可能就得是這樣, 除了可能需要改不少地方外, 插入這些code其實也不太好看啦 :p

追蹤 Thrift RPC

Thrift算是一個有點歷史的RPC框架(framework)了, 雖然應該還有不少大公司像是Twitter, Facebook, LINE, LinkedIn還有在使用, 不過現在大家大部分應該是比較常用比較潮的gRPC, 比較少用Thrift了, 所以在OpenTelemetry這種新東西找不到支援應該也情有可原

為了比較好確認解決這問題的概念是怎樣, 這邊先把問題/架構先簡化如下:

  1. Thrift client: 跑在nodejs下, 以typescript開發
  2. Thrift server: 跑在Twitter Finagle框架, 以scala開發 (事實上, 我也有實做一個go版本的server, 不過先不在這討論)

所以這邊會需要知道的是:

  1. client呼叫每個Thrift call需要的時間
  2. 在server上每個call又對應哪些呼叫或花費

用以下ZipKin這張圖來當範例, 就可以這樣一層層追蹤下去

ZipKin

Client部分雖然可以使用手工插入tracing相關的程式碼, 但當然還是做成自動的最好, 而且client必須要可以把相關的trace ID, span ID給傳遞到server, 要不然線索就會斷掉了

為了達到這目標, 首先我們先來看一下Thrift從Client到Server經過哪些地方:

Thrift

從這圖看來, 可能可以插入追蹤碼的點可以是產生出來的Client code或是TProtocol的位置(為何?後面再提)

在前面我也寫了一篇"在nodejs使用typescript呼叫thrift client“裡面有提到利用thrift -r --gen js:ts smaple.thrift來產生nodejs用的client code

以下面這個Thrift IDL來當範例:

namespace java sample.thrift
#@namespace scala sample.thrift
namespace go rpc

service SampleService {
    string hello(1: i64 a, 2: i64 b)
    void hello2()
}

thrift -r --gen js:ts sample.thrift就可以產生四個檔案, 分別是:

  1. sample_types.js
  2. sample_types.d.ts
  3. SampleService.js SampleService的定義
  4. SampleService.d.ts SampleService的javascript實作(Client + Processor)

再仔細去看SampleService.js, 以hello這個method為例, 你會發現在 SampleServiceClient 裡關於hello的部分有三部分:

  1. hello(a, b, callback) 實際給程式呼叫的介面, 這邊回傳是個Promise
  2. send_hello(a, b) 會由hello去呼叫, 實際上負責傳遞呼叫的相關資訊
  3. recv_hello(input,mtype,rseqid) 當send_hello送出呼叫資訊到server後, Connection會等到Server回應後, 會呼叫 recv_functionname, 去處理回傳回來的資訊

另外在 send_hello 的一開始會去呼叫 output.writeMessageBegin('hello', Thrift.MessageType.CALL, this.seqid()); , 這邊的output是TProtocol, 在呼叫 recv_hello 之前則是會呼叫 input.readMessageBegin() 這邊也可以得到呼叫的method的資訊

由上面的線索看來, 可以插入追蹤程式碼可能的幾個點:

  1. hello(a, b, callback) 的一開始到Promise結束
  2. send_hello(a, b)recv_hello(input,mtype,rseqid)的結束
  3. writeMessageBeginreadMessageBegin

這邊問題在於 hello, send_hello, recv_hello都是由thrift這個指令產出的, 而writeMessageBegin, readMessageBegin則是在thrift的程式庫內

我們要怎樣在裡面插入追蹤的程式碼?或是有沒辦法做到auto instrumentation那樣?

Javascript auto instrumentation in OpenTelemetry

OpenTelemetry其實是有開放介面給大家去開發相關的auto instrumentation, 不過這一塊實在看得有點頭痛, 沒文件, 又不好懂, 我最後沒採用這方法實作, 但因為在這邊花了不少時間, 還是簡單的介紹一下

前面有提到的有許多auto instrumentation的實作, 都是被放到 opentelemetry-js-contrib/plugins/node, 也就是說你可以用一樣的方法做出自己的auto instrumentation

其架構的原始碼可以參考opentelemetry-js/packages/opentelemetry-instrumentation, 至於如何去寫一個plugin則可以參考 這篇

基本的plugin大致上像這樣:

import type * as mssql from 'mypackage';
import {
    InstrumentationBase,
    InstrumentationConfig,
    InstrumentationModuleDefinition,
} from '@opentelemetry/instrumentation';
 
type Config = InstrumentationConfig ;
 
export class MYPlugin extends InstrumentationBase<typeof mypackage> {
       
    protected init(): void | InstrumentationModuleDefinition<any> | InstrumentationModuleDefinition<any>[] {
        throw new Error('Method not implemented.');
    }
}

Plugin必須繼承自InstrumentationBase, 最好的範例應該是 http的instrumentation的實作, 在這裏面, 你會看到像是:

this._wrap(
          moduleExports,
          'request',
          this._getPatchOutgoingRequestFunction('http')
        );

這目的就是為了把原本的函數替換成包裝過有插追蹤碼的程式, 原理其實很容易理解, 而它是用了 shimmer 這個package, 來達到這個替換的目的, 實際上去看 shimmer, 也並不是一個很複雜的做法就是了

本來我是考慮寫一個plugin來處理Thrift client的部分, 原本的考量點是, 由於 shimmer 需要先知道method的名字才能替代, 所以 hello, send_hello, recv_hello 就不適合用來做包裝, 畢竟要做也是要做一個通用的, 不然試作後, 單純包裝 hello 其實算容易 (在呼叫原版本hello前先startSpan, 並把span.end包裝到回傳的Promise), 所以適合用在這邊的可能是包裝 TProtocol.writeMessageBegin, TProtocol.readMessageBegin ,不過這邊一直弄不成功, 可能也沒搞很懂instrumentation plugin, 後來又發現更簡便的做法就先放棄

從 thift generator 下手

在用 shimmer 包裝 hello 時, 發現了一個問題, 由於我是用 typescript 而非javascript 在做這個實驗, typescript會去做型別檢查, 本來Javascript版本的 hello 的回傳是Promise, 但我在定義wrapped function的時候, 回傳型別設成 Promise<string> 則是會報錯, 結果實際上去看產生的程式碼:

hello(a: Int64, b: Int64): string;

這完全是錯的, 也就是由Apache thrift這個工具產生的typescript是有問題的

想到在Scala中, 產生Thrift相關程式碼是用scrooge並不會去用官方Apache thrift的工具, typescript會不會也有像scrooge這工具? 結果就找到了creditkarma/thrift-typescript

這個專案也是蠻有趣的, 它是透過 Typescript compiler API, 把Thrift IDL完全轉成typescript程式碼, 跟官方工具不同的地方是, 它產生的是純typecsript實做, 而非javascript實做搭配typescript定義, 因此產生的程式碼也好讀多了

所以我想, 何必一定糾結在auto instrumentation, 從code generator 去修改也是一個可行的做法, 要做到這件事, 那就要先看看, 我們預期它產生怎樣的程式碼, 於是我就去修改產生的程式碼來實驗, 像這樣:

export class Client {
    public _seqid: number;
    public _reqs: {
        [name: number]: (err: Error | object | undefined, val?: any) => void;
    };
    public output: thrift.TTransport;
    public protocol: new (trans: thrift.TTransport) => thrift.TProtocol;
    public tracer:opentelemetry.Tracer;
    private serverSupportTracing: boolean;

    constructor(output: thrift.TTransport, protocol: new (trans: thrift.TTransport) => thrift.TProtocol) {
        this._seqid = 0;
        this._reqs = {};
        this.output = output;
        this.protocol = protocol;
        this.tracer = opentelemetry.trace.getTracer('SampleServiceClient');
        this.serverSupportTracing = false;
    }
    public incrementSeqId(): number {
        return this._seqid += 1;
    }
    
    public hello(a: Int64, b: Int64): Promise<string> {
        const requestId: number = this.incrementSeqId();
        const span:opentelemetry.Span = this.tracer.startSpan("hello");
        return new Promise<string>((resolve, reject): void => {
            this._reqs[requestId] = (error, result) => {
                delete this._reqs[requestId];
                
                if (error != null) {
                    reject(error);
                }
                else {
                    resolve(result);
                }
                span.end();
            };
            this.send_hello(a, b, requestId);
        });
    }
}

這一段程式碼是截自 creditkarma/thrift-typescript 從我的IDL產生的程式碼, 加上了tracer跟span, startSpanspan.end就插在hello裡面

這一段先用手工插入實驗後沒問題, 接下來我們就可以去改 creditkarma/thrift-typescript 讓程式自動去產生

由於這邊牽涉多一點, 我就不一一解釋, 貼上我修改的commit, 大家有興趣可以參考 : https://github.com/julianshen/thrift-typescript/commit/5f2ebeb85f6e639be11d5184f5470ca8d4d466b9

這樣一來, 產生我們要的client code就沒啥問題了

傳遞追蹤資訊

前面有提到Finagle有支援Zipkin Tracing, 只有client和server都是Finagle才可以在Thrift間傳遞追蹤資訊, 那實際上Finagle又是怎做的呢? 它的做法是在Thrift的通訊協定上做了一些小修改, 先來看看底下這三張圖

Twitter Thrift

第一張是通常狀況, 在兩端都不支援傳遞追蹤資訊, 或是Client不支援, 就是走正常的路線, 第二三張則是在Client有支援(Finagle client), client會先送__can__finagle__trace__v3__這個呼叫確認server有支援, server如果有支援的話, 就會回傳正確的結果, 如果沒支援則會是 UNKNOW_METHOD

Client在確認server有支援後, 後面的request就會先多帶一個header包含Tracing相關的資訊了

這部分, 我目前也只實做go版本的server, client版本尚未做, 這邊會需要做的部份包含:

  1. 呼叫 __can__finagle__trace__v3__ 確認是否支援tracing
  2. 將client端的tracing資訊帶入相關的header中

如果client是用OpenTelemetry, 而server這邊是用Finagle加zipkin的話, 就得要注意Trace ID, Span ID的轉換, 這兩邊用的長度跟型別有點不太一樣, 轉換的範例如下:

func UInt64ToTraceID(high, low uint64) pdata.TraceID {
	traceID := [16]byte{}
	binary.BigEndian.PutUint64(traceID[:8], high)
	binary.BigEndian.PutUint64(traceID[8:], low)
	return pdata.NewTraceID(traceID)
}

(Source: https://github.com/open-telemetry/opentelemetry-collector/blob/6ae558c8757cad4ed29f7c9496b38827990f156f/internal/idutils/big_endian_converter.go#L24)

只要在把這整段整合到code generator, 應該就可以大功告成了

雖然一開始覺得是個小題目, 沒想到居然讓我用這麼大篇幅介紹, 而且全部實做還沒完成, 看來是有點太過低估了

之前一篇寫用Hugo打造blog有提到, 寫了一個小工具來產生open graph image, 但由於這個是一個command line工具, 而我又是把它放在 git pre-commit 去觸發並寫回檔案, 感覺不是那麼乾淨, 因此我又有另一個想法想把它寫成一個服務, 順便又思考了一下, 怎樣的圖片比較適合OG(Open Graph)

為什麼要設定 Open Graph Image

這邊並沒有要探討Open Graph是什麼, 怎麼去使用, 這應該可以找到其他相關的文章或是參考官網 https://ope.me/

目前OG會影響到的, 大概就是被分享到社群的文章/網頁, Facebook跟Twitter都支援OG來產生分享到Timeline上的預覽, Twitter則是另外支援它自己的Twitter Card, 而這其中 og:image 會影響到分享後的版面

首先, 這邊先介紹一下工具, 如果要先預覽Facebook分享出去的結果可以使用Facebook提供的 分享偵錯工具, Twitter部分則可使用Card validator

先來看看沒設定og:image的版面是怎樣一個狀況?

OG No Image

Twitter card No Image

前面一個是分享到Facebook上的版面, 後一個則是在Twitter上會看到的, 很明顯的版面偏小, 不起眼, 分享出去後應該也引不起使用者點擊的慾望

這邊Twitter跟Facebook不太一樣的地方是, Facebook決定大小版面是以og:image的大小來決定的, 大版面的圖片需要有1200x630的解析度, 但對Twitter來說, 你如果設定Twitter card的型別是 “summary_large_image” , 那就會選擇使用大版面來顯示, 因此如果圖不是很大的話, 會像下個範例:

OG Small

Twitter card small Image

這邊Twitter card使用的是 “summary_large_image” , 因此可以看到結果(第二張), 版面是較大的版面, 但圖片的部分就糊的有點慘了, 而且, 重點被截到了, Facebook圖雖沒那麼糊, 但版面依然很小

現在應該很多網站都有注意使用了大圖來做為og:image了, 效果就像是這樣:

OG Big

這樣版面是大得很明顯了, 但似乎有點問題, 這邊用的是LOGO, 但它賣的是"【JOYOUNG 九陽】LINE FRIENDS系列真空悶燒罐 熊大", 沒商品圖片, 我不知道大家是怎看, 至少, 不吸引我

再來看另一個例子:

OG PCHOME

看的到商品, 但部分字被遮住了, 而且"限時優惠"這個是有時效性的, 被分享出去的圖片是不會被改變的

那我自己之前的blog文章呢? 我最早設定的邏輯是這樣的, 如果文章內有圖, 就挑第一張圖, 把它放大到1200x630的規格, 如果沒有圖, 就拿標題做一張圖(前面提到的小工具)

OG Blog

OG Blog

OG Blog

OG Blog

我的文章大多偏技術面的文章, 這裡面有些是用到了流程圖, 看起來沒啥太大問題, 有些就沒那麼優了, 再來看看文章內沒有圖的狀況

OG Blog

自從用了這版本後, 覺得好像這樣會比較清楚一點, 與其去選一張跟內容沒那麼相關, 品質又沒保證的, 還不如使用標題來的直觀一點

但之前的小工具, 需要綁在git pre-commit, 我換台電腦就又得設定一次, 也是有點不方便, 重新來寫一個版本, 跑在heroku上, 反正文章沒那麼多, Facebook又不會常常跑來抓, Free dyno就夠用了

下面就來把這版本:Better OG 幾個實作來做一個介紹, 原始碼都在Github上, 因為都是用現成的package來簡單達成的, 所以我沒想把code整理當成一個可以直接發行的版本, 單純當範例, 大家有用到的片段可以直接拿去使用

純文字的OG Image

想達成的效果就跟前面提到這張圖一樣

OG Blog

使用text2img

這一部分算最簡單的, 就是前面的文章有提到的text2img, 它的設計也就是為了產生og:image, 所以很簡單就可以應用在這邊了, 我是用我自己改過的版本:

func (bog *BetterOG) drawText(text string) (*bytes.Buffer, error) {
	buf := bytes.NewBuffer(make([]byte, 0))
	if decoded, err := base64.RawURLEncoding.DecodeString(text); err == nil {
		text = string(decoded)
	} else {
		return nil, err
	}

	var err error
	var img image.Image
	if img, err = bog.drawer.Draw(text); err == nil {
		if err = jpeg.Encode(buf, img, &jpeg.Options{Quality: 90}); err == nil {
			return buf, nil
		}
	}

	return nil, err
}

因為我是要直接放在URL上, 像https://og.jln.co/t/W-ethuiomF3liKnnlKhheGlvcy1tb2NrLWFkYXB0ZXLngrpheGlvc-aPkOS-m-a4rOippueUqOeahOWBh-izh-aWmQ, 所以文字部分就用base64先編碼過(必須要用RawURLEncoding)

Unit test

這邊為了測試畫出來的文字是不是正確, 所以就引入了gosseract, gosseract是tesseract-ocr的go封裝, tesseract-ocr算是很老牌的OCR了, 辨識率還不錯, 不過老實說, 這邊只是想玩玩gosseract XD

func TestDrawText(t *testing.T) {
	server, err := NewServer(":8888", text2img.Params{
		FontPath: "../../fonts/SourceHanSansTC-VF.ttf",
	})

	assert.NoError(t, err)

	client := gosseract.NewClient()
	defer client.Close()

	buf, err := server.drawText(base64.URLEncoding.EncodeToString([]byte("For testing")))
	assert.NoError(t, err)
	client.SetImageFromBytes(buf.Bytes())
	text, _ := client.Text()
	assert.Equal(t, "For testing", text)
}

使用網頁截圖來當OG Image

這種類型應該比較常在ptt分享文章上看到, 像這樣

OG ptt

這也是不錯的做法

使用chromedp來擷取網頁畫面

chromedp是透過chrome debug protocol 來操作Chrome的, 這邊就很適合從程式來操作截取網頁畫面, 只是擷取畫面, 還算蠻簡單的:

func Capture(encodedurl string) ([]byte, error) {
	var err error
	var decoded []byte

	if decoded, err = base64.RawURLEncoding.DecodeString(encodedurl); err == nil {
		url := string(decoded)
		log.Printf("capture URL:%s\n", url)

		ctx, _ := chromedp.NewExecAllocator(context.Background(), chromedp.NoSandbox)
		ctx, cancel := chromedp.NewContext(
			ctx,
			// chromedp.WithDebugf(log.Printf),
		)

		defer cancel()

		var buf []byte

		if err = chromedp.Run(ctx, chromedp.Tasks{
			chromedp.EmulateViewport(1200, 630),
			chromedp.Navigate(url),
			FullScreenshotInViewport(&buf, 90),
		}); err != nil {
			return nil, err
		}

		return buf, nil
	}
	return nil, err
}

這邊要擷取的URL一樣是透過base64編碼完放在url傳過來的, 因為我們要的圖大小是1200x630, 所以這邊的View port就設定成那個大小, 有一個比較要特別注意的是, 跟原本chromdp範例不同的地方是, 這邊要用ctx, _ := chromedp.NewExecAllocator(context.Background(), chromedp.NoSandbox) “NoSandbox” 的模式來初始chrome, 要不然無法在heroku下跑

這邊並不是使用chrome.FullScreenshot來擷取畫面, 取而代之的是用自己寫的FullScreenshotInViewport

func FullScreenshotInViewport(res *[]byte, quality int) chromedp.EmulateAction {
	if res == nil {
		panic("res cannot be nil")
	}
	return chromedp.ActionFunc(func(ctx context.Context) error {
		format := page.CaptureScreenshotFormatJpeg

		var err error
		// capture screenshot
		*res, err = page.CaptureScreenshot().
			WithCaptureBeyondViewport(true).
			WithFormat(format).
			WithQuality(int64(quality)).WithClip(&page.Viewport{
			X:      0,
			Y:      0,
			Width:  1200,
			Height: 630,
			Scale:  1,
		}).Do(ctx)

		if err != nil {
			return err
		}
		return nil
	})
}

其實這支是抄自chrome.FullScreenshot, 但不一樣的是WithClip這邊的寬高用的是1200x630, 這是因為原本chrome.FullScreenshot會抓整頁完整頁面, 要多長有多長, 結果寬度雖是1200, 但長度可能超長, 這樣的比例可能也會被Facebook視為要用小版面來顯示

佈署到heroku

chromedp是沒辦法單獨運作的, 必須要有chrome才可以正常運作, 如果要部屬到heroku, heroku的環境上是沒裝chrome的, 這該怎麼辦?

一種方式是建立自己的Build Pack, 裝個chrome跑headless mode, 不過這條路太麻煩, 不是我選擇的路

一個是在heroku上用docker image來跑, 這樣只要包裝好一個docker image就搞定了, 簡單

為了這目的, 可以選用Headless shell這個docker image當作base image, 這個image已經等同包裝好一個headless chrome了

FROM chromedp/headless-shell:latest
...
# Install dumb-init or tini
RUN apt install dumb-init
# or RUN apt install tini
...
ENTRYPOINT ["dumb-init", "--"]
# or ENTRYPOINT ["tini", "--"]
CMD ["/path/to/your/program"]

這邊的dumb-init, tini是必須的, 因為這個container不只會跑一個procewss, 包含chrome是兩個, 所以不包這個的話, 在結束container時會有zombie process造成container無法被結束

有黑貓就加分!

不過, 也不是每個網頁都像ptt那樣適合用截圖來做og:image, 後來想了一下, 我想要的大概介於兩者之間, 有文字, 但不太單調的版面, 像這樣

OG cat

那其實這也不難, 做一個網頁範本, 用前面的截圖的方法截出來就好了, 那這也是目前最後使用的版本

小收尾

順便做了幾個小收尾

  1. 除了Facebook bot跟Twitter bot外, 不可以抓到產生的圖, 這是為了以防有人偷用, 用UA去判斷
  2. 加上cache-control, cdn-cache-control header, 前面再擋一層cloudflare

碰到的問題

目前碰到的主要問題有兩個

  1. heroku的free dyno冷啟動至少要6秒, 如果網頁又太慢, 那很有可能造成Facebook bot或Twitter bot timeout
  2. 最最詭異的部分是, Facebook部分, 字形跑掉了, 比照前面那張貓圖跟下面這張, 會發現"Julian Shen"的字形跑掉了, 前面那個是Twitter抓出來的, 後者是Facebook, 明明就同一個URL, 同一個browser render, 如果直接看那張圖字形也是正常的, 但Facebook不知道哪抓來的靈異照片 OG cat

Apache Thrift的官網上, 有提供了如何在nodejs下呼叫Thrift client的範例

這邊這個範例其實針對的是javascript, 而其由Thrift idl產生javascript的指令是:

thrift -r --gen js:node tutorial.thrift

但如果我們想要用typescript來寫呢? 這邊產生的程式碼就沒有適用於typescript的封裝, 那這個thrift generator有沒支援typescript呢? 如果我們用 thrift --help 來看看它的說明:

  js (Javascript):
    jquery:          Generate jQuery compatible code.
    node:            Generate node.js compatible code.
    ts:              Generate TypeScript definition files.
    with_ns:         Create global namespace objects when using node.js
    es6:             Create ES6 code with Promises
    thrift_package_output_directory=<path>:
                     Generate episode file and use the <path> as prefix
    imports=<paths_to_modules>:
                     ':' separated list of paths of modules that has episode files in their root   

除了我們剛剛用的js:node外, 還有一個js:ts, 似乎好像是有支援, 但你如果直接用thrift -r --gen js:ts tutorial.thrift ,它產生的typescript code是給browser用的, 並非給nodejs用的, 這怎回事?難道就不能兼顧嗎?其實可以, 如果你去看這段程式碼, 就會發現答案是用-gen js:node,ts, 範例沒寫, help也沒寫清楚

假設我們有一個範例叫sample.thrift:

service SampleService {
    string hello(1: i64 a, 2: i64 b)
    void hello2()
}

那我們用這個指令

thrift -s -gen js:node,ts sample.ts

那就會在gen-nodejs產生以下四個檔

  • sample_types.d.ts
  • sample_types.js
  • SampleService.d.ts
  • SampleService.js

那我們如何在我們程式裡面呼叫Thrift client呢?參考以下範例:

import {createConnection, TFramedTransport, TBinaryProtocol, createClient, Connection} from "thrift";
import { Client } from "./gen-nodejs/SampleService";
import Int64 = require('node-int64');

const conn:Connection = createConnection("localhost", 8080, {
    transport : TFramedTransport,
    protocol : TBinaryProtocol
  });

const client:Client = createClient(Client, conn);
(async () => {
    console.log(await client.hello(new Int64(11), new Int64(34)));
    conn.end();
  })()

對照一下原本javascript版本:

const thrift = require('thrift');

const SampleService = require('./gen-nodejs/SampleService');

var transport = thrift.TFramedTransport;
var protocol = thrift.TBinaryProtocol;

var connection = thrift.createConnection("localhost", 8080, {
    transport : transport,
    protocol : protocol
  });

var client = thrift.createClient(SampleService, connection);

client.hello(1, 2).then(resp => {
    console.log(resp);
}).fin(() => {
    connection.end();
});

相較之下, typescript的版本好像好讀一些