Cybozu Inside Out | サイボウズエンジニアのブログ

 

インターン生 Meetupを開催しました

こんにちは、インターンシップ運営係の中園です。

2018月10月31日(水)に、サマーインターンシップ2018に参加した学生さんたちを招待し、「インターン生 Meetup」を開催しました!

f:id:cybozuinsideout:20181102124859j:plain

インターン期間中になかなか関われなかった入社1~2年目のエンジニアや、別の日程に参加したインターン生同士の交流を目的としたイベントです。

本記事では、インターン生 Meetupの内容をご紹介します!

パネルディスカッション

事前にインターン生のみなさんから募集しておいた質問をもとに、若手エンジニア社員を交え、東京オフィスと大阪オフィスをつないでパネルディスカッションを行いました。

f:id:cybozuinsideout:20181102182410j:plain f:id:cybozuinsideout:20181102182445j:plain f:id:cybozuinsideout:20181102181647j:plain

質問と回答をいくつかご紹介します。

Q. 普段、どのように最新技術の情報をキャッチアップしていますか?

・Twitterでエンジニア界隈の著名人をフォローしている
・社内で使っているkintoneで、技術情報がどんどん流れてくる
・週1回お昼にフロントエンドランチを開催し、技術を学んでいる
・海外のカンファレンスに業務として行っている

Q. お休みの日や仕事のすきま時間に行っていること(勉強など)はありますか?

・休日は趣味を満喫している
・業務時間中に技術の勉強はできるので、休日は技術の勉強以外に時間を使う
・仕事に関わらないけど興味のある技術を休日に学ぶことはある
・みんながみんな、休日もガリガリ勉強しているわけでもない

関連して、「業務利用しているOSSの休日個人開発は業務か?」のブログ記事を紹介しました。 blog.cybozu.io

インターン生の方からは、「業務に含めるが、業務にしたくないならしなくてもよい、みたいなのがサイボウズさんらしいなと思いました」といった感想もいただきました!

Q. どういった勉強会がどのくらいの頻度で開催されていますか?

・社内ではエンジニアに限らず1日3~4件の勉強会がどこかしらで開催されている
・実際に参加するのは週1くらい
・エンジニアが開催する勉強会は技術的な内容だけでなく、英語の勉強会も開催される
・国内の他拠点や海外拠点と繋いでリモートで開催することもある
・社内だけでなく、社外の方も参加した勉強会も開催されている

インターン生のみなさんから、「勉強をする風土があるの良いですね」「インターンシップの時、勉強会が多くてびっくりしたことを覚えています」といったコメントをいただきました!

Q. 将来どんなキャリアを歩んでいきたいですか?

・ずっと手を動かしていたい、できることを増やしていきたい
・エンジニア新人研修の初日に先輩社員から「キャリアは後からついてくる」という話を聞いて、そういう考え方もあるというのを知った
・やりたいことは入社1年くらいで変わってくるので、いろいろできる会社を選ぶのもいいかも

Q. 社内でのキャリアアップはどんな感じですか?

・週1である1on1面談で興味のあることや、やりたいことを上司と話すことができる。熱く語ってもいいし、雑談ベースで話すのもOK!
・1on1面談で、スクラムマスターへの道を考えるきっかけになった
・希望すれば、兼務や異動も可能
・「育自分休暇」という制度があるので、一度社外に出てスキルアップして、サイボウズに戻ってくることも可能
・「大人の体験入部」という制度で他部署の仕事を経験することもできる

「育自分休暇」や「大人の体験入部」についての関連記事: cybozushiki.cybozu.co.jp

www.wantedly.com

インターン生のみなさんから感想やより深い質問をいただくことができ、パネルディスカッションはあっという間に盛況のうちに終わりました!

交流会

f:id:cybozuinsideout:20181102183302j:plain

当日はハロウィンということで、料理やお菓子をハロウィン仕様にしてみました!

交流会では、インターンシップのメンター社員も合流しました。

インターン生と近況を報告し合ったり、最近の技術について語り合うなどアツい会となりました!!

f:id:cybozuinsideout:20181102184751j:plain

おわりに

インターンシップの各日程に参加した学生さんたちの交流会は今年初めての試みでしたが、みなさん楽しんでいただけていたようでした。 また来年も、インターンシップと、インターンシップ後のMeetupを開催できるといいなと思います!

各コースのインターンシップの実施報告記事がありますので、気になる方は是非ご覧ください。

blog.cybozu.io

blog.cybozu.io

blog.cybozu.io

blog.cybozu.io

blog.cybozu.io

blog.cybozu.io

 

React Conf 2018のKeynoteで発表されたHooks、Concurrent Reactのまとめ

フロントエンドエキスパートチームの@koba04です。

10/25,26の2日間、ネバダで開催されたReact Conf 2018に参加して来ました。 今回は、気になっている人も多いKeynoteで発表されたHooksとConcurrent Reactについて紹介したいと思います。

今回紹介された内容は、2014年後半くらいからReactを見てきた中でも、最も大きな変更であると言えます。

React Conf 2018
React Conf 2018

React Conf 2018のストラップ
React Conf 2018のストラップ

カンファレンスのトーク自体はすでにYouTubeで公開されているので、全トーク観ることが出来ます。

https://www.youtube.com/playlist?list=PLPxbbTqCLbGE5AihOSExAa4wUM-P42EIJ

Hooks

Hooksは、GitHub上でも一切公開されておらず、React Conf 2018のタイミングで初めて公開された機能です。 これまでも、Stateful Function Componentという構想は語られていたので、それを実現するAPIと言えます。

トークの動画は下記です。 デモ主体でわかりやすいので、是非トークを観ることをオススメします。

また、こちらのトークでは実際にHooksを使ったデモが行われました。

Hooksは、Function Componentに対する機能です。 現時点(2018/10/31)ではまだ提案段階の仕様ですが、16.7.0-alphaのバージョンで試すことが可能です。 下記のRFCのIssueにはすでに500件以上のコメントがある通り、注目度と議論を招くAPIであることがわかります。

https://github.com/reactjs/rfcs/pull/68

まだRFCの段階にも関わらず、公式ドキュメントにも8つのセクションに渡ってHooksの解説が用意されています。

https://reactjs.org/docs/hooks-intro.html

したがって、今の段階でもこれを読んで16.7.0-alphaを使うことで実際に試してみることが可能です。

まだ提案の段階にも関わらずここまで丁寧にドキュメントが用意されているのは、Hooksの意図を正しく伝えたいということを示していると思います。

重要なポイント

現時点では、提案段階の仕様であり実験的に試してフィードバックをもらう段階です。そのため、今後APIが変更される可能性があるため、現時点でプロダクション環境で利用することは推奨されていません (Facebookではすでにプロダクションで使っているようですが)

Hooksを利用することで、これまでClass Componentでしか出来なかったことがFunction Componentでも可能になります。 ただし、Class Componentも現時点では引き続きサポートされるため、既存のClass ComponentをHooksを使って書き直す必要は少なくとも今の時点ではありません。

将来的には、Function Componentだけになることが予想されますが、それは近い将来の話ではありません。 Facebookでも50000以上のReact Componentがあり、それら全てをHooksを使ったものに書き直す予定はないと言っています。

Class ComponentとHooksを使ったFunction Componentは一緒に使うことが可能なので、 新しいコードではHooksを使うなど、段階的な導入が可能です。

Hooksとは

HooksとはFunction Componentに対して追加された新しいAPIです。現状下記のHooksのAPIが提供されています。

  • Basic Hooks
    • useState
    • useEffect
    • useContext
  • Additional Hooks
    • useReducer
    • useCallback
    • useMemo
    • useRef
    • useImperativeMethods
    • useMutationEffect
    • useLayoutEffect

色々とありますが、まずはBasic Hooksとして定義されている3つのHooksだけでも知っておくといいと思います。

全てのHooksはuseから始まります。 これは後述するCustom Hooksを作成する際の命名規則としても適用されます。

Hooksの使い方

それでは、それぞれのHooksについて簡単に使い方を示します。

useState

useStateは、ComponentのLocal Stateを利用するためのHookです。 下記のようにStateの初期値を渡すと、現在の値Stateを更新するための関数を配列で返します。 useStateを使うことで、Class Componentを使うことなくLocal Stateを作成できます。

import React, {useState} from 'react';

const Counter = props => {
    const [count, setCount] = useState(0);
    return (
        <div>
            <p>count is {count}</p>
            <button onClick={() => setCount(count + 1)}>++</button>
            <button onClick={() => setCount(count - 1)}>--</button>
        </div>
    );
}

Stateを更新するための関数は、オブジェクトを渡した場合にもsetStateのようにマージされません。置き換えられます。

useEffect

useEffectは、副作用のある処理を定義するHookです。 API呼び出しやイベントの購読・解除など、componentDidMountcomponentDidUpdateなどのライフサイクルメソッドで行なっていたような処理を定義出来ます。 ただし、API呼び出しに対しては後述するSuspenseが適しているケースも多いです。

import React, {useEffect} from 'react';

const Header = props => {
    // textが更新されるたびにdocument.titleを更新する
    useEffect(() => {
        document.title = props.text;
    }, [props.text]);
    return <header>{props.text}</header>;
}

useEffectは第一引数に副作用のある処理を定義します。 第一引数のみ定義すると、Function Componentの関数が呼ばれる度にuseEffectに渡したコールバック関数も呼ばれます。 これは、componentDidMountcomponentDidUpdateそれぞれで呼び出す場合と同様と考えることができます。

useEffectの第二引数には、配列を指定出来ます。 配列を渡した場合、配列のいずれかの要素が変更されていた場合のみ、第一引数のコールバック関数が呼ばれます。 つまり上記の場合、props.textの値が変わった場合のみ、document.titleが更新されます。 これは、componentDidUpdateでPropsの値をチェックして変更があった場合のみ処理を行なっていたケースで利用できます。

useEffectに空の配列を渡すと、常に変化がないものとしてComponentのマウント時のみに、第一引数のコールバック関数が呼ばれます。 これは、componentDidMountを利用していたようなケースに利用できます。

useEffectは、関数を戻り値として返すことができます。 戻り値として返した関数はFunction Componentがアンマウントされる場合に呼び出されます。 これはサブスクリプションの登録、解除を行いたい場合に便利です。 下記は、イベントハンドラーの登録、解除をuseEffectを使って行なう例です。

import React, {useEffect} from 'react';

const Resize = props => {
    useEffect(() => {
        const handler = () => {
            // ...
        };
        window.addEventListener('resize', handler);
        // イベントを解除する関数を返す
        return () => window.removeEventListener(handler);
    }, []);
    return props.children;
}

useEffectはClass ComponentでのcomponentDidMountcomponentDidUpdateとは違い、DOM更新処理後に非同期で呼ばれます。 そのため、componentDidMountcomponentDidUpdateがDOM更新後に同期的に呼ばれることを保証したい場合には、後述のuseLayoutEffectを使用します。

useContext

useContextは文字通り、Contextを利用するためのHookです。 React.createContextで作成されるオブジェクトを引数として渡します。 ConsumerのComponentを渡すわけではない点に注意してください。

import React, {useContext} from 'react';

const ThemeContext = React.createContext('normal');

const Button = props => {
    const theme = useContext(ThemeContext);
    return (
        <button className={`${theme}-btn`} onClick={props.onClick}>
            {props.text}
        </button>
    );
}

useReducer

useReducerは、reducerinitialStateを渡すと、statedispatch関数を返すHookです。 Reduxをイメージするとわかりやすいと思います。 第三引数として、最初に発行するActionをオブジェクトとして渡すことも可能です。

import React, {useReducer} from 'react';

const reducer = (state, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return state + 1;
        case 'DECREMENT':
            return state - 1;
        default:
            return state;
    }
};

const Counter = () => {
    const [state, dispatch] = useReducer(reducer, 0);
    return (
        <div>
            <p>count is {state}</p>
            <button onClick={() => dispatch({type: 'INCREMENT'})}>++</button>
            <button onClick={() => dispatch({type: 'DECREMENT'})}>--</button>
        </div>
    );
};

useReducerで作成できるのはLocal Stateですが、Contextと組み合わせることで、下記のようにReduxのようなグローバルなStateを作ることも可能ではあります。

https://github.com/koba04/react-hooks-like-redux

余談ですが、Reduxに関してはすでにIssueで議論がある通り、react-reduxのconnectで行なっていたことをHooksのAPIとして提供することが予想されます。

https://github.com/reduxjs/react-redux/issues/1063

ちなみに、前述したuseStateuseReducerを使って実装されています。

https://github.com/facebook/react/blob/3db8b80e1501b161b213b0b5405590e4325a0414/packages/react-reconciler/src/ReactFiberHooks.js#L324-L332

useCallback

useCallbackは、少しわかりにくですが、メモ化された関数を返すHookです。 第二引数に配列として渡された値が変わった場合のみ、第一引数で渡したコールバック関数が再生成されます。 つまり、第二引数に配列として渡した値が変わらない限り、同じコールバックを取得できます。 これはPureComponentやReact.memoを使った場合など、子のComponentがPropsで渡された値を比較して最適化を行なっている場合に有効です。

import React, {useCallback} from 'react';

// React.memoを使った最適化
const Child = React.memo(props => (
    <div>
        <p>{props.name}({props.score})</p>
        <button onClick={props.onClick}>++</button>
    </div>
));

const Parent = () => {
    const [score, setScore] = useState(0);
    // scoreが変わった場合のみ再生成される
    const onClick = useCallback(() => {
        setScore(score + 1);
    }, [score]);
    // こう書くと毎回新しい関数が渡されてしまう
    // const onClick = () => setScore(score + 1);
    return <Child onClick={onClick} name="child" score={score} />;
};

useMemo

useMemoは、useCallbackと少し似ていますが、こちらはメモ化されたコールバックではなくを返すHookです。 第二引数に配列として渡された値が変わった場合のみ、第一引数で渡したコールバック関数を再評価して値を返します。 例えば、Propsとして渡された巨大なリストをフィルタリングするような、計算に時間のかかるケースで使うことが想定されます。

import React, {useMemo} from 'react';

const ItemList = props => {
    // itemsかtypeが変わった場合のみ再評価される
    const filteredItems = useMemo(() => (
        props.items.filter(item => item.type === props.type)
    ), [props.items, props.type])
    return (
        <ul>
            {filteredItems.map(item => (
                <Item key={item.id} item={item} />
            ))}
        </ul>
    );
};

useRef

useRefは、その名前の通りRefを格納するためのオブジェクトを作成することが主な用途のHookです。 ちなみにRef以外のオブジェクトも格納できます。

import React, {useRef, useEffect} from 'react';

// マウント時にだけフォーカスをあてる
const Input = props => {
    const el = useRef(null);
    useEffect(() => {
        el.current.focus();
    }, [])
    return <input type="text" ref={el} {...props} />;
}

useImperativeMethods

useImperativeMethodsは、forwardRefを使ってRef経由で親からアクセスさせる際に、RefのオブジェクトをカスタマイズするためのHookです。 ユースケースとしては多くないと思います。

import React, {useImperativeMethods, useRef} from 'react';

const MyInput = forwardRef((props, ref) => {
    const el = useRef(null);
    useImperativeMethods(ref, () => ({
        focus: () => {
            el.current.focus();
        }
    }));
    return <input type="text" ref={el} {...props} />;
});
// ここで取得できるrefはfocusメソッドのみ持ったオブジェクト
// <MyInput ref={ref} />

useMutationEffect

useMutationEffectは、利用方法はuseEffectと同じですが、実行されるタイミングが異なるHookです。 useMutationEffectは、ReactがDOMを更新するのと同じタイミングで同期的に呼び出されます。 DOMが更新されるタイミングで処理をしたい場合に利用します。

DOM更新中に同期的に実行されるため、DOMのプロパティにアクセスすることで強制的なレイアウトの再計算が行われ、パフォーマンスに悪い影響を与える可能性があります。 基本的には利用することを避けるべきHookだと思います。

useLayoutEffect

useLayoutEffectは、useMutationEffectと似ていますが、こちらは全てのDOMの更新処理が終わったタイミングで同期的に呼ばれるHookです。 更新後のレイアウト情報をDOMから同期的に取得したい場合に利用します。 これは、Class ComponentのcomponentDidMountcomponentDidUpdateが呼ばれるタイミングと同じタイミングで呼ばれます。 DOMの更新処理の後に同期的に呼ばれるため、可能であればuseEffectを使う方が望ましいです。

useEffectuseMutationEffectuseLayoutEffectのタイミングをUser Timingで示すと下記の通りです。 useMutationEffectがHost Effects(DOMの更新処理のタイミング)に、useLayoutEffectがライフサイクルメソッド呼び出しのタイミングに、useEffectがそのあと非同期に呼ばれていることがわかります。

各Effectのタイミングの違い
各Effectのタイミングの違い

Hooksの制限

HooksはFunction Componentの関数内から呼び出す必要があります。 また、HooksはFunction Component内において、毎回同じ順番で呼び出す必要があります。 そのため、関数内のトップレベルで呼び出すことが推奨されています。条件分岐などの中で呼び出すことは避ける必要があります。

const Foo = props => {
    // OK
    const [foo, setFoo] = useState('foo');
    if (props.bar) {
        // NG
        const [bar, setBare] = useState('bar');
    }
    return null;
}

これをチェックするためのeslint-plugin-react-hooksというESLintのプラグインも同時に公開されています。

https://reactjs.org/docs/hooks-rules.html#eslint-plugin

この制限は、少しわかりにくく感じますが、HooksのAPIをシンプルにするためのトレードオフとして選択したとのことです。

Custom Hooksの作り方

Hooksは前述した通りFunction Componentから呼び出す必要がありますが、独自に定義したHooksの中で呼ぶことも可能です。 そのため、特定の処理をHooksとして共通化することが可能です。

Custom Hooksは前述したeslint-plugin-react-hooksでのチェックを有効にするためにも、useから始まる名前で作成することが推奨されています。 例えば、windowのサイズを返すHookは、下記のように作成して使用できます。 作成したHookは、Componentの描画には一切関与していないため、どこでも再利用できます。

  • useWindowSize.js
import {useState, useEffect} from 'react';

// windowサイズを返すHook
export const useWindowSize = () => {
    const [width, setWidth] = useState(window.innerWidth);
    const [height, setHeight] = useState(window.innerHeight);
    useEffect(() => {
        const handler = () => {
            setWidth(window.innerWidth);
            setHeight(window.innerHeight);
        }
        window.addEventListener('resize', handler);
        return () => window.addEventListener(handler);
    }, []);
    return [width, height];
}
  • 作成したHookを使う
import React from 'react';
import {useWindowSize} from './useWindowSize';

const WindowSize = () => {
    const [width, height] = useWindowSize();
    return <p>width:{width}, height:{height}</p>
};

Hooksの目的

Componentのロジックを再利用するための手段としては、Higher Order Component(以下HOC)やRender Propsのパターンがありますが、 これらはコードを理解するのが難しかったり、"wrapper hell"と呼ばれる大量のComponentのネストが作成されるという問題があります。 recomposeのようなHOCのユーティリティライブラリを使っていて、気づかないうちに"wrapper hell"を作ってしまっているケースも多いと思います。 "wrapper hell"はデバッグの難しさや、見た目のViewの構造以上にComponentが大量にネストすることで、パフォーマンスに対しても影響があります。

また、サーバーからのデータ取得やイベントの購読・解除の処理をClass Componentが持つライフサイクルメソッドを使って記述しようとすると、コードが冗長になったりライフサイクル毎に処理が分断してしまう問題があります。 加えて、全てのライフサイクルメソッドの挙動を理解して適切にロジックを書くのは難しいという意見もあります。

Hooksは、Componentのロジックの再利用をするためのPrimitiveとして新しく提案されました。

例えばHooksでは、Componentのライフサイクルについて考える必要はなく、useEffectなどを使うことで「この値が変わったらこの処理をする」といったように、行いたい処理だけに注目してロジックを書くことができます。

Reactでは、Function Componentを使ったとしても内部ではFiberと呼ばれているデータ構造で状態を保持しています。 ライフサイクルメソッドがComponentに対するハイレベルなAPIであったのに比べて、HooksはFiberの内部構造に対するPrimitiveなAPIとして考えることもできます。 それは、@dan_abramovがKeynoteの中で、Atom(原子)に対するElectron(電子)のように、HooksはReactを構成する各機能の要素であると言っていることからもわかります。

React内部の仕組みについては、builderscon 2018で話したので、興味あれば見てみてください。

https://speakerdeck.com/koba04/algorithms-in-react

余談ですが、Hooksのソースを見てみると、HooksはUpdateと同様に単方向のLinked Listとして実装されているのがわかります。

https://github.com/facebook/react/blob/3db8b80e1501b161b213b0b5405590e4325a0414/packages/react-reconciler/src/ReactFiberHooks.js#L52-L60

これを、Function Componentが処理される度に初期化して順番にアクセスしていくため、必ず同じ順番に呼び出す必要があります。 他にも、useReducerの実装を見てみると、更新処理は既存のsetStateの仕組みとは違い、別の更新キューを作って処理していることがわかったりと興味深い部分も多いです。

Keynoteでは、Hooksの導入の理由としてJavaScriptにおけるClassが、機械にとっても人間にとっても扱いが難しいものである点をあげています。 例えば、イベントハンドラー登録時のthisの扱いにハマる人が多かったり、constructorなどの定型コードによりコード量が多くなったり。 また、Function Componentで書いた後に状態やライフサイクルメソッドが必要になった際に大きくコードを書き換える必要があるなど、ClassがDeveloper Experienceに与える影響を指摘しています。

また、機械にとってもClassは最適化(プロパティ名やメソッド名のminifyや処理のインライン化など)が難しいという問題点を指摘しています。 この辺りは、Prepackで取り組んでいる結果としての結論なのかなと思います。 他にもHot Reloadingも難しいという問題もあるようです。

現状、getSnapshotBeforeUpdatecomponentDidCatchgetDerivedStateFromErrorなど、一部のライフサイクルメソッドに対応するHooksが提供されていませんが、これらもいずれ提供される予定とのことです。

将来的にはHooksを使うことで、Class Componentを廃止する流れになるかと思います。 ですが、それは近い将来の話ではないので、慌てず少しずつ試していくのがいいかなと思います。

Concurrent React

Hooksが話題になる中、次の日のKeynoteではConcurrent Reactについて発表されました。

トークの動画は下記です。 デモ主体でわかりやすいので、是非トークを観ることをオススメします。

また、こちらのトークでは実際にSuspenseを使ったデモが行われました。

Concurrent Reactは、これまでAsync Renderingと呼ばれていたもので、SuspenseとTime-slicingの2つの機能を指します。 今回は新しい何かが発表されたというより、現在の状況を改めて説明して、デモでどういったことが可能になるかを示すものでした。

Suspense

Suspenseはレンダリングを中断(Suspend)できる機能です。 Suspenseは、Promise(正確にはthenableなオブジェクト)をPrimitiveとして扱うため、APIからのデータ取得やComponentの動的な読み込みだけでなく、Promiseでラップすることで様々な非同期処理に対して適用できます。

Suspenseの仕組みについては、過去に何度か紹介しているので興味あれば参照してください。 スライドで紹介しているバージョンからAPIの名前は変わっていますが、基本的な仕組みやコンセプトは同じです。

本トークでは、React.lazyと組み合わせた動的なComponent読み込みが紹介されていました。

import React from 'react';

const LazyContent = React.lazy(() => import('./LazyContent'));

const App = () => (
    <main>
        <section>
            <p>Main</p>
            <React.Suspense fallback={"loading..."}>
                <LazyContent />
            </React.Suspense>
        </section>
    </main>

);

上記では、LazyContentのComponentを動的に読み込んで、読み込まれるまではloading...のメッセージを表示しています。 上記の場合、loading...のメッセージが即座に表示されますが、後述するReact.ConcurrentModeを使うことで指定秒経過後にローディングをメッセージを出すといったことも可能になります。

上記で使用している動的なimportはまだ提案段階の仕様ですが、webpackなどを使っている場合にはすでに利用可能です。 このようにReact.lazyを使うことで、webpackなどのツールのサポートは必要ですが、importの仕方を変えるだけで簡単に動的な読み込みが可能となります。

React.SuspenseのComponentは親の位置であればどこでも配置可能なため、ローディングで隠す範囲も簡単に指定できます。 また、例えば複数の非同期な依存関係がある場合に、それらの親にReact.Suspenseを定義することで、全ての非同期の依存が解決するまで、単一のローディングメッセージを出すといったことも可能です。 このようにSuspenseを使うことで、柔軟な非同期読み込みの制御が可能です。

APIリクエストについては、react-cacheというパッケージのunstable_createResourceを利用することが下記の通り記述できます。 ただし、react-cacheについてはまだStableではなく、キャッシュのInvalidationなど欠けている機能もあるのでまだ実際に利用できるレベルではないとしています。

import React from 'react';
import {unstable_createResource as createResource } from 'react-cache';

// Promiseを返す処理からリソースを作成する
const userResource = createResource(id => {
    return fetch(`/api/user/${id}`).then(res => res.json())
});

const User = props => {
    // リソースを読み込む
    // リソースがキャッシュされていない場合は、Promiseがthrowされるのでレンダリングが止まる
    const user = userResource.read(props.id);
    return <div>{user.name}</div>;
}

const App = () => (
    <React.Suspense fallback="loading...">
        <User id={1} />
    </React.Suspense>
);

上記では、指定したユーザのデータがキャッシュになければfetchを使ってAPIから取得してキャッシュに格納します。 その際、React.Suspenseに指定しているloading...のメッセージが表示されます。 その後、APIレスポンスを受け取るとレンダリングが再開されます。 再開時には、APIレスポンスのデータがキャッシュに格納されていてデータを同期的に取得できるので、Userが表示されます。

Suspenseの応用例として、"Moving To Suspense"のトークでは、React.Suspensefallbackに低解像度の画像を指定して、高解像度の画像を非同期にロードすることで、最初は低解像度の画像を表示してそのあと高解像度の画像に差し替えるといったデモも行われてました。

また、unstable_createResourceで作成したリソースはreadだけでなくpreloadというメソッドも持っていて、これを使うことで、事前にデータをキャッシュしておくことも可能です。

Suspenseについては、上記のような同期モードでの基本的な挙動については、すでにStableだとしていますが、react-cacheを使ったAPIデータの取得やReact.ConcurrentModeを使ったConcurrentModeについては、まだまだStableでないとしています。

ConcurrentModeはレンダリングを非同期にするためのモードです。 React.ConcurrentModeのComponentで囲むことで、その中はConcurrentModeになります。 また、ReactDOM.createRoot(domElement).render(ReactElement)という新しい方法でDOMをマウントすることで、全体をConcurrentModeにすることも可能です。

部分的に非同期レンダリングを導入したい場合にはReact.ConcurrentMode、アプリケーション全体を非同期レンダリングしたい場合にはReactDOM.createRootを使います。

ConcurrentModeでは、React.SuspensemaxDurationを指定できます。 maxDurationを指定することで、fallbackを表示するまでの時間を制御できます。 例えば、ネットワーク環境がよくてAPIリクエストが1秒以内に返ってくるような状況では、ローディングを表示せずにデータのロードを待って表示した方がスムーズです。 そういった場合に、maxDuration={1000}のように指定することで、1秒経過してからローディングを表示するといった制御が可能です。

Suspenseを利用することで、非同期な依存関係の制御が簡単に柔軟にできるようになります。

また、サーバーサイドレンダリング対応についても取り組んでいるようです。

Time-slicing

Time-slicingは、更新処理を優先度付け出来る機能です。 プライオリティをベースとした協調的マルチタスクにより、メインスレッドをブロックしない更新処理を可能にします。 これにはschedulerというパッケージを利用します。

Reactでは、clickinputtouchmoveなど、ユーザーがすぐにリアクションを期待するようなイベントに対しては、InteractiveUpdateとして高い優先度が割り当てられます。 ただし、巨大なスプレッドシートに対するフィルタリングなど、一度の更新処理が重い場合にはユーザー入力や他の更新をブロックしてしまいます。 この場合、ユーザーにとってはフィルタリングするためのテキストボックスはすぐに反映されて欲しくて入力もブロックして欲しくないですが、フィルタリングした結果の表示については、少しくらい遅れても問題ないことが多いです。

このような場合に、Time-slicingを使うことで、フィルタリングするためのテキストボックスへの更新処理は優先度高く反映して、フィルタリングした結果については優先度を下げて反映を遅らせることが可能となります。

import React, {useState, useMemo} from 'react';
// このバージョンはまだnpmにpublishされていない
import {scheduleCallback} from 'scheduler';

const App = props => {
    const [text, setText] = useState('');
    const [filterText, setFilterText] = useState('');

    const filteredItems = useMemo(() => (
        props.items.filter(item => item.indexOf(filterText) !== -1)
    ), [filterText, props.items]);

    return (
        <main>
            <Input
                value={text}
                onChange={value => {
                    setText(value);
                    // Filterする方の優先度は下げる
                    scheduleCallback(() => {
                        setFilterText(value);
                    })
                }}
            />
            <List items={filteredItems}  />
        </main>

    );
};

上記のサンプルは、使用しているschedulerscheduleCallbackがまだ公開されていないため動作しませんが、以前作成したデモがあるので、そちらを試してもらうと雰囲気が掴めるかなと思います。

また、ConcurrentModeでは、hiddenのPropsによるメインスレッドを邪魔しないプリレンダリングが可能です。 hiddenのPropsをDOM Componentに指定することで、その子要素はOffScreen Priorityという特殊な優先度で処理されます。 OffScreen Priorityはとても低い優先度として定義されているため、他の更新処理をブロックしません。

トークではタブUIの例が示されていましたが、ユーザーが表示するページを先に読み込んでおくことで高速なページ遷移を実現できます。 Suspenseと組み合わせることで、事前に非同期の依存関係をロードすることが可能になるため、さらに強力な仕組みとなります。

const Home = React.lazy(() => import('./Home'));
const User = React.lazy(() => import('./User'));
const Settings = React.lazy(() => import('./Settings'));

const App = props => (
    <main>
        <div hidden={props.page === "user"}>
            <User />
        </div>
        <div hidden={props.page === "settings"}>
            <Settings />
        </div>
        <div hidden={props.page !== "user" && props.page !== "settings"}>
            <Home />
        </div>
    </main>
);

上記では、最初に表示したページ以外も動的に読み込んでプリレンダリングしています。

まとめ

このように今回発表された内容は、Reactを使ったアプリケーションの作り方を変える大きなものでした。 ここからReduxなどの周辺ライブラリがこれらのAPIをどう使うかにもよりますが、時間をかけて今回紹介された内容を使った書き方に変わっていくと思います。 まだ安定版としてリリースされたという状況ではないので、盛り上がりに踊らされず少しずつ試していくのがよさそうです。

また、Keynoteでは、この他にも新しいProfilerについての紹介もあったのでそちらも注目です。

サイボウズのフロントエンドエキスパートチームでは、一緒にフロントエンド分野の問題解決に取り組んでくれる仲間を募集しています。

 

サイボウズサマーインターン2018 報告その6 〜Webサービス開発コース(Garoon)

こんにちは!Garoon開発チームの洲﨑です。この夏に開催されたエンジニア向けのサマーインターンシップ、前回はUX/UIデザイナーコースの紹介でした。今回は「Webサービス開発コース(Garoon)」の紹介します!

f:id:cybozuinsideout:20181018175745j:plain

Garoon参戦!

これまでkintoneのみの開催であったWeb開発コースですが、新たにGaroonでも開催することになりました! 5日間、東京オフィスでGaroonの製品コードに触れてもらい、プロトタイプを実装してもらいました!

Webサービス開発コース(kintone)と似ているのですが、製品の特性から以下の点が異なります。

  • 使用言語。バックエンドはPHP
  • コード量と機能が多い。リリースされて今年で16年
  • 複数バリエーションを考慮。クラウド版に加えてオンプレ版など複数種類ある

やったこと

5日間で機能のプロトタイプを作成してもらいます。 基本的なタイムスケジュールは以下の通りです。 f:id:cybozuinsideout:20181015175527p:plain

実装してもらうプロトタイプはインターン生向けに選んだ要望機能リストの中から選んでもらいます。 プロトタイプ作成では具体的に以下のことを行いました。

  1. 外部仕様書/内部仕様書の執筆
    要件から仕様を固める。読み手を考えながら外部仕様と内部仕様を書く。
  2. コーディング
    Githubでプルリクを作成してメンターがレビュー。

ちょうどREST APIの開発を行っていたので、課題もREST API絡みのものが多かったですね。 既にあるGaroonの機能をAPI越しに操作できるようにするので、既存実装を読み解く時間が多かったと思います。

実際にGaroonで取り入れているスクラム開発も体験してもらうなど、できるだけ実際の開発形式に近い形で業務体験をしてもらいました。 プログラマの業務の雰囲気を感じ取ってもらえたのではないかと思います。

次にインターン生が作成してくれたプロトタイプの一部を紹介します。

インターン生が取り組んでくれた課題

メールのREST API(GET/POST)

Garoonには「メール」と呼ばれるメーラーとしての機能があります。それをREST API経由で操作できるようにしてくれました!

実装してくれたのはGETとPOSTで、API越しにメールの送受信できるようになりました。

さらにLINEと連携したAPIの使用例も作ってくれました。 LINEと連携して、メッセージを送るとGaroon越しにメールが送られます! f:id:cybozuinsideout:20181029102511p:plain

メモのREST API(GET/POST)

Garoonには「メモ」と呼ばれるテキストと画像を記録する機能があります。このメモをREST API経由で操作できるようにしてくれました!

テキストを記録するシンプルな機能なのですが、それに加えて画像も保存できるようにしてくれました。

こちらもAPIと合わせてChrome拡張を使ったAPIの使用例を作ってくれました。 Chrome拡張を使って今見ているサイトのURLとスクリーンショットをGaroonのメモに保存することができます! f:id:cybozuinsideout:20181029102456p:plain

技術以外のこと

さて、このインターンは開発だけではありません。 他にも、インターン中は勉強会や青野さんとのランチなどのイベントがあります。 この辺りは他のコースと共通なので割愛します。 f:id:cybozuinsideout:20181018180224j:plain

インターン生の声

実際に参加してくれた学生さんからの感想をいくつか紹介します。

私はcybozuのwebコースに参加させていただき、実際にGaroonのコードに触りました。古くからあるアプリケーションなので歴史を感じる部分もありつつ、メンターの方のサポートが手厚かったので問題なくインターンの課題を進めていくことができました。さらにメンターの方以外と話す時間なども設けていただくことができ、Webコースのみならず自分が気になっていたけど参加できなかったコースの話なども十分に聞くことができ、とても満足しています。


技術面で勉強になったのはもちろんですが、カンバンやスクラム開発をうまく運用している現場を見れたのも良かったです。天野さんとの面談が盛り上がり、楽しくキャリアパスやスクラム開発に関するお話を聞けたのは思わぬ収穫でした。全体を通して、社員のみなさんがとても楽しそうに思い思いに働いている姿が印象的でした。


GaroonのAPIの作成はとても楽しかったです.メンターの方が1:1対応していて,わからない部分があれば,すぐに聞ける環境が整っていました.そのため,実質実装3日ほどでしたが,GET/POSTの実装までたどり着くことができました.また,実際のプロダクト開発の流れと同じように進めていただいたので,就業体験としも非常に有意義な経験となりました.ありがとうございました.

まとめ

今回のWeb開発コース(Garoon)の概要をまとめると以下の通りです。

  • 規模大きめのコードに触れた
  • 既存コードを読み解いて新機能プロトタイプを作成した
  • 普段と同じ開発フローを体験した

Garoonチームの開発の雰囲気を掴んでもらえたのではないでしょうか。 今回の体験がインターン生にとって何らかの糧になってくれていれば幸いです!

報告は以上です!それでは!

 

Neco のネットワーク - 実装編

Neco プロジェクトの ymmt です。Neco は cybozu.com のインフラを刷新するプロジェクトで、先日は全面的に見直したネットワークのアーキテクチャと設計をご紹介しました。

blog.cybozu.io

簡単にまとめると、spine-leaf ネットワークを実現するにあたって、全てのサーバーで BGP + BFD + ECMP を使うことで Layer-2 拡張技術を使うことなく経路の冗長化と障害時の高速な経路収束を達成しましょうという内容でした。

今回は具体的な実装方法について解説します。ただ、かなり前提知識が必要となるため以下二つのチュートリアルを別に用意しました。BGP や BIRD に詳しくない方はまずこちらをご覧ください。

本記事で解説するのは以下の内容です。長文になりますのでお時間のあるときにどうぞ。

  1. AS 構成の選択
  2. ラック内の構成
  3. 代表 IP アドレスの実装と注意点
  4. ICMPリダイレクトを避ける工夫
  5. Kubernetes への組込み

AS 構成の選択

Neco では全てのサーバーが BGP ルータとなります。ということは、全てのサーバーに異なる AS 番号を持たせて全て eBGP でピアリングすることが可能です。これを Calico では AS per Compute Server モデルと呼んでいます。

次の選択肢としては、同一ラック内のサーバーと Top-of-Rack (ToR) スイッチ 2 台は全て同じ AS 番号とし、ラック内は iBGP、ToR スイッチと Spine スイッチ間は eBGP という構成が考えられます。これを Calico では AS per Rack モデルと呼んでいます。

他のバリエーションも考えられますが基本的にはこのどちらかの選択となるでしょう。どちらを選んでも構築は可能ですが、違いを簡単に比較してみたのが以下の表となります。

項目 AS per Compute Server AS per Rack
AS 数 概ねサーバー台数と同じ 概ねラック数と同じ
BGP設定 量は多いが単純 量は少ないが iBGP に注意が必要

Neco では AS per Rack 構成を採用しました。主な理由は、ToR/Spine スイッチを管理するネットワークチームからの要請です。As per Compute Server モデルではネットワークチームの管理が及ばないサーバーが個別に AS 番号を名乗ることになるので、これを避けました。

以下の図のように、ラック内の ToR スイッチ 2 台とサーバーで一つの AS を構成します。

f:id:cybozuinsideout:20181029013618p:plain

ラック内の構成

前述のようにラック内は iBGP で ToR スイッチ 2 台と全てのサーバーは同一の AS 番号を持ちます。MC-LAG のような L2 拡張技術は使わないので、ToR スイッチは一体ではなく、それぞれ別の L2 サブネットを有します。

iBGP 構成を簡略化するため、各 ToR スイッチはルートリフレクターとして動作します。そうすることでラック内のサーバーは ToR スイッチ 2 台とのみピアリングすれば良く、ラック内の他サーバーと直接ピアリングする設定を書かなくて済みます。

各サーバーは ToR スイッチ二台と接続するために NIC のポートを二つ持ちます。別々の L2 ドメインに接続するわけですから、ポート 1 とポート 2 で異なるサブネットのアドレスが割り当てられます。

この L2 サブネットの利用範囲は非常に限定的です。一つは、ネットワークブートするために DHCP でブロードキャストするために使われます。ネットワークブート後は、ToR スイッチとのピアリングにのみ使われます。その他の通信の用途には、次に説明する代表 IP アドレスを使用します。

f:id:cybozuinsideout:20181029023526p:plain

代表 IP アドレスの実装と注意点

二つの NIC ポートが持つアドレスをそのままサーバーのアドレスとするのは、いくつか問題があります。

  1. ToR スイッチが持つ L2 サブネットのアドレスなので、スイッチ故障時に到達できなくなる
  2. Kubernetes ではサーバー(Node)のアドレスは原則として一つ

2 の例としては、Pod が動作しているホストの IP アドレスの表現(status.hostIP)などがあります。

そこで、各サーバーに ToR スイッチが持つサブネットとは無関係な代表 IP アドレスを持たせます。この代表 IP アドレスは /32 のネットマスクをつけて BGP で経路広告することで、他のサーバーがアクセスできるようになります。

dummy デバイスによる実装

Linux の場合、dummy デバイスを以下のように作成すれば代表 IP アドレスを持たせられます。

$ sudo ip link add node0 type dummy
$ sudo ip link set node0 up
$ sudo ip address add 10.1.0.1/32 dev node0

node0dummy デバイスとして作られます。node0 のアドレスを経路広告するには BIRD に以下の設定を入れます。

protocol direct {
    ipv4;
    interface "node0";
}

protocol kernel {
    merge paths;
    ipv4 {
        export filter {
            if source = RTS_DEVICE then reject;
            accept;
        };
    };
}

protocol directnode0 のアドレスを BIRD の master4 ルーティングテーブルにインポートしています。

master4 で BGP ピアと経路交換して Linux カーネルに必要な経路を登録するのが protocol kernel です。この際に node0 のアドレスは自サーバーのものですので、カーネルに再登録しないようフィルターしているのが if source = RTS_DEVICE then reject; です。

不定送信元アドレス問題

もう一つ、node0 を代表アドレスにするためにやらなければいけないことがあります。それは node0 以外のリンクが持つ IP アドレスのスコープを link local に制限することです。例えば eno1 のアドレスを systemd-networkd で設定している場合は以下のように Scope=link を指定します。

[Match]
Name=eno1

[Address]
Address=10.1.0.65/26
Scope=link

Linux カーネルは、スコープが global なアドレスであれば、どのアドレスであっても直接 NIC が所属していない宛先 IP アドレスのパケットの送信元アドレスに利用してしまいます。そのため node0 以外に NIC のポート二つが持つアドレスのスコープを global のままにしておくと、3 つのアドレスのどれが送信元アドレスに使われるか不定となってしまうのです。

余談ですが、この問題に気付いた当初は Linux カーネルの不具合の可能性もあるかと、再現コードを準備して LKML で質問しました。その結果上記の動作が仕様であることと、アドレスのスコープを制限する対策についてヒントをいただけて無事解決できました。メールアーカイブで一連の投稿が読めますのでご興味ある方はどうぞ。

ICMPリダイレクトを避ける工夫

AS per Rack モデルなのでラック内のサーバーは ToR スイッチ 2 台と iBGP でピアリングします。そしてフルメッシュ構成を避けようと ToR スイッチをルートリフレクターにすると、少し難しい問題が発生する可能性があります。

  1. ToR スイッチが eBGP で受け取った経路の NEXT_HOP アドレスは iBGP であるため各サーバーにそのまま届く
  2. 各サーバーにとってその NEXT_HOP は外部のネットワークアドレスであるため未知である場合がある
  3. 未知の NEXT_HOP アドレスを持つ経路を BIRD は受け取らず捨ててしまう

よくある対処その 1 は、未知にならないように当該アドレスを含む経路を同時に BGP で広報する方法です。一般的なルータはこの方法で対処できることが多いのですが、残念ながら BIRD は BGP で同時に広報されたとしても受け取りません。

よくある対処その 2 は OSPF 等の IGP で当該アドレスの経路を広報する方法です。これは BIRD でも有効ですが、せっかく BGP にまとめようとしているのに IGP を併用するのは嬉しくありません。

よくある対処その 3 は next-hop-self を ToR スイッチで設定する方法です。これは eBGP 経路の NEXT_HOP アドレスを ToR スイッチのアドレスに書き換えて、未知アドレスではなくするものです。

next-hop-self + ルートリフレクターで発生する問題

next-hop-self は通常パケットの送り先となるルータを NEXT_HOP にするので大きな問題になりにくい設定です。しかしながら、ここまで解説してきた:

  • 同一 L2 ドメイン内に多数 BGP ルータが存在して
  • ToR スイッチがルートリフレクターである

ケースでは、本来直接パケットを届けることができる同一ラック(L2)内のサーバーへの経路も ToR スイッチのアドレスに書き換えられてしまいます。すると下図のように、ToR スイッチはパケット送信元に ICMP リダイレクトメッセージを返して直接通信するよう伝えます。

f:id:cybozuinsideout:20181030015947p:plain

ICMP リダイレクトの発生は避けたほうが効率的というばかりでなく、一部のスイッチでは BFD との併用ができないといった問題が発生する場合があります()。他にも複数の ICMP リダイレクトを避けたほうがいい理由が存在します(参考)。

next-hop-self に頼らず BIRD の設定で解決

未知の NEXT_HOP アドレスを持つ経路を破棄してしまう問題に対するよくある対処は 1, 2, 3 とも欠点があることが分かりました。

( ˘⊖˘) 。o(待てよ?ルートリフレクターやめてフルメッシュにすればいいのでは?)

( ◠‿◠ )☛そこに気づいたか・・・消えてもらう

仕方がないので BIRD の設定を工夫して解決しました。BIRD の使いかたに書いてあるのですが、以下のようにフィルターで NEXT_HOP (gw) を条件に応じて書き換えて受け取ることで、eBGP 経路のみ ToR を NEXT_HOP とし、同一 L2 内のルータの NEXT_HOP は書き換えないということが可能です。

# BIRD が未知の NEXT_HOP を持つ経路を破棄しないように、
# ダミーでどんな経路もとりあえず受け取るテーブルを用意
ipv4 table dummytab;
protocol static dummystatic {
    ipv4 { table dummytab; };
    route 0.0.0.0/0 via "lo";
}

protocol bgp {
    ...

    ipv4 {
        igp table dummytab;  # ダミーテーブルを参照して NEXT_HOP が未知でも受け取り
        gateway recursive;   # direct 接続時も IGP テーブルを参照させる
        import filter {
            # ラック内 AS (iBGP ピア)からの経路なら bgp_next_hop を維持
            # 以下の式は送信元ルータのアドレスを 26bit サブネットに含まれるかの判定
            if bgp_next_hop.mask(26) = from.mask(26) then {
                gw = bgp_next_hop;
                accept;
            }

            # そうでないなら from をゲートウェイに(next hop self 相当)
            gw = from;
            accept;
        };
    };
}

Kubernetes への組込み

Kubernetes のネットワークプラグインには、BGP で経路広告して Pod 間通信を実現するものが多数あります。

  • Calico: Pod に /32 な IP を割り当てて組込みの BIRD で経路広告
  • Romana: 経路数を減らせるよう Pod アドレスをブロック単位で BGP 経路広告
  • kube-router: CNI bridge プラグインと BGP の組み合わせ

Neco では Pod だけでなく、データセンターネットワーク全体を BGP で制御するため、既に全サーバー上で複雑な設定を持つ BIRD が動作しています。Calico は BIRD を組込んでしまっているため、Neco の BIRD と連携させるのが困難で選択肢から外しました。余談ですが Calico は MetalLB との組み合わせも難しいようです(参考)。

kube-router と Romana を比較すると、Romana は BIRD を直接組込まず、BGP による経路広告はオプション機能としている点や、柔軟なアドレス割り当て方式が優れていると判断しました。

このようにネットワーク設計段階では Romana を採用するつもりだったのですが、実装する段階で様々な点を考慮して自社製のネットワークプラグイン Coil を開発する方針に変更しました。設計開始から実装終了まで 2 週間で、すでに完成しています。

Coil の紹介はまた機会を改めて。

まとめ

Neco のデータセンターネットワークの実装にあたり解決が必要だった諸問題と、その解決方法を紹介しました。基本的なところはすでに動作しており、今後は CiliumIstio, MetalLB と組み合わせて高度な機能を実装していきます。

Neco プロジェクトではネットワークやストレージ、コンテナ技術などを一緒に追求するメンバーを募集しています。Neco チーム専属の採用枠も用意していますので、エントリーをお待ちしています!

www.wantedly.com

 

ルーティングソフトウェア BIRD の使いかた

Neco プロジェクトの ymmt です。本記事では Neco のネットワークの実装を理解するために、ルーティングソフトウェアである BIRD の仕組みと設定方法を解説します。

公式文書がすこしとっつきにくいので、こちらを読んでから公式文書にあたるとスムーズに理解ができると思います。見所は、Invalid NEXT_HOP への工夫をこらした各種対処方法です。

以下、社内向けの解説文書からコピペしているので文体が変わります。悪しからずご了承ください。

BIRD とは

BIRD は Linux 等で動作する BGP や RIP などのルーティングプロトコルを実装したプログラムである。2018 年 10 月時点の最新版は 2.0.2 で、2.0 と 1.6 の両系列がメンテナンスされている。

本記事の内容はバージョン 2.0.2 を対象とする。

アーキテクチャ

BIRD は単独のプロセスとして動作する。

内部に複数のルーティングテーブルを持ち、それらを protocol と呼ばれる仕組みで外部のルータや Linux カーネル(が持つルーティングテーブル)とつなげることができる。

以下で理解が必要なものについて解説する。

Routing tables

BIRD は複数のルーティングテーブルを持てる。うち、以下は最初から用意されている。

  • master4

    ipv4 nettype でデフォルトで使用されるルーティングテーブル

  • master6

    ipv6 nettype でデフォルトで使用されるルーティングテーブル

IPv4 用のテーブルを別に作るには、以下のように bird.conf に書く。

ipv4 table another_table;

これらは Linux カーネルが持つルーティングテーブルとは関係がない。

BIRD のルーティングテーブルは BGP であれば AS の経路情報(aspath)など様々な属性を持つ route を登録するもの。Linux カーネルのルーティングテーブルにはそのような属性はなく、単純に prefix match でパケットの転送先を決めるFIB (Forwarding Information Base)としての役割しか持たない。

以下、ルーティングテーブルと言えば BIRD のルーティングテーブルを指す。カーネルのルーティングテーブルは「フォワーディングテーブル」もしくは「FIB」と呼ぶ。

Routes and nettypes

Route は文字通り経路だが、IPv4/IPv6 で言えば対象となるアドレスレンジ(サブネット)と、宛先(nexthop)、それに加えこの Route を連絡してきたネタ元(source protocol instance)や優先度(preference)などなどの属性が付属しているもののこと。

nexthop は複数あることもあり、その場合どの経路をどの程度の割り合いで選ぶかの重み(weight)を持つ。全ての nexthop が同じ重みを持つ場合は Equal Cost Multipath (ECMP) となる。

nettypes には先述の ipv4, ipv6 以外に flow4 などあるが、我々の用途では基本的に ipv4ipv6 しか利用しない。

後述する route filter は route を受け取る関数となっており、route が持つ各種属性を関数内で変数として参照できるようになっている。

Protocols and channels

Protocol はルーティングテーブルと「何か」をつなぐもの。「何か」が他のルータである場合、protocol は BGP や OSPF といったルーティングプロトコルを指す。「何か」はルータ以外のこともあり、例えば kernel プロトコルは Linux kernel が持つフォワーディングテーブル(FIB)と BIRD のルーティングテーブルとの経路交換を実現する。

Protocol は接続先に応じてインスタンスを複数持てる。例えば、BGP であればピア先のルータ毎にインスタンスを作る。これらを区別するため、protocol のインスタンスは名前を付けられる。以下は ToR 2 台と eBGP 構成を取るルータの例。インスタンスに tor1, tor2 と名前を付けている。

protocol bgp tor1 {
    local AS 65000;
    neighbor IP_OF_TOR1 AS 65001;
    ...
}
protocol bgp tor2 {
    local AS 65000;
    neighbor IP_OF_TOR2 AS 65001;
    ...
}

Protocol の中には channel が書ける。channel は nettype 毎に作れるので、つまるところ ipv4ipv6 が channel 名となる。BGP protocol では ipv4, ipv6 両方の channel を持てるが、RIP などは一つしか持てない。この辺りはプロトコルの特性による。後述する bfd などの特殊な protocol は channel を持つことができない。

channel は、ルーティングテーブルとその protocol インスタンスの接続先で経路を交換する方法を示す。ipv4 channel では、指定をしないと master4 ルーティングテーブルと経路交換をすることになる。

protocol bgp tor1 {
    local AS 65000;
    neighbor IP_OF_TOR1 AS 65001;

    ipv4 {
        import all;
        export filter {
            if proto = "static1" then reject;
            accept;
        };
    };
}

上の例は tor1 BGP インスタンスが IPv4 の経路を master4 ルーティングテーブルにどう入れるかを設定している。import は protocol の接続先からルーティングテーブルに経路を入れる設定で、all であるので neighbor AS から受け取った経路は全て master4 に入ることになる。

exportmaster4 から neighbor AS に伝える経路の設定で、後述する経路フィルタで一部の経路を広報しないようにしている。

channel の設定が空の場合(デフォルトのままでいい場合)は、{, } ごと省略できる。ipv4 channel であれば単に ipv4; と書くことになる。ただし BGP protocol のうち eBGP となる場合は export, import の明示が必要であり、この記法は使えない。

重要な機能

protocol template

BGP のピア毎に protocol を書いていくと、共通点が非常に多い設定が並ぶ。このような作業を効率的に行えるよう、BIRD は protocol template を提供している。以下のように使う。

template bgp tor {
    local AS 65000;
    rr client;

    ipv4 {
        import all;
        export filter {
            if proto = "static1" then reject;
            accept;
        };
    };
}

protocol bgp tor1 from tor {
    neighbor IP_OF_TOR1 AS 65001;
}
protocol bgp tor2 from tor {
    neighbor IP_OF_TOR2 AS 65001;
}

経路フィルタ

経路フィルタは、route を受け取って最終的に rejectaccept する関数。関数の中で if などの制御構文や集合を含む各種のデータ型を利用できるため、非常に柔軟な経路制御を可能としている。

フィルタの中では、route は出現せず、route が持つ属性が変数に代入されて利用できる。例えば proto という変数には、その route をルーティングテーブルに登録した protocol インスタンスの名前が入っている。

一部の属性はフィルタの中で書き換えが可能で、例えば dest 属性を書き換えることで ICMP UNREACHABLE を返すように経路設定をすることができる。

関数

経路フィルタは関数だが、別途純粋な関数も定義できる。

function with_parameters (int parameter)
int local_variable;
{
    local_variable = 5;
    return parameter + local_variable;
}

プロトコル

以下、Neco で利用するものに限定して解説する。

Device

ルーティングプロトコルではなく、ルーティングテーブルとも結びつかない。BIRD が OS のネットワークインターフェイス(link)情報をスキャンする設定。

このプロトコルのインスタンスはほぼ確実に一つ作る必要がある。

scan time は Linux の場合 OS が notify する仕組みになっているので、それほど短い値にしなくても良い。

protocol device {
    scan time 10;
}

BFD

BFD はルーティングプロトコルではなく、ルーティングテーブルとも結びつかない。ルータ機器同士で非常に短い間隔で keepalive メッセージを相互に送ることで、経路障害を迅速に検出するプロトコルである。

BFD プロトコルは必要に応じて BGP 他のプロトコルが利用する。また BFD のセッションは BGP のピア接続情報から自動的に作ることができる。そのため BFD プロトコルは有効にしておけば他に設定する必要はないことが多い。

protocol bfd {
}

Direct

Direct はカーネルが持つ L2 リンクに割り当てられた IP アドレスから自動的に FIB に登録される経路を扱う。例えば eth0192.168.16.3/24 というアドレスを割り当てると、FIB には 192.168.16.0/24 via eth0 という経路が自動登録される。

Direct から BIRD のルーティングテーブルに経路を import するのは、通常は必要がない。必要になるケースとしては、例えば以下のように dummy デバイスを作り /32 の代表アドレスを持たせる場合などである。

$ sudo ip link add node0 type dummy
$ sudo ip address add 192.168.16.3/32 dev node0

Direct から BIRD ルーティングテーブルに import した経路は、次に述べる Kernel プロトコルであらためて FIB に登録はしたくないはずである。以下のように、export filter で落とす。

protocol direct direct1 {
    ipv4;
    interface "node0";
}

protocol kernel {
    ipv4 {
        export filter {
            if proto = "direct1" then reject;
            accept;
        };
    };
}

Kernel

Kernel プロトコルはルーティングプロトコルではない。ルーティングテーブルの情報を OS のフォワーディングテーブル(FIB)に登録するものである。逆に、FIB に手動で登録された経路をルーティングテーブルに取り込むこともできる。

ルーティングテーブルに対して、kernel protocol は一つしかインスタンスを持てない。ルーティングテーブルを分ければ複数のインスタンスを作れるが、接続先の FIB は異なるものにしなければならない。

ipv4 table alt_v4tab;

# alt_v4tab の経路を全て FIB 8 番に登録
protocol kernel {
    kernel table 8;
    ipv4 {
        table alt_v4tab;
        export all;
    };
}

# master4 の経路を全て main FIB に登録
# main FIB の経路も learn して master4 に登録
protocol kernel {
    learn;
    persist;
    ipv4 {
        import all;
        export all;
    };
}

FIB 8 番を実際に Linux カーネルがルーティングに利用するには以下が必要。

$ sudo ip rule add priority 100 from all lookup 8

Pipe

前節で複数のルーティングテーブルを使う場面がでてきたが、ルーティングテーブル間で経路を交換するために使うのが pipe protocol である。pipe は 2 つのルーティングテーブルを繋ぐ channel というべき存在であり、その設定には export, import 等の channel の設定を直に書く。

# BGP 用に経路を集約するテーブル
ipv4 table bgp_v4tab;

# master4 -> bgp_v4tab
protocol pipe {
    table bgp_v4tab;
    peer table master4;
}

# alt_v4tab -> bgp_v4tab
protocol pipe {
    table bgp_v4tab;
    peer table alt_v4tab;
}

Static

いわゆるスタティックルートを定義できる。パケットを捨てたり ICMP UNREACHABLE を返すような設定もできる。

protocol static {
    ipv4;
    route 0.0.0.0/0 via 192.168.0.1;  # default gateway
}

BGP

BGP のインスタンスは、neighbor (ピア)毎に設定をする必要がある。同一 AS 番号の neighbor なら iBGP, 異なる AS 番号なら eBGP となるが、iBGP か eBGP かで各種設定のデフォルト値が変わるので注意をすること。

また、ピアが多々ある場合はほぼ同じ設定が並ぶので、前述の protocol template を利用すると良い。

  • bfd: BFD を有効にしたければ、こう書くだけで有効になる。
  • passive: もしかすると存在しないピアである場合、自分から接続にはいかないようにする。
  • rr client: ピアに対してルートリフレクターとして振る舞うならこれ。
  • add paths: iBGP で一つの宛先に複数の経路を neighbor に伝えたり受け取ったりする。ECMP に必要。
  • direct: iBGP のピアが同一 L2 ドメインにある場合は指定する必要がある。
  • multihop: eBGP のピアが同一 L2 ドメインにない場合は指定する必要がある。

トラブルシューティング

状態の確認

birdc を使うと、ルーティングテーブルやプロトコルインスタンスの状況を確認できる。

ルーティングテーブルの表示:

bird> show route
Table master4:
0.0.0.0/0            unicast [tor1 18:55:10.191] * (100) [AS65000i]
        via 10.69.64.1 on eth0
                     unicast [tor2 18:55:09.946] (100) [AS65000i]
        via 10.69.128.1 on eth1
10.69.0.5/32         unicast [tor1 18:55:07.808] * (100) [i]
        via 10.69.64.1 on eth0
                     unicast [tor2 18:55:07.609] (100) [i]
        via 10.69.128.1 on eth1
10.69.0.4/32         unicast [tor1 18:55:10.903] * (100) [i]
        via 10.69.64.1 on eth0
                     unicast [tor2 18:55:11.059] (100) [i]
        via 10.69.128.1 on eth1
10.69.0.3/32         unicast [direct1 18:55:03.687] * (240)
        dev node0

プロトコルインスタンスの一覧:

bird> show protocols
Name       Proto      Table      State  Since         Info
device1    Device     ---        up     18:16:10.826
bfd1       BFD        ---        up     18:16:10.826
defaultgw  Static     master4    up     18:16:10.826
kernel1    Kernel     master4    up     18:16:10.826
rack0-tor1 BGP        ---        up     18:16:14.081  Established
rack0-tor2 BGP        ---        up     18:16:14.686  Established

BGP インスタンスの詳細を表示:

bird> show protocols all 'rack0-tor1'
Name       Proto      Table      State  Since         Info
rack0-tor1 BGP        ---        up     18:16:14.081  Established
  BGP state:          Established
    Neighbor address: 10.0.1.1
    Neighbor AS:      64600
    Neighbor ID:      10.0.1.1
    Local capabilities
      Multiprotocol
        AF announced: ipv4
      Route refresh
      Graceful restart
      4-octet AS numbers
      Enhanced refresh
    Neighbor capabilities
      Multiprotocol
        AF announced: ipv4
      Route refresh
      Graceful restart
      4-octet AS numbers
      Enhanced refresh
    Session:          external AS4
    Source address:   10.0.1.0
    Hold timer:       177.403/240
    Keepalive timer:  8.268/80
  Channel ipv4
    State:          UP
    Table:          master4
    Preference:     100
    Input filter:   ACCEPT
    Output filter:  ACCEPT
    Routes:         4 imported, 2 exported
    Route change stats:     received   rejected   filtered    ignored   accepted
      Import updates:              4          0          0          0          4
      Import withdraws:            0          0        ---          0          0
      Export updates:              7          4          0        ---          3
      Export withdraws:            0        ---        ---        ---          1
    BGP Next hop:   10.0.1.0

ここでは特に Channel ipv4 -> Route change statsrejected を注目する。Export updates が先方に reject される理由は様々あり、多いのはすでにより良い経路を持っているからなどだが、next hop アドレスが到達不能アドレスになっているように修正を要するケースもある。

ログ出力

journalctl -u bird.service でエラーが記録されていないか確認をする。

詳細にプロトコルインスタンスの動きを知りたい場合、以下のように birdc で指示すると、ログに出力されるようになる。

bird> debug 'rack0-tor1' all

出力例:

2018-04-20 19:14:38.143 <TRACE> rack0-tor1: BGP session established
2018-04-20 19:14:38.143 <TRACE> rack0-tor1: State changed to up
2018-04-20 19:14:38.143 <TRACE> rack0-tor1: Sending KEEPALIVE
2018-04-20 19:14:38.143 <TRACE> rack0-tor1 < added 0.0.0.0/0 unicast
2018-04-20 19:14:38.143 <TRACE> rack0-tor1 < added 10.69.0.5/32 unicast
2018-04-20 19:14:38.143 <TRACE> rack0-tor1 < added 10.69.0.4/32 unicast
2018-04-20 19:14:38.143 <TRACE> rack0-tor1 < added 10.69.128.0/26 unicast
2018-04-20 19:14:38.143 <TRACE> rack0-tor1 < added 10.69.0.3/32 unicast
2018-04-20 19:14:38.144 <TRACE> rack0-tor1: Sending UPDATE
2018-04-20 19:14:38.144 <TRACE> rack0-tor1: Sending UPDATE
2018-04-20 19:14:38.144 <TRACE> rack0-tor1: Sending END-OF-RIB
2018-04-20 19:14:38.144 <TRACE> rack0-tor1: Got UPDATE
2018-04-20 19:14:38.144 <TRACE> rack0-tor1 > added [best] 10.69.64.0/26 unicast
2018-04-20 19:14:38.144 <TRACE> rack0-tor1 < rejected by protocol 10.69.64.0/26 unicast

Invalid NEXT_HOP への対応

以下は NEXT_HOP アドレスが到達不能な経路を受け取ったときに出力されるログである。

2018-04-20 19:14:38.253 <RMT> tor2: Invalid NEXT_HOP attribute

典型的な発生シナリオは以下の通り。

  1. あるルータが /31 のサブネットで繋がっている eBGP ピアから経路を受け取る

    この経路の NEXT_HOP は /31 に属するアドレスとなる。

  2. そのルータが iBGP ピアに eBGP ピアから受け取った経路を伝える

    iBGP は NEXT_HOP を書き換えずに伝える。

  3. 受け取った iBGP ピアは、1 の /31 サブネットを知らないので Invalid として破棄する

    NEXT_HOP 記載アドレスに到達ができない場合、BIRD はすぐに経路を捨ててしまう

これを回避するには以下のいずれかの対応が必要になる。

  • 事前に eBGP の IP アドレスに到達できるよう、iBGP ルータに経路を入れておく

    事前に入れる方法としては RIP などの IGP かスタティックルートを設定するかとなる。BGP で同時に広報すると、タイミング次第で先に到達不能な NEXT_HOP が弾かれてしまい、うまくいかない。

  • 経路を広報するルータが next hop self を設定する

    BIRD や他の BGP ルータは、iBGP でよくあるこの問題に対処するため、広告する経路の NEXT_HOP を全て自ルータのアドレスにする機能を持つ。これを有効にすることで、eBGP ルータの IP アドレスを隠すことができる。ただし、ルートリフレクターでこれを設定すると eBGP ピアから受け取った経路だけでなく、 iBGP ピアから受け取った経路の NEXT_HOP も書き換えてしまう。

  • 経路を広報するルータが export filter で NEXT_HOP を手動設定する

    eBGP ピアから受け取った経路の NEXT_HOP を書き換えるといったことが条件文で書くことができる。以下に例を示す。

      protocol bgp {
          ...
    
          ipv4 {
              import all;
              export filter {
                  # eBGP からの経路のみ NEXT_HOP を書き換える
                  if proto = "eBGP" then bgp_next_hop = 10.16.0.1;
                  accept;
              };
          };
      }
    
  • 経路を受け取る側が import filter でゲートウェイを上書きする

    BIRD では igp table TABLE を指定すれば、NEXT_HOP の経路の有無はそちらのテーブルを参照する。これを利用してひとまず受け取れるようにし、 import filter で経路のゲートウェイを上書きする。

      ipv4 table dummytab;
      protocol static dummystatic {
          ipv4 { table dummytab; };
          route 0.0.0.0/0 via "lo";
      }
    
      protocol bgp {
          ...
    
          ipv4 {
              igp table dummytab;
              gateway recursive;   # direct 接続時も IGP テーブルを参照させる
              import filter {
                  # ラック内 AS (iBGP ピア)からの経路なら bgp_next_hop を維持
                  # 以下の式は送信元ルータのアドレスを 26bit サブネットに含まれるかの判定
                  if bgp_next_hop.mask(26) = from.mask(26) then {
                      gw = bgp_next_hop;
                      accept;
                  }
    
                  # そうでないなら from をゲートウェイに(next hop self 相当)
                  gw = from;
                  accept;
              };
          };
      }
    

print デバッグ

BIRD がどのような経路を広報しているか詳しく知りたい場合、以下のように export filter 中で print することで経路の情報をログに出力できる。

    ipv4 {
        export filter {
            print "route: ", net, ", ", from, ", ", proto, ", ", bgp_next_hop;
            accept;
        };
    };

備考

代表 IP アドレスの実装

Neco のネットワーク設計 では、各サーバーは NIC の IP アドレスとは別に代表 IP アドレスを持つことにしている。

NIC とは別に仮想的なインターフェイスを作るには、Linux では dummy link を使う。

$ sudo ip link add node0 type dummy
$ sudo ip address add ADDRESS/32 dev node0

dummy インターフェイスは自ホスト宛てに決まっているので、FIB への登録は不要。BIRD で経路広報するには Direct プロトコルで node0 インターフェイスをルーティングテーブルに import する。

protocol direct direct1 {
    ipv4;
    interface "node0";
}

protocol kernel {
    persist;
    merge paths;   # ECMP

    # direct1 の経路を FIB に取り込まないようにする
    ipv4 {
        export filter {
            if proto = "direct1" then reject;
            accept;
        };
    };
}

疑似経路集約

内部でラック等に利用している AS 番号はクラスタ間で共通なので、クラスタ外への経路広報では aspath に含めないようにする必要がある。これは Cisco 等では経路集約して summary-only 指定すれば良いが、BIRD には経路集約機能がない。

代わりに、複数のルーティングテーブルを自由に利用できるため、クラスタ外の BGP ピアに広報したい経路を static protocol で登録したルーティングテーブルを用意すれば良い。

ipv4 table outertab;

protocol static myroutes {
    ipv4 { table outertab; }
    route ...;
}

protocol bgp outerpeer {
    local as ...;
    neighbor ADDRESS as ...;

    ipv4 {
        table outertab;
        import all;
        export all;
        next hop self;  # next hop self の正しい使いかた
    };
}

# BGP で受け取った経路を master4 に登録
protocol pipe outerroutes {
    table master4;
    peer table outertab;
    import filter {
        if proto = "myroutes" then reject;
        accept;
    };
    export none;
}

まとめ

ルーティングソフトウェア BIRD の仕組みと、各種設定方法やトラブルシューティングを解説しました。

複数の内部ルーティングテーブルとそれをつなぐ protocol という BIRD のアーキテクチャは、ソフトウェアの柔軟性を活かした拡張性の高い優れた設計だと思います。一度理解すれば、できないことがほとんどない便利なルーティングソフトウェアです。

BIRD、いいですよ!