The Backbone of Frontend Framework

It’s almost impossible to write a frontend without any UI framework nowadays.

All these frameworks provide some kind of mechanism that, whenever we change a value in JavaScript, some element in UI will be changed automatically. For example, adding new row to a table when we push a item into a list.

But how does this work under the surface? That’s the Proxy Object.


Internal Method and Proxy

We need to talk about internal method in JS first.

A internal method is a special method/function that decide how a object behaves in JS runtime. For example, [[GET]] internal method determines the result when we access some property prop from an object obj, e.g. obj.prop. And [[Call]] internal method determines what to proceed when we call the object obj as a function, e.g. obj().

These internal methods can be intercepted and customized from some existing object, with a Proxy instance.

For example, if we want to intercept the property-read action of some object. Here is the way to do.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var person = {
name: "Alice"
};

var agent = new Proxy(
person,
{
get: function(target, property, receiver) {
console.log(`access "${property}" as ${target[property]}`);
return target[property];
},
},
);

var _ = agent.name; // prints: access "name" as Alice"

In the snippet above, agent is the proxy object instance. The second argument to Proxy constructor is the handler, and the get function in that handler is a trap to the [[GET]] internal method.

See

The set Trap for Binding

Just like we can intercept the property read action for some object, we can also intercept the property write action. And that’s what UI frameworks do underneath.

Assuming we have a paragraph in DOM tree, identified by id greeting.

1
<p id="greeting">Hi</p>

We can ensure that the text is updated automatically when we are doing some change to JavaScript object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var element = document.querySelector('#greeting');
var handler = {
set: function(target, property, value, receiver) {
if (property !== 'text')
return false;
target[property] = value;
element.textContent = value; // react to the change
return true;
}
};

var greeting = new Proxy({text: element.textContent}, handler);
console.log(greeting.text); // print 'Hi'
greeting.text = 'Hello';
// the text in the html page will be changed accordingly.

Once this proxy object is created, we make the view (HTML) reactive to the model (JavaScript object), and the binding is established.


In the examples above, we may noticed that all the intercepted objects are an object literally. That means we can not do similar thing to primitive type in JS, like number and string. And that do have impact on the design of UI framework. For example, Vue.js framework has a ref() wrapper for that.

However, Proxy is still a powerful feature in JS, and probably the most important feature for any senior engineer who need to work with JS. So just get used to it. :D

See Also: Proxy - MDN

Appendix

Full HTML file for the view-model binding example.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html> 
<html>
<head>
<title>Proxy Demo</title>
</head>
<body>
<p id="greeting">Hi</p>
<script>
document.addEventListener('DOMContentLoaded', function() {
var element = document.querySelector('#greeting');
var handler = {
set: function(target, property, value, receiver) {
if (property !== 'text')
return false;
target[property] = value;
element.textContent = value; // react to the change
return true;
}
};

var greeting = new Proxy({text: element.textContent}, handler);
console.log(greeting.text); // print 'Hi'
greeting.text = 'Hello';
// the text in the html page will change accordingly.
});
</script>
</body>
</html>

There is No Unit Type in C++

There is NO unit type in C++. (Not in core language spec, at least.)

A Story

Assuming we are define a abstract interface for storage to get name / set name for some user.

1
2
3
4
5
6
7
class Storage {
public:
virtual ~Storage() = default;

virtual std::string GetName(int id) const = 0;
virtual void SetName(int id, std::string name) const = 0;
};

Simple and straightforward.

But it’s intended to be a storage accessed through network, so any operation on it is inherently going to fail at some time. Also, We are 2024 now, loving FP, preferring expression over statement, monadic operation being so cool. Thus we decide to wrap all return type in std::optional to indicate these actions may fail.

1
2
3
4
5
6
7
class Storage {
public:
virtual ~Storage() = default;

virtual std::optional<std::string> GetName(int id) const = 0;
virtual std::optional<void> SetName(int id, std::string name) const = 0;
};

Looks good! But now it fails to be compiled.

1
...\include\optional(100,26): error C2182: '_Value': this use of 'void' is not valid

Well. template stuff.

What Happened?

The problem is that void is an incomplete type in C/C++, and always to be treat specially when we are trying to use them.

By incomplete in C/C++, we mean a type that the size of which is not (yet) known.

For example, if we forward declare a struct type, and later define it’s member. The struct type is incomplete before the definition.

1
2
3
4
5
6
7
8
9
struct Item;

Item item; // <- invalid usage, since that the size of Item is unknown yet.

struct Item {
int price;
};

Item item; // <- valid usage here.

And void is a type that is impossible to be complete by specification.

But we can have a function that return void? Well, we return nothing

1
2
3
void foo() { }

void foo() { return; } // Or explicit return, both equivalent.

BTW, C before C23 prefer putting a void in parameter list to indicate that a function takes nothing, e.g. int bar(void), but is’s kinda broken design here.

Since that we can not evaluate bar(foo()). There is no such thing that is a void and exists.

1
2
3
4
5
6
7
void foo() { }

int bar(void) { return 0; }

int main() {
return bar(foo()); // <- invalid expression here.
}

So back to our problem here std::optional<void>

Conceptually, std::optional<T> is just a some T with additional information of value-existence.

1
2
3
4
5
template <typename T>
struct MyOptional {
T value;
bool hasValue;
};

Because that there is impossible to have a member void value, std::optional<void> is not going to be a valid type at first place.

(Well, we can make a specialization for void, but that’s another story.)

So, How can We Fix?

The problem here is that there is no a valid value for void in C/C++. At some level, program can be though of a bunch of expressions. An running a program is just the evaluation of these expressions. (Also the side effects, for real products / services)

The atom of expression is value. If there is a concept that’s not possible to be express as a value, we are kicking ourselves.

Take Python for example, if we have a function that return nothing, then the function actually returns None when it exits.

1
2
3
4
def foo():
pass

assert(foo() is None) # check pass here
1
2
3
4
5
6
7
def foo(arg: None) -> None:
pass

def bar(arg: None) -> None:
pass

bar(foo(None)) # well.. if we really want to chain them together

So nothing itself is a thing, in Python, we call it None. Every expression now is evaluated to some value that exists, the the type system build up that is complete at any case.

The concept of nothing itself here is call unit type in type theory. It’s a type that has one and only one value. In Python, the value is None, in JavaScript it’s null, in Golang … maybe struct{}{} is a good choice, although not standardized by the language.

Unit Type in C++

Now is the time for C++. As we already see, void is not a good choice for unit type because we can not have a value for it. Are there other choices here?

Just define a empty struct and use it probably not a good choice, since that now our custom unit type is not compatible with unit type from other code in the product code base.

How about nullptr, that’s the only one value for std::nullptr_t. (So the type is std::optional<std::nullptr_t>). It’s a feasible choice, but looks weird since that pointer implies indirect access semantic, but it’s not the case when using with std::optional<T> here.

How about using std::nullopt_t? It’s also a unit type but it’s now more confusing. What’s does it mean by std::optional<std::nullopt_t>? A optional with empty option? There is a static assert in std::optional<T> template that forbid this usage directly, probably because it’s too confusing.

Maybe std::tuple<>? A tuple with zero element, so it have only one value, the empty tuple. That seems to be a good choice because the canonical unit type in Haskell is () the empty tuple. So it looks natural for people came from Haskell. But personally I don’t like this either since that now the type has nested angle bracket as std::optional<std::tuple<>>.

There is a type called std::monostate, arrived at the same time as std::optional in C++17. This candidate do not have additional implication by it’s type or it’s name. It’s monostate! Just a little wordy.

std::monostate is originally designed to solve the problem for a std::variant<...> to be default initialized with any value. But it’s name and it’s characteristic are all fit our requirement here. Thus a good choice for wrapping a function that may fail but return nothing.

Now the interface looks like

1
2
3
4
5
6
7
class Storage {
public:
virtual ~Storage() = default;

virtual std::optional<std::string> GetName(int id) const = 0;
virtual std::optional<std::monostate> SetName(int id, std::string name) const = 0;
};

Hmm… std::optional<std::monostate>, which takes 29 characters. C++ is not easy. Just like we use std::shared_ptr<T> all over the places.

Maybe the C++ Standards Committee should specialize std::optional<void>, just like std::expected<void> in C++23.


Wish someday void can be a REAL unit type in C/C++. :D

Golang 1.23 Iterator Functions

For a long long long time, Golang have no standard way to represent a iterable sequence.

C++ has range adaptor and iterator (although not strictly typed, only by concept), Python has iterable/iterator by __iter__/__next__, JavaScript has standardized for-of and Symbol.iterator since ES6.

Now it’s time for Golang. Starting from Golang 1.23 Aug., we have iterator functions.

How It Works.

Sample code explains faster.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Iota() func(yield func(idx int) bool) {
return func(yield func(idx int) bool) {
idx := 0
for {
if !yield(idx) {
return
}
idx += 1
}
}
}

func main() {
for idx := range Iota() {
if idx == 3 {
break
}
fmt.Println(idx) // print 0 1 2
}
}

According to Go 1.23 Release Notes Now the range keyword accept three kinds of functions, for which takes another yield function that yield zero/one/two values.

1
2
3
func(func() bool)
func(func(K) bool)
func(func(K, V) bool)

The loop control variable and the body of the for-loop is translated into the yield function by language definition. So we can still write imperative-style loop structure even though we are actually doing some functional-style function composition here.

Why Do We Need This?

Standardize the iterable/iterator interface is a important pre-condition for lazy evaluation. For example, how should we do when we need to iterates through all non-negative integer, and doing some map/filter/reduce on them? It waste space to allocate a list for all these integers (if possible).

Someone may say “we already have channel types”. Well, but that requires a separate coroutine instance. We probably don’t want such heavy cost every time we are doing some iterate operations.

Also a separate coroutine means additional synchronization and lifecycle control. For example, how can we terminate the Count coroutine when we need early break in loop?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Count(start int) chan int {
output := make(chan int)
go func() {
idx := start
for {
output <- idx
idx += 1
}
}()
return output
}

func main() {
for idx := range Count(0) {
if idx == 10 {
break
}
fmt.Println("Loop: ", idx)
}
}

We need some mechanism like context object or another channel right? That’s a burden for such easy task here.

On the other hand, iterator functions are just ordinary function that accept another function to yield/output the iterated values, so it’s much lightweight than a separate coroutine. We want fast program, right? :D

The Stop Design

For languages like Python and JavaScript, the iterator function (or generator in Python terms) is paused and the control is transfer back to the function that iterates the values. When break/return happens and no more value are required, the iterator function just got collected by the runtime since that there are no more references to the function object.

But how do we early break the iteration process, if the control is transfer into the iterator function? Let’s look at the function signature again. (Take one value iterator function for example).

1
func(yield func(idx int) bool)

The yield function returns a bool to indicate that whether the loop body does reach the end, or encounter a break statement. So in normal case, we continue to next possible value after yield return, but if we got false from yield, our iterator function can return immediately.

Ecosystem around Iterator

The beauty of iterator only appears if the ecosystem, or we say, the common operations around iterator are already implemented in standard library. That means:

  • Conversion from and to standard container types, like slice map and chan
  • Operations and compositions of iterators, e.g. map/filter/reduce/chain/take

In Python, there are generator expressions, which evolves implicit map/filter. reduce is a function at global scope, also there are many useful functions in itertools package, e.g. pairwise, batched, chain. Most builtin container types takes iterable as first argument in it’s constructor.

In Golang, the first part is mostly done along the release of Golang 1.23. For example, to convert slice from and to iterator, we can use slices.Collect and slices.Values.

For second part, there is a plan to add x/exp/xiter package under golang.org namespace. There should be at least Concat, Map, Filter, Reduce, Zip … once it’s released. But unfortunately it’s not compete yet.

See: iter: new package for iterators · Issue #61897 · golang/go

Also I create a toy package github.com/wdhongtw/mice/flow to address some important building wheel around iterators

  • Empty/Pack return a iterator for zero/one value
  • Any/All short-circuit lazy evaluation of a predicate on a sequence of values
  • Forward/Backward absorb input and iterate in reversed order.

For example, if we want to define a iterator function for a binary tree in recursive manner, we can use Empty and Pack together with Chain to implement this easily.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Node struct {
val int
left *Node
right *Node
}

func Traverse(node *Node) iter.Seq[int] {
// Empty is useful as base case during recursive generator chaining.
if node == nil {
return Empty[int]()
}
// Pack is useful to promote a single value into a iterable for chaining.
return Chain(
Traverse(node.left),
Pack(node.val),
Traverse(node.right),
)
}

Looks cool, doesn’t it? :D

Python 的 type 不是 type?

Intro

近期因為一些原因,想自己寫一個 Python 用的 DI library。寫是寫完了,不含 test 基本上不到 50 行, 也 release 到 luckydep · PyPI 了。 不過在寫的過程中發現了一些問題。

與 Golang 不同。 在 Python 中,DI container 拿取特定 instance 的介面 (invoke/bind/get 等,下稱 invoke) 需要明確傳遞想拿取的 instance 的 type。

1
2
3
4
5
6
7
func Invoke[T any](c *Container) T {
var _ T // can construct T event if T is a interface
}

// although we need to specify the type parameter, the type parameter
// is not passed during runtime
var instance = Invoke[SomeType](c)
1
2
3
4
5
class Container:
def invoke(t): # search and build a instance of type t

c = Container()
instance = c.invoke(SomeType) # need to pass type as a parameter

其中的根本差異是,Golang 類型的 static type language,generic function 會真的根據不同型別,產生對應的 function 出來,這些 function 的 byte code/machine code 自然知道當下在處理的型別。 而 Python 這類語言,靠 static type checker 建立 generic function,實際上到 runtime 時還是只有一個 function,自然會需要傳遞 type 給 invoke 介面。

自從 Python 3.6 開始我們有 type hint,所以我們可以 annotate function/method 來幫助 IDE/type checker 來推論正確的型別。

1
2
3
4
5
class Container:
def invoke(t: type[T]) -> T: # search and build a instance of type t

c = Container()
instance: SomeType = c.invoke(SomeType) # ok, we can infer instance is SomeType

這邊 type[T] (or typing.Type[T], the old way) 用來表示我們正在用 t 來傳遞傳遞某個 type T, 而非 type 為 T 的某個 instance。

From typing document:

A variable annotated with C may accept a value of type C. In contrast, a variable annotated with type[C] (or typing.Type[C]) may accept values that are classes themselves

The Problem

OK,我們有 type[T] 可以用。 DI library 開發者可以用型別為 type[T]t 來做 indexing, library 使用者可以享受到 static type checker 帶來的 type safety。

於是我們拿這個 library 來用在真實情境.. 沒想到一下子就碰上問題了。 當我們定義 interface type,並透過 DI container 對該 interface 拿取對應的 implementation instance 時。 因為 interface 通常是個 abstract class (or protocol),mypy type checker 會報錯 (mypy: type-abstract)。

1
2
3
4
5
6
7
class SomeInterface(Protocol):
def hello(self): ...

class SomeImplementation:
def hello(self): ...

c.invoke(SomeInterface) # trigger mypy error [type-abstract]

不會吧… 這不是我們需要 DI 的最重要原因嗎? 我們定義 interface 並另外提供 implementation,來達到隔離不同 class 職責的效果。 結果當 user 要用這個 library 的時候卻卡在型別檢查…

The History

翻閱文件,第一時間以為這是 mypy 的設計問題。

Mypy always allows instantiating (calling) type objects typed as Type[t]

沒想到翻了 mypy issue #4717 · python/mypy 後,發現這是已經寫在 PEP 544 內的規格。

Variables and parameters annotated with Type[Proto] accept only concrete (non-protocol) subtypes of Proto. The main reason for this is to allow instantiation of parameters with such type. For example:

1
2
def fun(cls: Type[Proto]) -> int:
return cls().meth() # OK

mypy 允許 construct 一個不知道 constructor 長什麼樣子的 interface type, 所以該標示 Type[Proto] 的 parameter 只能傳遞 concrete type… 嗯?

繼續往下追,想不到一開始會有這個檢查,是因為 Guido 本人 在 2016 年開的 #1843 · python/mypy, 認為應該允許這種使用方法。

於是 mypy 加入了這個檢查,後來 2017 年的 PEP 544 也明確定義了這個使用規則。

The Controversy

這個 t: type[T] 的設計引起很多爭議,從 #4717 · python/mypy 來看,不少人認為: 為了允許 construct t() 而限制只能傳遞 concrete class 會大幅限制這個 type[T] 的使用情境。

也有人認為這個檢查根本就不合理,因為沒有人能保證這個 protocol type 底下的 concrete class 的 constructor 到底要吃什麼東西。 即使 static type check 檢查過了,t() 在 runtime 噴掉一點也不奇怪。 更何況根本沒看過有人在 protocol type 上面定義 __init__ method,這個 t() 一開始到底要怎麼檢查也不知道。

如果看相其他語言的開發經驗… Golang 生態系 constructor 是 plain function,定義 interface type 時自然不會包含 constructor。 寫 C++ 的人應該也沒聽過什麼 abstract constructor,只有 destructor 會掛 abstract keyword。 回到 Python 自身,mypypyright 兩大工具也都允許 __init__ 的 signature 在繼承鍊中被修改。 (see: python/typing · Discussion #1305)

至於 typing.Type 的文件,寫得很模糊,我想有一定程度的人看到反而更容易誤會。

type[C] … may accept values that are classes themselves …

就算捨棄掉 protocol,限制都只能用 concrete class 來定義 interface。 這個只能允許 concrete class 的規則還造成了另一個問題: 使用者該如何傳遞 function type?

1
c.register(Callable[[int, int], int], lambda a, b: a + b) # ????

說好的 function as first-class citizen 呢? 怎麼到了要傳遞型別時就不行了?

在翻閱 issue 的過程中,發現其他 DI framework 的 repo 也遇上同樣的問題 #143 · python-injector/injector, 頓時覺得自己不孤單。

The Future

由於 PEP 544 自從 2017 年就已經完成,mypy 預設執行這個檢查也行之有年, 現在再來改這個行為或許已經來不及了。

於是為了解決這個問題,2020 有人在開了新 issue 9773 · python/mypy 想要定義新的 annotation TypeForm[T]/TypeExpr[T] 來達成要表達任意 type 的 type 的需求。 到目前 (2024-06),對應的 PEP 747 draft 也已經被提出了。

若一切順利,以後我們就會用 TypeExpr[T] 來表達這類 generic function

1
2
3
4
5
6
7
8
class Container:
def invoke(t: TypeExpr[T]) -> T: # search and build a instance of type t

class SomeType(Protocol): ...

c = Container()
instance = c.invoke(SomeType) # ok, we find a object for type SomeType for you!
operator = c.invoke(Callable[[int], bool]) # you need a (int -> bool)? no problem!

至於目前嘛.. library user 在使用到這類 library 的檔案加入下面這行即可。 我想要修改的範圍和造成的影響應該都還可以接受。

1
# mypy: disable-error-code="type-abstract"

期許 Python typing system 完好的那天到來。

Timeline

Golang 1.18 Generics 終於來臨

今天 Golang 1.18 終於正式釋出啦! 我們終於有辦法在 Golang 裡做 generic programming 啦!

Golang 是一個近 10 年快速竄紅的程式語言,但在很多面向其實還是非常土炮。 得靠後續社群不斷的討論與貢獻才達到一個比較完善的水準。 像是..

  • context package in Golang 1.7: 解決 long job cancelling 的問題
  • errors package in Golang 1.13: 滿足其他語言常見的 error 嵌套需求和提供統一的判斷方式
  • Generic support in Golang 1.18: 提供開發者實作各種型無關演算法的機會

一直以來,在 Golang 的標準函式庫中,碰到類型無關的抽象問題時,最後給出來的解法大概就兩種

  1. 所有 input / output 參數都定義成 interface{},大家一起把型別檢查往 run-time 丟
  2. 同一個 class / function 對於常用的 data type 通通實作一遍

前者最典型的大概就是 sync 這個函示庫,後者.. 大家應該都看過那個慘不忍睹的 sort..

不過這些都是過去式了,從今天開始,大家都可以寫自己想要的 generic code / library / framework。 :D

Usage

基本語法很簡單,只要在想做成 generic 的 function / struct 後面多加一個 [T TypeName] 即可, TypeName 是用原本就有的 interface{...} 語法來表示,可以自己描述這個 generic function / struct 支援的型態必須滿足什麼樣的介面。

以 Python style 的 sort by key 當例子。 我們可以定義一個 generic 的 sort function,並且明定送進來的 list 內的每個元素需要支援一個 Key 函示, 作為排序時的根據。

範例 code 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

type Keyable interface {
Key() int
}

func Sort[T Keyable](items []T) []T {
if len(items) <= 1 {
return items
}

pivot := items[0]
less, greater := []T{}, []T{}
for _, item := range items[1:] {
if item.Key() < pivot.Key() {
less = append(less, item)
} else {
greater = append(greater, item)
}
}

return append(append(less, pivot), greater...)
}

type Person struct {
Name string
Age int
}

func (n Person) Key() int {
return n.Age
}

func main() {
persons := []Person{{Name: "alice", Age: 33}, {Name: "bob", Age: 27}}

persons = Sort(persons)
}

在這個範例中,Sort 要求送進來的 []T 當中的 T 要實作 Keyable 介面 (提供 Key method)。 當我們想排序一堆 Person 時,我們可以在這個 Person 物件上定義 Key method,取出 Person 的年齡。 完成之後,我們就可以依年齡來排序 []Person 了。

期許自己未來可以多加利用這個遲來的功能.. XD

References

單台機器的 Ceph 部署

原由

Ceph 的預設設定對資料的 replica 行為要求嚴格。 若只有單台機器或是硬碟數量受限,往往架設起來的 Ceph 無法順利存放資料。

此篇筆記關注的目標如下

若想要用最少資源,建立可用的 Ceph 環境,需要做哪些額外的調整?

背景知識

Ceph 是一套開源的儲存叢集 solution。 可以整合多個儲存設備並在其上提供 RADOSGW, RBD, Ceph FS 等不同層級的存取介面。

ceph-stack

對於每個儲存設備 (HDD, SSD),Ceph 會建立對應的 OSD 來管理儲存設備。 有了儲存設備之後,Ceph 會建立邏輯上的 pool 作為管理空間的單位。 Pool 底下會有多個 PG(placement group) 作為實際存放資料至 OSD 中的區塊。

在 Ceph 的預設設定中,一般 pool 的 replica 行為如下

  • 要有三份 replica
  • replica 要分散在不同的 host 上

在開發環境中,資料掉了其實並無傷大雅,三份 replica 意味著儲存空間的浪費。 且若資料真的要放在不同的 host 上,連同 replica 三份這點,我們就至少要開三台機器, 增加無謂的管理成本。

解決方式

假設我們都是透過 cephadm bootstrap 來架設 Ceph。

Ceph cluster 建立好,也設定完需要的 OSD 之後,就可以來建立 pool。

根據 pool 的目的不同,要解決單台機器部署 Ceph 的限制,大概會有兩種做法。

降低 Pool Size

Pool size 此術語意味著該 pool 下的 PG 要 replica 幾份。 若某 pool 是拿供他人存放資料,或是會使用較多空間的,可以把 size 降為 1。

調整完之後就相當於該 pool 內的所有資料都不會有 replica。

範例如下:

1
2
3
ceph osd pool create "<pool name>"
ceph osd pool set "<pool name>" size 1
ceph osd pool application enable "<pool name>" rbd

調整 Choose Leaf 行為

Ceph 有定義不同層級的資料分散設定。 預設值為 host,意味著只有一台機器的情況下,資料會無法複製。 若調整為 osd,只要該機器上有多顆硬碟即可滿足複製條件。 若是針對 Ceph 自行建立出來,管理 meta data 的 pool (e.g. device_health_metrics) 可以考慮使用此方式處理。

設定方式大概有兩種。

方法一: 調整 Ceph global 設定

編輯 /etc/ceph.conf 並在 global section 下加入 osd_crush_chooseleaf_type 設定

1
2
3
[global]
...
osd_crush_chooseleaf_type = 0

或是直接執行 command

1
ceph config set global osd_crush_chooseleaf_type 0

這邊的 0 代表 OSD。預設的對應列表如下

1
2
3
4
5
6
type 0 osd
type 1 host
...
type 9 zone
type 10 region
type 11 root

方法二: 修改 crush map 內容

筆者有注意到有時即使有執行方法一,pool 還是不會受到設定影響。 (相關知識還太少, 不太確定具體原因) 不過針對此狀況,還有第二個方法可以使用。

此方法會用到 crushtool 指令 (Ubuntu 中需要額外安裝 ceph-base 套件)

首先執行指令將目前的 crush map 撈出來

1
2
ceph osd getcrushmap -o "compiled-crush-map"
crushtool -d "compiled-crush-map" -o "crush-map"

接著修改 crush-map 檔案內容,應該會有一行有 step chooseleaf 開頭的設定,把最後的 type 從 host 調整為 osd

1
2
3
4
# Before
step chooseleaf firstn <number> type host
# After
step chooseleaf firstn <number> type osd

最後將修改好的 crush map 設定塞回去。

1
2
crushtool -c "crush-map" -o "compiled-crush-map"
ceph osd setcrushmap -i "compiled-crush-map"

相關 reference link

結語

筆者在公司業務並不負責維護 production 的 Ceph cluster,僅是為了建立 Kubernetes 開發環境,需要有個基本會動的 Ceph。

為了用最少資源建立 Ceph 環境,需要調整相關設定來改變 Ceph 行為。 只可惜相關的資源不是很夠,一路跌跌撞撞下來,決定寫下這篇筆記,希望造福未來的自己,也同時照顧他人。

NATS 與 JetStream 簡易介紹

最近因公司業務在玩一套相對新的 MQ: NATS。 因為官方文件不慎清楚且有些地方與直覺不同,造成起步緩慢。

以下簡單紀錄一下剛入門時應知道的事情。

Guide

相較 RabbitMQ, Kafka 等,NATS 是一套較為年輕的 MQ。 雖然有部分子專案的版本未達 v1.0,但官方宣稱已經接近 production ready。

NATS 從一開始就是針對 cloud service 設計,cluster mode 的水平擴展, node 之間的身分驗證及 TLS 通訊設計看起來都還不錯。

NATS 的 message 並無特別限制,在 client library 內任何的 byte sequence 都可以成為 message。

NATS 有以下三個模式(以及其對應的 client library)。

NATS (NATS Core)

NATS 專案從一開始發展時的基本模式。 支援 Pub/Sub pattern 並提供 at-most-once 語意。

NATS Streaming

NATS Streaming 是一套疊在 NATS 上面形成的 solution。

因為設計上的問題,後來又有了 JetStream,所以我們基本上不用理它,只要知道 NATS Streaming 和 JetStream 不一樣,翻文件的時候不要翻錯即可。

JetStream

JetStream 是後來做在 NATS 內,可選擇是否啟用的子系統。 藉由 JetStream, 可以實作 Producer/Consumer pattern 並提供 at-least-once 語意。

Server side 沒什麼需要注意的,只要用較新版的 NATS image 並啟用設定即可。 Client 開發則需要注意一些概念。

  • Subject: NATS 最初的概念,代表一些 message 的集合。
  • Stream: 建立於一或多個 Subject 之上,可將這些 subject 內的 message 統整起來,並放入 persistent storage。
  • Consumer: 建立在某個 Stream 之下,可以依序的 consume 屬於此 stream 的特定 message。

需要注意的是,不只 Subject 與 Stream,Consumer 本身也是建立在 NATS server 中的一個物件。 當利用 client library create 一個 Consumer 時,並不是該 process 本身成為一個 consumer, 而是 NATS server 中被創了一個 Consumer 物件,準備去使用 Stream 裡面的 message。

JetStream client library 並沒有提供一個對稱的 producer/consumer API。 基於術語的限制以及為了避免誤會,以下在稱呼一般所稱的 producer/consumer 時, 會特別加上 role 後綴來表示。

Producer role: 要使用 NATS library 內的 Publish API,將產生的 message 推送至 某個 Subject 內。

Consumer role: 要使用 JetStream library 內的 Stream API,在 NATS server 上對目標 Subject 建立 Stream,接著使用 JetStream Consumer API,在 NATS server 中 建立屬於該 Stream 的 Consumer。以上都完成之後,即可利用 Consumer 上的 NextMsg 來 消耗 message。

Conclusion

JetStream 的 API 設計並不常見,需要先認知到與既有設計的差別之處才能開始開發。 不過其 cloud native 的架構設計或許可以在維運上面勝過其他老牌的 MQ solution。

今天就先寫到這裡,如果有哪天有興趣再補吧。 :D

Reference

Appendix

Golang sample code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
package main

import (
"context"
"fmt"
"log"
"math/rand"
"os"
"testing"
"time"

"github.com/nats-io/jsm.go"
"github.com/nats-io/jsm.go/api"
"github.com/nats-io/nats.go"
)

var fullSubject string = "report_task.scheduled"
var wildcardSubject string = "report_task.*"

func consumeOne(doneChan chan bool) {
msg, err := consumer.NextMsg()
if err != nil {
fmt.Printf("Fail to get message: %v\n", err)
}
fmt.Printf("Consume get task: %s\n", string(msg.Data))
time.Sleep(2 * time.Second)
if err := msg.Ack(); err != nil {
fmt.Printf("Fail to ack the message %s: %v\n", string(msg.Data), err)
}
doneChan <- true
}

func TestProduceAndConsume(t *testing.T) {
producerStopChan := make(chan bool)
consumerStopChan := make(chan bool)
var taskCount int = 1

go func() {
for idx := 0; idx < taskCount; idx++ {
taskName := fmt.Sprintf("task #%d", rand.Int())
nc.Publish(fullSubject, []byte(taskName))

fmt.Printf("Producer produce: %s\n", taskName)
}
producerStopChan <- true
}()

for idx := 0; idx < taskCount; idx++ {
go func() {
consumeOne(consumerStopChan)
}()
}

<-producerStopChan
for idx := 0; idx < taskCount; idx++ {
<-consumerStopChan
}

fmt.Println("Done")
}

var ctx context.Context
var cancel context.CancelFunc
var nc *nats.Conn
var stream *jsm.Stream
var consumer *jsm.Consumer

func setup() {
var err error

ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
nc, err = nats.Connect(nats.DefaultURL, nats.UserInfo("name", "password"), nats.UseOldRequestStyle())
if err != nil {
log.Fatal(err)
}

jsmgr, err := jsm.New(nc)
if err != nil {
log.Fatal(err)
}

streamName := "ReportTask"
stream, err = jsmgr.LoadOrNewStreamFromDefault(streamName,
api.StreamConfig{
Subjects: []string{wildcardSubject},
Storage: api.FileStorage,
Retention: api.LimitsPolicy,
Discard: api.DiscardOld,
MaxConsumers: -1,
MaxMsgs: -1,
MaxBytes: -1,
MaxAge: 24 * time.Hour,
MaxMsgSize: -1,
Replicas: 1,
NoAck: false,
})
if err != nil {
log.Fatal(err)
}

consumerName := "Generator"
consumer, err = stream.LoadOrNewConsumerFromDefault(consumerName,
api.ConsumerConfig{
Durable: consumerName,
DeliverPolicy: api.DeliverNew,
FilterSubject: fullSubject,
AckPolicy: api.AckExplicit,
AckWait: 30 * time.Second,
MaxDeliver: 5,
ReplayPolicy: api.ReplayInstant,
SampleFrequency: "0%",
})
if err != nil {
log.Fatal(err)
}

}

func shutdown() {
cancel()
}

func TestMain(m *testing.M) {
setup()
code := m.Run()
shutdown()
os.Exit(code)
}

GitHub Pages 與 GitLab Pages 架設 Blog

筆者最近把個人 blog 的產生工具從 GitHub Pages 預設的 Jekyll 換成 Hexo,有了一點心得。 而且不只 GitHub Pages, 筆者在公司業務中也有大量使用 GitLab Pages 來產生文件及測試報表,算是有累積不少經驗。

趁著印象還深刻時,寫點筆記,替這兩個相同性質的服務做基本的介紹。

Pages 服務與 Static Site Generator

GitHub / GitLab Pages 可以將一組靜態網頁內容 (html, css, js 等),透過 GitHub / GitLab 的伺服器,host 在某個 URL 底下。 網頁產生工具 (Static Site Generator, 下稱 SSG) 則是 一個可以將用 Markdown 撰寫的文章,轉化成漂亮的靜態網頁內容的工具。常見的 SSG 有 Jekyll(Ruby), Hugo(Go), Hexo(JavaScript) 等。

若將 SSG 工具與 GitHub / GitLab Pages 服務,搭配使用, 寫作者只需要寫寫簡單的 Markdown 並 push commit,就能得到一個漂亮的 blog 或是文件網頁。 筆者的個人 blog 及公司的工作筆記即是使用這類流程架設。

整體流程大概如下圖所示:

1
2
3
4
5
6
7
8
9
               +   GitHub            +     github.io
Local Project | Project | site
| GitLab | gitlab.io
+ +

+----------+ +----------+ Build & +------+ User
| Markup | Push | Markup | Deploy | Site | Browse
| config.. | +----> | Config.. | +-------> | | +------->
+----------+ +----------+ +------+

GitHub Pages

GitHub Pages 基本上會有兩種主要的使用方式。 可以直接使用 GitHub Pages,或是透過 GitHub Pages 的 Jekyll 整合功能。 前者需要的技術背景與設定步驟均較複雜,後者較簡單但缺少了根據個別需求調整的機會。

Native GitHub Pages

若直接使用 GitHub Pages,使用方式是: 將 SSG 產生的網頁擺放至某 branch (預設為 gh-pages) 的 //docs 目錄。 每次該 branch 被更新時,GitHub 就會將最新版本的網頁內容, 呈現在 https://<username>.github.io/<project> 連結下。

早期這個 push brach 的動作是蠻麻煩的,但後來有了 GitHub Action 之後, 產生網站和後 push branch 的動作都可以在 GitHub 提供的環境完成,非常方便。

筆者個人使用的 job 描述檔如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# .github/workflows/blog.yaml

name: build-and-deploy-blog

on:
push:
branches: [ "master" ]
pull_request:

jobs:
blog:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2.3.4
- name: Setup Node.js environment
uses: actions/setup-node@v2.1.5
with:
node-version: 12.x
- name: Install dependent packages
run: npm install
- name: Build blog posts
run: npm run build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public

若不想使用 GitHub 提供的 domain,也可以參照 官方文件, 使用自己購買的 domain 來架設網站。 只要設定完成,GitHub 也可以一併幫使用者申請 custom domain 需要的 HTTPS 憑證。

比方說筆者的 blog 原本可存取的位置應是 https://wdhongtw.github.io/blog,但有設定 custom domain 後,目前是透過 https://blog.bitisle.net 來存取架在 GitHub Pages 上的 blog。

GitHub Pages Jekyll

前述 (Native) GitHub Pages 的使用方式會需要自己 push branch。 但若 GitHub 偵測到 project 使用的 SSG 是 Jekyll,GitHub 會自動處理產生網頁以及 後續部屬到 https://<username>.github.io/<project> 的工作。 (連 gh-pages branch 都省了,整個 project 會非常乾淨。)

此方法相當容易上手,也是 GitHub Pages 教學文件預設的使用方式。但因為產生網站的 環境是由 GitHub 協助處理,能使用的 Jekyll plugin 自然也是受到限制。

說是受到限制,但以一般使用情境應該也很夠了。諸如 RSS feed, sitemap, Open Graph metadata 等現代 blog 必備的功能都有自帶。 若想知道有那些 Jekyll plugin 可用,可至 Dependency versions | GitHub Pages 查閱。

Compare Native GitHub Page with GitHub Pages and Jekyll

簡單比較上述兩種方式如下

GitHub Page GitHub Page with Jekyll
SSG Tool on your choice only Jekyll
Deployment push generated site done by GitHub
Customization any plugins of SSG limited Jekyll plugin list

GitLab Pages

GitLab Pages 與 GitHub Pages 一樣,有將 SSG 產生的網頁 host 在特定網址的能力。 以 GitLab 官方 host 的站台來說,網站會放在 https://<username>.gitlab.io/<project> 下。 (私人或公司 host 的 GitLab instance 就要看各自的設定)

與 GitHub 不同的是,GitLab Pages 並不是透過 push branch 的方式部屬, 且沒有針對特定 SSG 提供更進一步的自動部屬功能。

GitLab Pages 的使用方式是基於 GitLab CI Pipeline 的架構設計的。若想要部屬 網站,一般的使用方式是在 pipeline 內產生網頁,接著將網頁內容擺放至為特定 job (pages) 的特定 artifacts 目錄 (public) 中。 一旦有 pipeline jobs 達成此條件。 GitLab 就會 把網頁內容部屬到對應的網址下。

筆者個人使用的 job 描述檔如下: (因為 GitHub Action 與 GitLab CI 的架構差異,寫起來比較簡潔)

1
2
3
4
5
6
7
8
9
10
11
12
13
# .gitlab-ci.yml
pages:
image: ruby:3.0.1
stage: deploy
before_script:
- bundle install
script:
- bundle exec jekyll build --destination public --baseurl "$CI_PAGES_URL"
artifacts:
paths:
- public
only:
- master

至於其他 GitHub Pages 有的功能,如 custom domain,自動申請 HTTPS 憑證等,GitLab Pages 也都有。 記得去 project 設定頁面設定即可。

Conclusion

在 2008 年 GitHub Pages 剛推出時,使用者都要自己手動 push gh-pages branch。 後來 GitLab 推出基於 GitLab CI 的 Pages 服務之後,GitHub Pages 使用體驗相較之下可說是非常糟糕。

但後來隨著 GitHub Actions 服務推出,以及社群維護的高品質 Pages 部屬 Action 出現。 GitHub / GitLab Pages 的使用體驗已經變得相當接近。 其他像是 custom domain 以及 HTTPS 的支援也都是免費的基本功能。

基於上述原因,許多早期的 比較文章 其實已經沒什麼參考價值。 若現在想架設新的 blog 等站台,只要選擇自己習慣的平台即可。

References

GitHub 即日起支援使用 Security Key 進行 Git 操作

GitHub 開始支援使用 security key 進行 Git 操作啦!

這應該是各家科技巨頭當中,第一個支援 security key 進行 SSH login 的服務吧。 筆者昨天 5/10 才在公司內分享如何使用 security key 來做 SSH login, 沒想到 Yubico 和 GitHub 也剛好在昨天一起同步更新 blog 文章,通知大家這個新功能。 喜極而泣..

以下簡單介紹如何使用這個新功能。 為了方便解說及避免誤會,後述內容均以正式名稱 authenticator 代稱 security key。

如何使用

首先當然要有一把 authenticator,如果還沒有,趕快去買一把囉。 :D

第一步,在 authenticator 內產生新的 key pair。

產生 key pair 的流程和傳統放在檔案系統上的差不多,只是 key type 要指定代表 authenticator 的 type。

1
ssh-keygen -t ecdsa-sk

產生的過程,根據不同的 authenticator,會有要求按一下 且/或 輸入 PIN code 驗證身分。 此步驟會在 authenticator 內產生一組 key pair,並將 public key 寫到檔案系統上。 平常放 private key 的那個檔案還是會產生,不過這次裡面放的會是一個 key handle。

第二步,透過 GitHub 的 web UI 上傳 public key

上傳完之後,沒意外就可以順利使用了,可以試著 clone 一些 project

1
2
3
4
$ git clone git@github.com:github/secure_headers.git
Cloning into 'secure_headers'...
Confirm user presence for key ECDSA-SK SHA256:........
User presence confirmed

若確認 OK,之後的 Git 操作都可以透過隨身攜帶的 authenticator 保護, 只要按一下 authenticator 即可。

設定完之後,若沒有拿出 authenticator,就不能進行 push / pull 操作。 只要確保 authenticator 還在身上,就可以安心睡大覺。 (再也不用擔心 private key 放在公司電腦上會被摸走了!)

進階使用方式

FIDO 2 authenticator 博大精深,除了上述的基本使用流程外,還有些細節可以設定。

以下分別介紹三個進階使用技巧:

要求身分驗證 (User Verification) 才能使用 Authenticator

根據每個人平常保管鑰匙習慣的差異,可能有人會擔心 authenticator 真的被摸走。

但 authenticator 也有支援一定要驗甚身分 (e.g. PIN code or 指紋辨識) 後才能 使用內部的 key pair 的功能。

要如何使用呢? 只要在產生 key pair 的過程中多下一個 flag 即可。

1
ssh-keygen -t ecdsa-sk -O verify-required

若之後要使用由此方式產生的 key pair,除了手指按一下 authenticator 之外,還會要求 使用者輸入 PIN code 才能順利完成操作。如此即使 authenticator 被偷走了,也不用太緊張。

全自動使用 Authenticator (避免 User Presence Check)

若把 authenticator 插上電腦後,想要隨時隨地都進行 push / pull, 但不要每次都手按一下 authenticator。這種自動化的使用情境 OpenSSH 其實也是有支援的。

使用的方式也是在產生 key pair 時多帶一個參數。

1
ssh-keygen -t ecdsa-sk -O no-touch-required

同時在將 public key 部屬到目標機器上時,在 public key 該行前面多下 no-touch-required 即可。 (詳情請見 ssh-keygen(1)sshd(8))

以此方始產生的 key pair 在使用時就不需要每次都手按一下,可以全自動使用。

不過,雖然 OpenSSH 有支援此種使用情境,但目前 GitHub 禁止這種使用方式

節錄自上述 blog 文章

While we understand the appeal of removing the need for the taps, we determined our current approach to require presence and intention is the best balance between usability and security.

所以在 GitHub 上,若想要全自動操作,只能回去用一般的 SSH key 或 API token 囉。

避免手動複製 Key Handle

前面有提到,原先檔案系統上用來放 private key 的檔案會變成拿來擺放 key handle。

這意味著,當我們想在新的機器上透過 SSH 進行 Git 操作時,除了拿出 authenticator 之外, 也需要把原先的 key handle 檔案複製到新的機器上。 且若 key handle 檔案掉了,該組 key pair 就不能使用了。

若要避免此問題,就要用上 authenticator 的另一個進階功能 discoverable credentials / resident keys 。

1
ssh-keygen -t ecdsa-sk -O resident

使用此類型的 key pair 時,會在 authenticator 上消耗一些儲存空間。但換來的好處是, 使用者可以在新的機器上,把 key handle 從 authenticator 內抽出來。

1
ssh-keygen -K # extract discoverable credentials

如此就不用手動複製擺放 key handle 的 private key 檔案了。

但要注意的是此類型 key pair 會消耗 authenticator 的空間。 每把 authenticator 可以放多少 key pair 要再自行查閱官方文件。

結語

以上介紹了基本使用情境及三種可能的進階使用方式。

筆者在 2014 年第一次注意到 FIDO (U2F) 標準,當時就在想像沒有密碼的世界。 如今,藉由 FIDO 2 security key 的普及,當初所想的美好願景似乎在慢慢地實現中..

希望未來能看到 security key 運用在更多場景上!

延長或縮短 GPG 金鑰的過期時間 (Expiration Time)

筆者在 2018 年的時候開了第一個真的有在長期使用的 GPG 金鑰。

因為年少輕狂不懂事,當時特別把 primary key 設定成永遠不會過期。 但演算法可能在未來被發現漏洞,電腦的運算能力也會越來越好, 一把不會過期的 GPG 金鑰是先天上不合理的存在。 考量到此問題,筆者後來又再修正了該金鑰的過期時間, 以及整理這篇筆記…

GPG Key 可以延展過期時間?

我想這應該是熟悉 X.509 憑證生態系的人最為驚訝的一件事情了。

我發現幾位公司主管並不知道這件事情,也促使我在經過一段時間後回來整理這篇文章。 事實上,GPG key 不只是可以延展過期時間,這也是一般推薦的最佳慣例。

People think that they don’t want their keys to expire, but you actually do. Why? Because you can always extend your expiration date, even after it has expired!

See: OpenPGP Best Practices - riseup.net

使用者應該設一個較短的有效時間,並在後續有需要時延展過期時間。

GPG key 可以自己修改金鑰的過期時間,是因為 GPG key 和 X.509 憑證有著本質上的區別。

GPG key 的產生是透過 primary key 的 self-signature, 而 X.509 憑證的簽署是由公正的第三方 CA 進行。

X.509 憑證的過期時間是 CA 幫你簽署憑證時決定,自然無法隨意修改, 大家也很習慣這件事情,但 GPG key 就不一樣了。 GPG key 的有效時間是透過 key 的 self-signature 內所記載的時間決定。 只要 primary (private) key 沒有遺失,持有者隨時可以重新自簽並修改時間。

只要認知到兩者本質上的差異,可以修改過期時間這件事情也就很好理解了。

他人如何認定過期時間?

既然 GPG key 可以隨時重簽修改過期時間,那對他人來說, 該如何判定某把 key 究竟什麼時候過期呢?

規則很簡單

The latest self-signature takes precedence

See: Key Management

若是透過 gpg tool 修改過期時間,舊的 self-signature 會被刪掉。 因為只有一個 self-signature,修改完之後,只要重新把 key export 給他人, 他人就可以知道新的過期時間。

若不是透過信賴管道直接把新簽的 key 給他人,而是透過 GPG key server, 狀況會有點不一樣。

基於安全考量,GPG key server 是不允許部分或完全刪除 key 的,MIT 名下的 key server 還特別寫了一篇 FAQ 來說明這件事。 對於一把已存在的 key,使用者只能推新的 sub key 或新的 signature 上去。

因此,他人透過 key server 取得 key 時,也會拿到多個 signature。 好在 signature 本身也有時戳,根據上述 “後者為準” 的規則,他人就可以知道 正確的過期時間是何時。

有興趣的可以查看筆者的 GPG key 來確認這個行為

1
2
3
4
5
6
7
8
gpg --keyserver keys.gnupg.net --recv-keys C9756E05
# Get key from key server

gpg --export C9756E05 | gpg --list-packets
# One signature has "key expires after ..." while another doesn't

gpg -k C9756E05
# Validate that the key indeed expires at some time

或是可以直接去 GnuPG 官方的 key server 查看: Search results for ‘0xc728b2bdc9756e05’

結語

翻閱文件研究的過程,慢慢感受到到 GPG 這個扣除 X.509 之外唯一成熟的 PKI 生態系,究竟有多麼偉大。同時也看到很多值得細讀的 guideline 文件。

若有時間,真的該來好好吸收整理。