Posts for: #2023/03

リリース延期の危機

1時に寝て7時に起きた。いつも通り夕方にホテルに戻って2時間ほど寝て起きたらテレビで WBS をやっていてそのままみてた。そしたら晩ご飯食べるタイミングも作業するタイミングも逃して日記を書いたり雑多なことをしていた。

プロジェクトの進捗報告

出張したときの月例報告の4回目。前回の進捗報告はこちら 。1月にリリース前倒しを提案して颯爽と1ヶ月前倒しをしたのにその翌月に現時点ではまだリリース可能かどうかを判断できないといったことを報告した。まったく情けない。サーバーサイドとフロントエンドの開発はすでに完了しているのに。しかし、もともとこのプロジェクトの開発対象に入っていなかったもので、ほぼ完成していると聞いていたモジュール群の半分が機能不足や低品質で作り直すことになった。残りの半分もそのままでは動かない。

経験の浅いメンバーに1ヶ月以上の時間を与えて作り直してもらうようにお願いしていたが、うまく進捗せず時間だけが過ぎていって、結果的に2月の半ばから私が引き取って大半の機能を開発している。結果的にそのメンバーにお願いしていた開発の8割を私が2週間でほとんど実装した。2月の中旬から私がずっと休祝日に開発のお仕事をしていたのはこの開発遅れを補填するためだった。チームが fix した2月の issue 数が57でそのうちの30を、enhance ラベルが付いたものは28でそのうちの13を私が担当した。今月の半分の開発を私が代替わりして帳尻を無理やりあわせた。もはや遊撃のレベルではなく、私が本気出して全部作っておきましたみたいなことをした。

本当はメンバーに開発経験をつけてもらうために私がいるので私が主担当で開発するのはよくない。とはいえ、このままいくと2ヶ月ほど開発遅延する、しかもこのプロジェクトの中核でもない機能のために、それも悔しいし、うちの会社の信頼にも関わるのでズルしてしまいましたと経営陣へ正直に報告した。自分がやるよりも他人に教える方がずっと難しい。先方からは咎めるものではなかったし、私が開発して帳尻をあわせるのを止めるものでもないという承認は得た。

難しい開発課題を経験の浅いメンバーに担当させてしまった私のマネジメントの誤りであることは、チームのふりかえりでも、経営者への報告でも伝えている。なにが起ころうとプロジェクトの責任はマネージャーの私にあることは理解している。その遅れはマネージャーが責任をもって対応するのだとメンバーが学ぶ機会にもなったんじゃないかという意見も出た。私も過去にそういう上長をみて思うところはあったのでそれはそうかもしれない。なぜ1ヶ月以上も時間を与えているのに芋づる式にスケジュールが遅延するのか。その要因もメンバーの行動や進捗をみていて理解できた。第一に経験が浅いために開発の見通しや見積もりを立てられない。例えば課題が3つあるとして、1つしかみえていないから「できそうです」と言っていても、1つ終えた後にまた1つありましたと報告があり、その1つを終えてもまだもう1つありましたと報告が来る。一定の経験があれば作業を開始する前に3つあることを整理して、その上で納期にあわせて3つを対処する。納期いっぱい使って1つだけやろうとするところの意識の差は大きい。第二に期日までに実装できる一定のスキルをもっていないとコードレビューが1週間とか続いてしまう。そういった開発者にクリティカルパスとなる issue を担当させてはいけないように思えた。

ある issue がクリティカルパスになってしまった時点で、私かスキルのあるメンバーのどちらかへ引き継ぐように2月中旬に調整していればいまの状況は変わったのではないだろうか。その判断が2週間遅れたことに今回は気付けた。結果論ではあるが、厳しい判断をもう少し早めに下さないといけなかった。

windows のサービス起動を go から制御する

2時に寝て3回ほど起きて7時に起きた。淡々と自分の機能開発をしていた。ある処理が失敗したときにローカルのファイルシステムに暗号化して書き込み、定期的にそのディレクトリを監視してファイルがあれば読み込んで復号化してリトライをするといった仕組みを実装した。とくに難しくなくすぐにできた。

晩ご飯に 大衆酒場 PING (ピン) というお店に行ってみた。1人でも入りやすく値段も安く食べ応えもあっても私のイメージする居酒屋さんの印象にぴったりでよかった。また出張したら行こうと思う。そろそろ出張でバテてきたので今日は夜にお仕事せずに晩ご飯食べてからもテレビをみながらだらだらしてた。

windows のサービス起動

準標準パッケージである golang.org/x/sys パッケージを使ってアプリケーションの起動と停止をサービス管理画面から操作できるようにする。サンプルコードは次の場所にある。

サービスから呼ばれたかどうかを判定をすることでエントリーポイントを切り替えられる。

inService, err := svc.IsWindowsService()
if inService && err == nil {
    if err := runService(serviceName, false); err != nil {
        log.Error("failed to run service", map[string]any{
            "err": err,
        })
        return
    }
}

そして、次のような Execute() メソッドをもつ windows サービスから呼ばれる構造体を定義して、そのメソッド内でサービス管理画面からのステータス変更に対応する状態遷移のコードを実装すればよい。ここではサービス開始してから stop/shutddown で停止するぐらいしか必要ないのでシンプルに実装した。一時停止や再開も必要ならもう少し複雑なコードになる。

type myService struct{}

func (m *myService) Execute(
	args []string, reqCh <-chan svc.ChangeRequest, statusCh chan<- svc.Status,
) (ssec bool, errno uint32) {
	ctx, cancel := context.WithCancel(context.Background())
	ch := startMyService(ctx, getConfig())
	statusCh <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
	stop := false
	for {
		select {
		case c := <-reqCh:
			switch c.Cmd {
			case svc.Stop, svc.Shutdown:
				stop = true
				cancel()
				break
			}
		}
		if stop {
			break
		}
	}
	statusCh <- svc.Status{State: svc.StopPending}
	<-ch // wait until MyService would be completed
	return
}

暗号化と復号化を実装してみる

不定期に寝て7時半に起きた。起きてからシャワー浴びたらしゃんとした。昨日と同様に昼間働いて、ホテルに戻ってきて2-3時間寝てから夜に2-3時間かけて次のコードを書いた。

go での暗号化/復号化

ある機密情報を扱うので暗号化して普通には「みえない」ようにしたい。まったくわからないのでググったら次の記事がみつかった。

まずはこの記事にあるサンプルコードを動かしてみながら内容を理解する。暗号技術そのものは理解できないが、コードの振る舞いそのものは動かしながら理解できる。その後、いろいろ調べているとこのサンプルコードの大半は crypto/cipher パッケージにあるサンプルコードと同じであることに気づく。標準ライブラリのドキュメントでは iv (initialization vector) を暗号化対象の文字列の一部を切り取って生成している。iv は一意である必要はあるが、セキュアでなくてもよいとある。よくあるやり方とあるのでサンプルコードをみながら同じように実装した。

// The IV needs to be unique, but not secure. Therefore it's common to
// include it at the beginning of the ciphertext.

最終的な暗号化と復号化のコードは次になった。標準ライブラリにあるサンプルコードと基本的には同じ。

func Encrypt(secret string, plainText []byte) ([]byte, error) {
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return nil, fmt.Errorf("failed to create chiper: %w", err)
	}
	cipherText := make([]byte, aes.BlockSize+len(plainText))
	iv := cipherText[:aes.BlockSize]
	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
		return nil, fmt.Errorf("failed to read for iv: %w", err)
	}
	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(cipherText[aes.BlockSize:], plainText)

	buf := make([]byte, base64.StdEncoding.EncodedLen(len(cipherText)))
	base64.StdEncoding.Encode(buf, cipherText)
	return buf, nil
}
func Decrypt(secret string, text []byte) ([]byte, error) {
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return nil, fmt.Errorf("failed to create chiper: %w", err)
	}

	dbuf := make([]byte, len(text))
	n, err := base64.StdEncoding.Decode(dbuf, text)
	if err != nil {
		return nil, fmt.Errorf("failed to create chiper: %w", err)
	}
	cipherText := dbuf[:n]

	if len(cipherText) < aes.BlockSize {
		return nil, fmt.Errorf("ciphertext is too short: %d", len(cipherText))
	}
	iv := cipherText[:aes.BlockSize]
	cipherText = cipherText[aes.BlockSize:]
	stream := cipher.NewCFBDecrypter(block, iv)
	// XORKeyStream can work in-place if the two arguments are the same.
	stream.XORKeyStream(cipherText, cipherText)
	return cipherText, nil
}

いろいろな変換

前日は0時ぐらいまでオフィスでお仕事していて、家に帰ってお風呂入って荷造りして、そのまま寝ないで5時過ぎに出かけて新神戸駅から6時10分発の始発に乗ってから2時間ほど寝た。昼間働いて18時にホテルに戻ってまた2-3時間ほど寝て晩ご飯食べてから2時間ほどお仕事していた。寝付けなくて3時ぐらいに起きて吐いた。吐いたら気分よくなってその後はよく眠れた。

windows の内部の文字の扱い

前日の日曜日に実装したツールをメンバーに windows server検証してもらう。ある変換処理で windows の内部では文字を utf16le で扱っていて、文字コード変換の処理が漏れていることに気付いた。go の準標準ライブラリを使って、次のようなコードで utf16le から utf8 への変換ができる。

import "golang.org/x/text/encoding/unicode"

decoded, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder().Bytes(b)

LDAP の DN の仕様

LDAP の distinguished name (DN) を調べた。例えば、windows で dn を powershell の Get-ADUser を使って取得すると次のような値を取得できる。

dn="CN=Test,CN=Users,DC=example,DC=com"

CNDC は大文字/小文字どちらでもよいが、それが違うと dn の値として別の値になってしまう。できれば小文字に統一したい。その変換処理を実装しようと思って仕様を調べていたら RFC-4514 Lightweight Directory Access Protocol (LDAP): String Representation of Distinguished Names で定義されていることがわかった。ここで定義されている特殊な文字、例えばカンマや等号などはエスケープして値としても使えるように書いてある。キーの値を変換するにはこれらの仕様を理解してパースして変換しないといけない。わりと大変なことを理解して go-ldap のライブラリのコードを読んでいたら正に ParseDN() がそんな実装になっていた。

func ParseDN(str string) (*DN, error)

このツールを使って dn インスタンスにパースして文字列表現を取得すると自動的に正規化されて小文字に変換してくれる。

dn, err := ldap.ParseDN(s)
if err != nil {
    return fmt.Errorf("failed to parse dn: %w", err)
}
dn.String()

イベントログに書き込む

1時に寝て2時ぐらいから吐き気で3時間ほど苦しんで寝て9時に起きた。バテてきた。10時過ぎからオフィスへ行ってだいたい丸一日お仕事してた。

go のマルチプラットフォーム対応

いま作っているモジュールは windows/linux の両方で動くモジュールにする必要がある。go は標準でそういったモジュールの開発に対応していて Build Constraints にビルドタグの仕様が書いてある。例えば、ログ出力を linux では標準出力に windows ではイベントログに書き込みたいとか。windows のイベントログに書き込むのも準標準パッケージである golang.org/x/sys パッケージを使ってすぐにできた。私がイベントログの仕様をよく知らないのでイベントログそのもののインストール作業をどうするかという課題がある。これは詳しい人に教えてもらおう。ビルドタグには unix という設定が使える。これで linux/macos 対応になる。例えば、log 出力のインターフェースをそれぞれ分けたいときに次のように実装できる。windows は標準ロガーに加えてイベントログにも書き込むようにしてみた。この log パッケージを使う呼び出し側のコードはマルチプラットフォームの違いを意識する必要はない。とても簡単にマルチプラットフォームのコードを管理できる。go のビルドやコンパイラの仕組みを学ぶたびに感心する。

//go:build unix

package log

func Error(msg string, fields map[string]any) error {
	return mylogger.Error(msg, fields)
}

func Warn(msg string, fields map[string]any) error {
	return mylogger.Warn(msg, fields)
}
//go:build windows

package log

import (
	"golang.org/x/sys/windows/svc/eventlog"
)

const (
	// this logName should be installed in advance
	logName = "mylog"
	eventID = 1 // TODO
)

var elog *eventlog.Log

func init() {
	var err error
	elog, err = eventlog.Open(logName)
	if err != nil {
		mylogger.ErrorExit(err)
	}
}

func Remove() {
	eventlog.Remove(logName)
}

func Error(msg string, fields map[string]any) error {
	elog.Error(eventID, getMessage(msg, fields))
	return mylogger.Error(msg, fields)
}

func Warn(msg string, fields map[string]any) error {
	elog.Warning(eventID, getMessage(msg, fields))
	return mylogger.Warn(msg, fields)
}

お土産探し

いつもは東京出張のタイミングにあわせてお店やお菓子探しから時間をかけて、自分でも買って試食してみて選択している。今回は2週間前から土日もずっと開発していて余裕がない。前回は 本髙砂屋の金つば を持って行った。本髙砂屋は金つばの老舗なのだけど、洋菓子も販売していて和菓子のお店の隣に洋菓子のお店も構えている。 今回も本髙砂屋にあやかって洋菓子の エコルセ の季節限定で売っている詰め合わせバージョンを購入してみた。「貧すれば鈍する」とよく言ったものでお土産探しの時間すらかけられなくなってしまっている。15時半頃から出かけて本髙砂屋さんで購入した。今回は試食できていないけど老舗だからあまり心配はいらないとは思う。

http クライアントのリトライ機能

1時に寝て7時に起きた。今週末もお仕事をがんばる。今週中に開発タスクを完了させたいという思いがある。まだ今週は続いている。

ストレッチ

今日の開脚幅は開始前154cmで、ストレッチ後157cmだった。今週も座っている時間が長かったせいか、あまり数値がよくなかった。ここ2-3週間前から右すねの外側の筋に張りがあったが、これが左にも転移して左すねの外側の筋も張りが強くなってきた。両方とも張りがあるという意味ではバランスが取れたと言えるのかもしれない。いずれにしてもいままで張りがなかったところに違和感があるので引き続き注視していきたい。歩くときにやや気になるけれど、日常生活に支障が出るほどの障害ではない。少し前からオフィス通勤に自転車をやめて、通勤とお昼ご飯を買いに行くのが自転車から歩きになったことが影響しているのかな?言うても徒歩3分ぐらいの距離でそんなこと起きるかなぁと思ったり。

http クライアントのリトライ機能

結論から言うと go-retryablehttp を使ってすぐできた。自分で作ってもよかったのだけど、retryablehttp がシンプルで私の求めている機能そのものにみえたのでこのライブラリを採用した。既存の http クライアントの機能を次の2箇所変更するだけで retryablehttp が使える。こういうインテグレーションの簡単さも嬉しい。ちゃんとバックオフとジッターの機能も実装しているし、デフォルトのそれらの機能をカスタムで置き換えることもできる。最低限のリトライ機能があればいいといった http クライアントならこれでよいと思う。こういうシンプルで使い勝手のよいライブラリをみつけると嬉しくなる。

-	res, err := c.raw.Do(req)
+	_req, err := retryablehttp.FromRequest(req)
+	if err != nil {
+		return []byte{}, fmt.Errorf("failed to create retryable request: %w", err)
+	}
+	res, err := c.raw.Do(_req)
+func NewRetryableClient(cfg config.Client) *retryablehttp.Client {
+	c := retryablehttp.NewClient()
+	c.Logger = log.GetLogger()
+	c.RetryWaitMin = cfg.RetryWaitMin
+	c.RetryWaitMax = cfg.RetryWaitMax
+	c.RetryMax = cfg.RetryMax
+	return c
+}

-		raw: http.DefaultClient,
+		raw: NewRetryableClient(cfg),

まずまずの開発進捗

1時に寝て何度か起きて7時に起きた。寝たと思うけど、あまり記憶にない。

勉強会は初のお休み

11月から5ヶ月続けてきた毎週の勉強会を今日はお休みすることにした。理由は先週から私が開発にずっと張り付いていて勉強会の段取りをする余裕がないから。勉強会をやってくれと依頼されているわけではない。私が勝手に段取りを組んでずっと継続していきた。続いていると途切れさせたくないインセンティブもあって5ヶ月の間ずっと続いていた。それが一旦途切れてしまった。クロッゾも言っているように、自分勝手な論理を振り回さず、いまチームにとって大事なことをするという側面では、私が開発に張り付くのが悪いわけでもない。今週中に開発を終了させるという目標に全力を尽くすために今週の勉強会はお休みとした。

ああ。これも魔剣と同じ、意地と仲間をはかりにかけるのはやめた by ヴェルフ・クロッゾ

コードレビューしつつ開発

メンバーのコードレビューを合間にしつつ、あるモジュールの開発も進める。windows/linux の両方で動くモジュールを作らないといけないのでビルド環境の考慮や ci/cd、検証もやや工数がかかる。将来的な拡張や開発の分散も考慮にして2つのリポジトリに分割してモジュール化したものを組み合わせる。今日の時点で Active Directory における sAMAccountName と LDAP の DN との変換処理以外は一通り動くものを実装できた。エラー制御や耐障害性のための仕組みはまだこれから実装することになるが、それは後回しにしても、いまはクリティカルパスが私の開発にならないよう、テストや検証の作業をブロックしてしまわないよう、段階的に機能を作り込んでいけるように考えている。あと土日の2日間を使って Active Directory の処理を実装できればクリティカルパスからは脱することができる。

Active Directory のキーワードで検索していると go-adsi/adsi をみつけたので明日はこのライブラリと背景の調査をしていく。感覚的には2日もあれば十分だろうと楽観的にみているが、実際にはどうだろう?

zeromq のメッセージングを go でやり取りする

0時に寝て何度か起きて7時に起きた。たぶんよく眠れたと思う。

zeromq のライブラリ選定

zeromq のクライアントをサンプル的に実装している。windows と linux の両方で使う予定なので pure go 実装の go-zeromq/zmq4 を使ってみることにした。zeromq のライブラリはいくつかあるが、大きく2つに分けられる。

  • c 言語の zeromq ライブラリのラッパー
  • zeromq のプロトコルを pure go 実装

c 言語のライブラリのラッパーだとビルド環境をプラットフォームごとに用意しないといけない。pure go ならクロスコンパイルも簡単。例えば、linux 上で windows のバイナリをビルドするには次のようにする。go ならたったこれだけでよい。

GOOS=windows GOARCH=amd64 go build -o bin/myapp.exe ./cmd/myapp/main.go

但し、zeromq の pure go 実装は開発があまり活発ではないし production ready でもない。まだまだベータ版というか、stable な実装にはなっていないようにみえる。うちの用途はとてもシンプルなメッセージングに使うだけなので動けば問題ないだろうという想定で最初の選択肢として go-zeromq/zmq4 を試してみる。これはまだチュートリアル的に動かしているレベルなのでコードが誤っているかもしれないが、ひとまずメッセージのやり取りができるところまで確認した。windows でも動く。これから1ヶ月ほどかけて実運用レベルのデータやテストを実施して本当に使えるかどうかの検証をしていく。

メッセージを送る側は Push というソケットを使う。送信は chan を使って非同期に送る必要性はないのだけど Pull にあわせて汎用性をもつ実装にするとこんな感じかな。

func Push(
	ctx context.Context, cfg config.Queue, msgCh <-chan zmq4.Msg,
) (<-chan error, error) {
	push := zmq4.NewPush(ctx, zmq4.WithDialerRetry(time.Second*3))
	if err := push.SetOption(zmq4.OptionHWM, cfg.SendHWM); err != nil {
		return nil, fmt.Errorf("failed to set socket option: %w", err)
	}
	endpoint := "ipc://" + cfg.Path
	if err := push.Dial(endpoint); err != nil {
		return nil, fmt.Errorf("failed to dial: %w", err)
	}
	if addr := push.Addr(); addr != nil {
		return nil, fmt.Errorf("dialer with non-nil addr")
	}

	errCh := make(chan error, messageChanSize)
	go func() {
		defer func() {
			push.Close()
			log.Debug("push queue was closed", nil)
			close(errCh)
		}()
		for msg := range msgCh {
			if err := push.Send(msg); err == nil {
				errCh <- nil
				continue
			} else if errors.Is(err, context.Canceled) {
				log.Info("push queue is closing ...", map[string]any{
					"err": err,
				})
				return
			} else {
				errCh <- err
			}
		}
	}()
	return errCh, nil
}

メッセージを受け取る側は Pull というソケットを使う。Recv() でキューからメッセージの到着をブロックする。context をキャンセルすると Recv() が即時でエラーを返すので終了処理も制御しやすい。

func Pull(ctx context.Context, cfg config.Queue) (<-chan zmq4.Msg, error) {
	pull := zmq4.NewPull(ctx)
	endpoint := "ipc://" + cfg.Path
	if err := pull.Listen(endpoint); err != nil {
		return nil, fmt.Errorf("failed to listen: %w", err)
	}
	if addr := pull.Addr(); addr == nil {
		return nil, fmt.Errorf("listener with nil addr")
	}

	ch := make(chan zmq4.Msg, messageChanSize)
	go func() {
		defer func() {
			pull.Close()
			close(ch)
			log.Debug("pull queue was closed", nil)
		}()
		for {
			log.Debug("-- waiting messages ...", nil)
			msg, err := pull.Recv()
			if err == nil {
				ch <- msg
			} else if errors.Is(err, context.Canceled) {
				log.Info("pull queue is closing ...", map[string]any{
					"err": err,
				})
				return
			} else if err == io.EOF {
				log.Debug("got EOF", nil)
			} else if err != nil {
				log.Error("failed to recieve", map[string]any{
					"err": err,
				})
			}
		}
	}()
	return ch, nil
}

rocky linux も悪くない

2時に寝て7時に起きた。夜うまく眠れなくて夜更かしした。3月になってしまったか。2月の記憶がほとんどない。

rocky linux をベースイメージとして使う

コンテナを作るときのデフォルトの os は、私の中では alpine だが、依存ライブラリの制約があって、いま作っている docker image は rockylinux をベースイメージに使う。rockylinux を docker pull した感覚は速いのでそんな大きなイメージでもない。minimal だと40数 MiB 程度のサイズ。すでに成果物として rpm パッケージがあり、その rpm が依存解決できないといけないため、rhel ベースの os をベースイメージにしないといけない。rpm パッケージに systemd の起動スクリプトなども入っているのでそのまま使えばいいかとも思ったのだけど普通には systemd は動かない。rockylinux のドキュメントにも systemd はこうやったら動くよと書いてあるものの、実際に起動してみるとエラーになる。細かくは追いかけていないけれど、cgroup v2 で未解決な問題があるらしく、cgroup v2 を使うカーネルでは systemd が動かないらしい。

コンテナを動かすプラットフォームがハイパーバイザーも兼ねるので systemd を動かす必要性はないけれど、サードパーティの起動スクリプトを自分たちで保守するのも嫌だなという印象はあるのでもしかしたら悩ましい問題なのかもしれない。