cockscomblog?

cockscomb on hatena blog

macOSのコンテナ開発環境におけるVirtualization frameworkの採用

Docker Desktop for Mac

Docker Desktop for Macでは、仮想マシン上のLinuxでDockerを動かしている。仮想マシンにはhyperkitやQEMUが使われていた。が4.14.0からVirtualization frameworkがデフォルトで使われる。

Set Virtualization framework as the default hypervisor for macOS >= 12.5.

Virtualization frameworkmacOS内蔵の仕組みで、macOS 11で導入されてから、徐々に機能が拡張されている。Virtualization frameworkは高レベルなAPIで、より低レベルなAPIとしてmacOS 10.10から搭載されているHypervisor frameworkがあり、おそらくVirtualization frameworkもこれを利用している。(hyperkitやQEMUも利用している。)

Virtualization frameworkを使うと、Docker Desktopの設定でvirtiofsを有効にできる。現時点ではベータ扱いになっている。4.15.0でGAになった。おそらくVirtualizatoin frameworkのVZVirtioFileSystemDeviceConfigurationを利用しているのだろう。

virtiofsを有効にすると、ホストのmacOSとゲストのLinuxの間でのファイル共有が高速化される。ファイルの共有の遅さは、Docker Desktop for Macの積年の課題だった。

さて、Docker Desktopのロードマップを見ると、今後はApple Siliconでx86_64アーキテクチャのバイナリを実行する際にRosetta 2を利用することが検討されている。

macOS 13では、Virtualization framework上のLinuxでもRosetta 2が利用できる機能が追加されており、これを利用するつもりだろう。

Rosetta 2はAOT変換(とJIT変換)によって、Apple Silicon上でx86_64のバイナリをかなり高速に実行させられることが知られている。現在、Apple Siliconでx86_64のDocker Imageを実行する際にはエミュレーションが行われており、ネイティブよりかなり遅い。Rosetta 2が利用できれば、一定の高速化が期待できる。

ということで、Docker Desktop for MacはVirtualizaton frameworkへの移行によって、高速化を達成しつつある。

Lima

ところで、Docker Desktop以外のソリューションはどうしているのかというと、Rancher DesktopにせよFinchにせよ、macOSLinuxを動かすというところではLimaを使っている。

LimaはQEMUを使っているが、最近ちょうどVirtualization frameworkのサポートが追加されつつあり、同時にvirtiofsも利用可能になる。

それだけでなく、Apple SiliconではRosetta 2によるx86_64バイナリの実行がサポートされようとしており、最新のベータにもこれが含まれている。

ということで、Lima 0.14に大注目だ。

まとめ

macOSでのコンテナ開発環境は、macOS上でどうやってLinuxを動かすかというところから始まっていて、最近ではmacOSに搭載されたVirtualization frameworkの採用が広がっている。そしてvirtiofsとRosetta 2によって、開発体験が改善されつつある。Docker DesktopもLimaを採用する他のソリューションも同様に進歩していて、目が離せない。

筆者は現在、勤め先がDockerの有料プランを契約しているため、それを使っています。

ソフトウェアエンジニアとしての最初の10年

働き始めてから丸10年経った。

2012年、僕は北海道に住む大学院生で、趣味としてプログラミングを楽しんでいた。Appleのファンだから、macOSiOSのアプリケーションを開発して、ちょっとでもAppleに近づいたような気持ちになっていた。その夏1ヶ月のインターンシップに参加した。インターンシップで、それまで趣味だったプログラミングが突然違った価値を持ち始めて、これを仕事にしないといけないと思うようになった。それで、両親や先生に謝って、大学院を退学して、インターン先の会社に正社員として入社した。それが2012年11月のことで、それから10年間、株式会社はてなで働いている。

この業界では、10年同じ会社で働いているというと、ちょっと珍しい部類なのかなと思う。とはいえ社内ではそれほど珍しくもなくて、あまり気にならない。いろいろなプロダクトを夢中になって開発していたら、いつの間にか10年経っていた。

この10年の間に結婚もしたし、ふたりの子供にも恵まれて、生活環境の方は大きく変化した。上の子は5歳だから、半分以上の期間は親として暮らしていると思うと、少し不思議な感じもする。

ソフトウェアエンジニアとしては、もともとスマートフォンアプリのエンジニアという感じだったが、仕事をし始めてからはサーバーサイドの開発もそれなりにやった。やってはいたが、振り返ってみれば、最初の5年くらいは何も分かっていなかった。何も分かっていないなりに必死にやっていたら、2017年に転機が訪れた。

2017年、サーバーサイドから何から一通りを新規開発する案件のテックリードを任された。プロダクトの性質を考えるとSingle-Page Applicationがよさそうだったので、いろいろ調べながらReactを導入していった。コンテナで運用しようということになって、一夜漬けでDockerを学んで、翌日開発途中のアプリケーションをコンテナ化した。こういったことの過程で、突然、いままでやっていたことの点と点がつながって、いろいろなことがはっきりと理解できるようになった。

それから、ソフトウェアのアーキテクチャについて急にくっきりしてきて、おもしろおかしくソフトウェア開発できている。たぶんそんなに珍しい話ではなくて、経験を分解して再構築できた、というだけのことなんだとは思う。

2018年頃にはGraphQLの導入をして、それからしばらくはGraphQLにハマっていた。いろいろなところで導入して、ブログに書いたりしていたら、WEB+DB PRESSの特集に繋がった。

我が身を省みると、あまり勉強熱心な方ではなく、スーパーハッカーへの夢は遠い。それでも10年かけて、興味のあることだけでも地道に取り組んでいたら、いくらかは形になった。尊敬する上司や同僚たちに導かれつつではあるが、元はただのAppleファンボーイだったことを思えば、悪くないと思う。

ということで、次の10年が始まりました。引き続きよろしくお願いします。

ステージマネージャの使い方

macOS VenturaおよびiPadOS 16で導入されたステージマネージャだけど、どうやって使うのか段々わかってきた。

ステージマネージャのコンセプト

ステージマネージャは、ウインドウのセットを作る機能だ。タスクに合わせてウインドウのセットを作れば、複数のタスクを行ったり来たりする場合に、ステージマネージャの切り替えをするだけでよくなる。

例えば、Webサービスを作っているとき、筆者の場合、Visual Studio CodeとTerminalとGoogle Chromeを使う。これをひとつのセットにまとめておく。iOSアプリを作っているときは、XcodeとSimulatorをセットにする。同僚とのコミュニケーションや通知を受け取るのに、Slackとメールもセットにしておく。作業内容に応じてこれらを切り替えながら仕事を進めていくイメージだ。

ステージマネージャの使い方

Appleのドキュメントを読むとだいたい書いてある。

有効・無効はコントロールセンターから切り替えられる。

セットの作り方

画面端に最近使ったアプリケーションのサムネイルが表示されている。Slackとメールをひとまとめにしたい場合、Slackを前面に表示した状態で、メールをサムネイルからドラッグして持ってくる。あるいは、Shiftキーを押下しながらアプリケーションをクリックすると、現在のセットに追加できる。こちらの方が手早くていい。

反対に、現在のセットからアプリケーションを取り除きたいときは、ウインドウをドラッグしてサムネイルの並びに持っていく。もしくは、Shiftキーを押下しながらウインドウのタイトルバー部分をクリックする(macOSのみ)。または、ウインドウの「+」ボタンをホバーすると表示される「ウインドウをセットから削除」を選ぶ(macOSのみ)。

macOSではShiftキーが便利なので、これだけ覚えておけばよさそうだ。iPadOSでは、ウインドウ上部の「…」メニューから一通りの操作を行える。

マルチディスプレイ

マルチディスプレイでは、ディスプレイ単位でステージマネージャが機能する。つまり、複数のセットを同時に利用できる感じになる。ので、ディスプレイ単位でセットを作る。(iPadOS 16.1時点ではまだマルチディスプレイは利用できない。)

macOSでは、Mission Controlのスペース(仮想デスクトップのような機能)でも、スペースごとにステージマネージャが機能する。

ステージマネージャの設定

macOSでは、「システム設定」の「デスクトップとDock」からステージマネージャをカスタマイズできる。

「最近使ったアプリケーション」は、画面端のサムネイルをデフォルトで表示するかどうか。表示していても、サムネイル部分をウインドウが覆うと表示されなくなる。いずれにしても、マウスカーソルを画面端に持っていけばサムネイルが出てくる。

「デスクトップ項目」は、デスクトップ上のファイルやフォルダを常に表示するか、デスクトップをクリックしたときだけ表示するか、という設定。

「アプリケーションウインドウの表示方法」は、1つのアプリケーションが複数のウインドウを開いているときにどうするかという設定。「ウインドウを一度にすべて表示」では、2つ以上ウインドウを開いてもデフォルトで1つのセットに表示される。「ウインドウを1つずつ表示」にすると、2つ目以降のウインドウがデフォルトで異なるセットになる。いずれにせよ、自分でウインドウごとにセットに入れたり外したりできる。

macOSでは、ダイアログ的に小さいウインドウを表示するアプリケーションがあるので、「ウインドウを一度にすべて表示」の方が使いやすいかもしれない。というのも、macOSではウインドウがステージマネージャ上でどう扱われるかはNSWindow.CollectionBehaviorによって決まるらしい。これが適切に設定されていないアプリケーションでは、変な挙動になる。

いかがでしたか

まだ変な挙動もあるものの、まあまあ便利と思います。

SwiftUIでSFSafariViewControllerを使う手法の探求

SwiftUIからSFSafariViewControllerを使いたい場面は多い。

SafariView

SFSafariViewControllerはビューコントローラーだから、UIViewControllerRepresentableを使ってSwiftUIのビューにしてしまうのが簡単か。

import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    typealias UIViewControllerType = SFSafariViewController

    typealias Configuration = SFSafariViewController.Configuration

    private let url: URL
    private let configuration: Configuration?

    init(url: URL, configuration: Configuration? = nil) {
        self.url = url
        self.configuration = configuration
    }

    func makeUIViewController(context: Context) -> SFSafariViewController {
        let safariViewController: SFSafariViewController
        if let configuration {
            safariViewController = SFSafariViewController(url: url, configuration: configuration)
        } else {
            safariViewController = SFSafariViewController(url: url)
        }
        return safariViewController
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
    }
}

誰でもこういうコードを書いたことがあるんじゃないか。

あとは何らかのきっかけ‌fullScreenCover(isPresented:onDismiss:content:))なんかを使って、このSafariViewを表示してやればいい。 UIViewControllerpresent(_:animated:completion:)とは少し違うけど、他にいい方法を知らない。

じゃあ「何らかのきっかけ」ってなんだろう、というところが本題。

OpenURLAction

SwiftUIでは、TextのイニシャライザにAttributedStringを渡せば、文字列中にリンクを埋め込める。 あるいはそのものずばりLinkビューというのがあって、URLへのリンクを表現できる。 こういうのをクリックしたときもSFSafariViewControllerを表示したい。

実はLinkドキュメンテーションにいい例があった。

Link("Visit Our Site", destination: URL(string: "https://www.example.com")!)
    .environment(\.openURL, OpenURLAction { url in
        print("Open \(url)")
        return .handled
    })

LinkText内のリンクをクリックすると、EnvironmentからOpenURLActionが呼び出される。 そしてこれは上書き可能になっている。 OpenURLActionを上書きして、SFSafariViewControllerを開くようにフックしてやればよいらしい。

渡ってくるURLを状態として持って、URLが存在するときSafariViewを開く、という風にしたい。 何とか再利用性のあるコードにしたいので、ちょっと考える。

ViewModifier

ここで、ViewModifierを使えばモディファイア内に状態を持てることを思い出す。 ちょっと書いてみるとこういう感じになる。

import SwiftUI

struct OpenURLInSafariViewModifier: ViewModifier {
    @State private var url: URL? = nil
    private var isPresented: Binding<Bool> {
        Binding {
            url != nil
        } set: { newValue in
            if newValue == false {
                url = nil
            }
        }
    }

    private let configuration: SafariView.Configuration?

    init(configuration: SafariView.Configuration?) {
        self.configuration = configuration
    }

    func body(content: Content) -> some View {
        content
            .environment(\.openURL, OpenURLAction { url in
                switch url.scheme {
                case "https"?, "http"?:
                    self.url = url
                    return .handled
                default:
                    return .systemAction(url)
                }
            })
            .fullScreenCover(isPresented: isPresented) {
                if let url {
                    SafariView(url: url, configuration: configuration)
                        .edgesIgnoringSafeArea(.all)
                }
            }

    }
}

extension View {
   func openURLInSafariView(configuration: SafariView.Configuration? = nil) -> some View {
        return modifier(OpenURLInSafariViewModifier(configuration: configuration))
    }
}

いっけんよさそう。

SafariServicesUI

こういう感じで使う。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Link("Open in SFSafariViewController", destination: URL(string: "https://developer.apple.com")!)
            .openURLInSafariView()
    }
}
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Open in SFSafariViewController with [Attributed String](https://developer.apple.com)")
            .openURLInSafariView()
    }
}

状態が隠蔽されていて、シンプルだし、SwiftUIっぽいインターフェースだと思う。

ということでライブラリっぽくSwift Packageにしてみた。

どうでしょうか。

東京に引っ越しました

はてなインターンシップに参加した2012年の夏から、10年弱、京都で暮らした。その間に結婚もしたし、二人の子供にも恵まれた。京都では2Kの賃貸住宅に住んでいて、ひどく手狭だった。上の子は来年度から小学生になる。その前に引っ越しておきたい。

勤務先の株式会社はてなは、フレキシブルワークスタイル制度というので、だいたい全国どこに住んでもよくなった。オフィスに出社することも稀なので、通勤時間を気にする必要もほとんどない。ということで、京都にこだわらず、どこにでも引っ越せる。

いろいろ合理的に考えると、東京近郊がいいということになった。そもそも僕は北海道出身、妻は静岡出身であるから、帰省などに都合がいいのは関東だ。家賃が高くなるのを心配したけど、地域によっては京都の街中より安いくらいだった。

ということで、先月末に引っ越してきた。

部屋が広くなったおかげで、以前は部屋の狭さから我慢していたいろいろなことが解決した。小さすぎる冷蔵庫もいくらか大きなものにできた。特に大きめのダイニングテーブルを買ったことで、家族で落ち着いてご飯を食べたり、子供の勉強をみたりしやすくなって、嬉しい。

東京はなんでもあるようだけど、京都のコンパクトな街が恋しいとも思う。思っていたより、住む街が自分のアイデンティティの一部になっていたことに気づいた。しばらく住んでいたら気にならなくなるのだろうけど、こういう気持ちになったことは覚えていたい。

ということで、住む場所は変わりましたが、引き続きよろしくお願いします。 フレキシブルワークスタイル制度の株式会社はてなもよろしくお願いします。

WWDC22への期待が高まってきたのでSwift Evolutionをナナメ読みする

WWDC22で何が発表されるのか、期待で眠りが浅い日々を過ごしている。何かヒントはないかとSwiftの次期バージョンについて調べていた。

普段から、定期的に同僚のid:ikesyoid:yutailang0119id:nakiwoid:kouki_danらとSwift Evolutionの様子をチェックしていて、おもしろいプロポーザルにはだいたい目を通している(教えてもらっている)のだけど、あらためてみてみると見えてくるものもある。

Swift 5.7になる

現在のSwiftのバージョンは5.6で、次のバージョンはSwift 5.7となる。

Swift 6は破壊的変更のためにとってあるバージョンで、そういう意味ではPython 3のようなフィールもあって不気味だが、期待も高い。

Swiftは(Appleの都合もあってか)半年ごとにマイルストーンを刻んでいる。直近のバージョンで言えば、Swift 5.5でasync/awaitやActorが入って、非同期処理に対する言語的なサポートが充実した。Swift 5.6はそれと較べると小規模な変更で、「チックタック」の「タック」という趣があった。

ではSwift 5.7はどうなるのかというと、広範なアップデートがありそうに見える。

apple/swift-evolution

Swiftの言語に対する変更は、SwiftがOSSであり、Swift Evolutionによる民主的な意思決定プロセスを採用していることから、予め窺い知れることが多い。ということでSwift Evolutionの主なプロポーザルを見ていく。

ここで取り上げるプロポーザルは、すでにSwift 5.7で実装されることが明らかなものもあれば、まだ議論の途中のものもある。また受理されたプロポーザルであっても、実装がSwift 5.7に間に合わないこともある。それでもSwiftの方向性を知るのに参考になるだろうから、混ぜこぜにしている。

構文

SE-0345 if let shorthand for shadowing an existing optional variable

if letでoptional unwrappingするときにif let a = a {}if let a {}と書けるようにして、冗長さをなくす。

SE-0359 Build-Time Constant Values

コンパイル時に決まる(動的ではない)値の概念を導入する。そのような値は、文字列や数値、真偽値、associated valueのないenum、それらのArray、Dictionary、タプルに限られる。@const属性を導入し、プロパティや関数の引数に付ける。そのようなプロパティはコンパイル時に決まる値で初期化されていなければならないし、関数の引数もコンパイル時に決まる値でないといけない。これらの制約(コンパイラから見れば保証)を導入することで、コンパイル時にできる処理が増える。

標準ライブラリ

SE-0329 Clock, Instant, and Duration

(Foundationではなく)標準ライブラリに時刻に関する型を加える。非同期処理のようなタスクのスケジューリングに使う。

SE-0358 Primary Associated Types in the Standard Library

「SE-0346 Lightweight same-type requirements for primary associated types」で導入されたprimary associated typesを標準ライブラリでも設定する。

正規表現

SE-0348 buildPartialBlock for result builders

(「SE-0351 Regex builder DSL」を見越して)result buildersにフックとなるメソッドを追加して、挙動をカスタマイズしやすくする。このフックによってオーバーロードを減らせる。

SE-0350 Regex Type and Overview

正規表現型の導入。キャプチャされた値の型を型パラメータで表現するところがSwiftらしい。

SE-0351 Regex builder DSL

Result buildersでDSLを作り、正規表現を組み立てられるようにする。おもしろい。単におもしろいだけでなく、キャプチャした値を何らかの型に変換する処理も組み込める。

SE-0354 Regex Literals

正規表現リテラルを言語に組み込む。リテラルコンパイル時に評価され、正規表現として妥当であることが保証され、キャプチャの型も決まる。DSLにも組み込める。Swift 6以降は/のみで使えるが、Swift 5.7の時点では演算子の互換性の都合から#/を使う。

SE-0355 Regex Syntax and Run-time Construction

実行時に文字列から正規表現を作る。

SE-0357 Regex-powered string processing algorithms

StringCollection正規表現を活用するメソッドを追加。パターンマッチの追加。

非同期処理関連

SE-0302 Sendable and @Sendable closures

Sendableの概念自体はSwift 5.5からあるが、Sendableのチェックに関する警告は有効になっていなかった。Swift 5.6で有効にされそうだったが、あまりにも警告が多くなりすぎることから、結局無効にされた( https://github.com/apple/swift/pull/41368 )。Swift 5.7でついに有効になった。ついでに-warn-concurrencyオプションが-strict-concurrency=(minimal|targeted|complete)になっている( https://github.com/apple/swift/pull/42523 )。

SE-0338 Clarify the Execution of Non-Actor-Isolated Async Functions

Actorのasync関数から、他のactorに紐付かないasync関数を呼び出すとき、これまではactor上で実行されてしまっていた。これをやめて、actorに紐付かないasync関数は常にグローバルに共有されたプールで実行されることになる。これに伴ってSendableのチェックも行われる。

SE-0340 Unavailable From Async Attribute

Async関数はawaitの前後で実行されるスレッドが同一とは限らない。このような環境で呼び出されることを意図してない(単一のスレッドから呼び出されることを意図している)関数について、async関数からの呼び出しを防ぐように宣言できる。

SE-0343 Concurrency in Top-level Code

トップレベルでawaitできる。このときグローバル変数は暗黙的に@MainActorが関連付けられる。

分散actor

Actor間のやりとりを拡張していくと、(やりとりするメッセージがシリアライズできるなら)個々のactorが同一のプロセス、あるいは物理マシンに存在しなくてもいいんじゃないか、となっていく。しかしこれらが実際にどういうものになるのか、門外漢すぎてわかっていない。

SE-0336 Distributed Actor Isolation

SE-0344 Distributed Actor Runtime

型システム関連

SE-0309 Unlock existentials for all protocols

Associated typeやSelfを持つprotocolでもany Pとしてexistential typeのように扱える。これまではジェネリクスの制約として使うしかなかった。anyという表現はSwift 5.6の「SE-0335 Introduce existential any」ですでに実装されている。

SE-0326 Enable multi-statement closure parameter/result type inference

式が2つ以上のクロージャで、引数や返り値の型推論を有効にする。これまでも式が1つなら型推論されていたが、型チェッカーのパフォーマンスの問題で式が2つ以上のときは型を明示する必要があった。実装の改善でパフォーマンスの問題を解消できたため、2つ以上でも型推論を有効にさせられる。

SE-0328 Structural opaque result types

Opaque result typeは、protocolを返り値の型としてsome Pのように書けるもので、Swift 5.1から導入されている。これを拡張して、(some P)?(some A, some B)() -> some Pのようなパターンも書けるようにする。

SE-0341 Opaque Parameter Declarations

Opaque typeのsome Pを引数の宣言でも使えるようにする。これまでは型パラメータを制約する形で表現していた。

SE-0346 Lightweight same-type requirements for primary associated types

Protocolが1つ以上のassociated typeを持つとき、その一部をprimary associated typeであると宣言できる。Primary associated typeはProtocol<AssociatedType>というジェネリクスと同様の構文で実際の型を指定できる。これによって表記が簡単になる。

SE-0347 Type inference from default expressions

関数の引数の型にジェネリクスで型パラメータを使っていても、型推論によってデフォルト引数を持てるようにする。

SE-0352 Implicitly Opened Existentials

あるprotocol Pについて、any Psome Pとして渡せるようにする。

SE-0353 Constrained Existential Types

「SE-0346 Lightweight same-type requirements for primary associated types」でprimary associated typesが設定されたprotocolがexistential typeとして使える。

SE-0360 Opaque result types with limited availability

Opaque result typeを使っていて関数の返り値がsome Pのとき、その関数が実際に返す具体的な型Tは常に一致している必要がある。例えば関数の中で条件分岐して、ある場合はA、ある場合はBを返したりはできない。これを緩和して、#available()による条件分岐に限って型が違ってもいい、とする。

Swift Package Manager

SE-0339 Module Aliasing For Disambiguation

利用するモジュールに別名を与えて紛らわしくなくできる。

SE-0356 Swift Snippets

Swift Packageにスニペットを追加できる。

Swift 5.7の傾向

こうしてプロポーザルを概観すると、大まかな傾向が掴めるはずだ。

非同期処理関連の改善がいくつか見られる。データ競合を引き起こさない安全な平行処理のために、そしてSwift 6で完全に近い保護を達成するために準備が進められている。

わかりやすいのは正規表現に関するプロポーザルが多いことで、一連のプロポーザルによって、正規表現はSwiftの一級市民になる。Swiftでは何年も前にString Manifestoというのが議論されていて、このときに(正規表現ではないが)文字列のパターンマッチングが俎上に載せられていた。今回の正規表現について、これの一環とも思える。

もうひとつ、型システムについて大幅な改善がある。Opaque result typeのsomeやexistential typeのany、あるいはprimary associated typeによって、protocolの使い勝手が(あるいは書き味が)かなりよくなる。これらの一部はGenerics Manifestoで議論されていたが、ついに大きく手が入った格好である。

Swiftの進化による期待

ここで手が入った機能の多くは、例えばSwiftUIに大きな影響を与えそうだ。SwiftUIはSwiftの言語機能を活かしているし、特に型システム周りでは恩恵が大きそうに思う。

勝手な期待では、SwiftUIのナビゲーションをもっと抽象的に、あるいは宣言的に扱いたい。そういう機能が追加されるとして、アプリ内の任意の画面にディープリンクを設定するなら、正規表現の言語的サポートも大いに役立ちそうだ。

加えて、Swiftの言語的な進化による、Swiftのためのライブラリやフレームワークの進歩も期待している。Apple製のフレームワークのうち新しいものには、Swiftからのみ利用できるものが出てきている。それらはSwiftの言語機能を活かしていて、堅牢で使いやすい。今後もそのような、例えばasync/awaitやactorをうまく活用したフレームワークが増えていくだろうことに非常に期待している。例えばデータの永続化について、Swiftyなものが出てきてほしい。

ということで、今年もWWDCを楽しみにしています。

AWS LambdaでSlackアプリを動かす

プライベートな用事でサーバサイドで何かやりたい場合、サーバレスな構成が第一選択になる。規模が十分に小さい場合、サーバレスにした方が安い。常にインスタンスが立ち上がっているような構成は(たとえ冗長構成を取らなくても)プライベートな用事程度では大げさになる。またサーバレスな構成は放置しやすいのも魅力である。

f:id:cockscomb:20220307094357p:plain
Lambdaで動くcockscombot

最近、サーバサイドで何かしたあとの通知先としてSlackを使っている。Slackはちょっとしたユーザーインターフェースの代わりになる。その延長線上でSlackアプリを作ってみようと考えた。Slackが提供しているBoltというのを使うと、Slackアプリが簡単に作れる。

Slack | Bolt for JavaScript | Bolt 入門ガイド

BoltはAWS Lambdaにデプロイできるようになっている。ドキュメントではServerless Frameworkが使われているが、要するにAPI Gateway経由でLambdaを呼び出しているだけだ。

Slack | Bolt for JavaScript | AWS Lambda へのデプロイ

AWS CDKでSlackアプリを構築する

Serverless Frameworkでもいいけど、AWS CDKでもすぐにできる。API GatewayにNode.jsランタイムのLambdaをくっつけるのは、次のように書け、これでPOST /slack/eventsでLambdaが呼び出されるようになる。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import { Construct } from 'constructs'

export class SlackBotStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
    })

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

API GatewayをHTTP APIにしたかったので、experimentalの@aws-cdk/aws-apigatewayv2-alpha Construct Libraryを使っているが、これくらいなら大きな問題にはならないだろう。

aws-cdk-lib/aws-lambda-nodejsのおかげで、TypeScriptで書いたSlackアプリのコードがesbuildでいい具合にトランスパイルされ、手間要らずだ。

肝心のSlackアプリ(./slack/src/bot.ts)はBoltを使って次のように書ける。

import bolt from '@slack/bolt'
const { App, AwsLambdaReceiver } = bolt

const SLACK_BOT_TOKEN = ??? // TODO
const SLACK_SIGNING_SECRET = ??? // TODO

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: SLACK_SIGNING_SECRET,
})
const app = new App({
  token: SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
})

app.message('Hello', async ({ message, say }) => {
  await say('Hi')
})

export const handler: ReturnType<typeof awsLambdaReceiver.toHandler> = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start()
  return handler(event, context, callback)
}

AwsLambdaReceiverというのが用意されているおかげで、Lambdaと繋ぎこむところはほとんど何もしなくていい。

シークレットをParameter Storeから読み取る

SLACK_BOT_TOKENSLACK_SIGNING_SECRETのふたつのシークレットを使っている。これらはハードコードしたくないので、AWS Systems Manager Parameter Storeに入れておいたものをLambdaから読み取らせることにする。

Parameter Storeにそれぞれ/slack/bot/SLACK_BOT_TOKEN/slack/bot/SLACK_SIGNING_SECRETの名前で、シークレットとして保存しておく。これらをLambdaから読み取るために、スタックを次のように書き換える。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import * as ssm from 'aws-cdk-lib/aws-ssm'
import { Construct } from 'constructs'

export class SlackBotStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    const slackBotToken = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackBotToken', {
      parameterName: '/slack/bot/SLACK_BOT_TOKEN',
    })
    const slackSigningSecret = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackSigningSecret', {
      parameterName: '/slack/bot/SLACK_SIGNING_SECRET',
    })
    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
      environment: {
        SLACK_BOT_TOKEN_NAME: slackBotToken.parameterName,
        SLACK_SIGNING_SECRET_NAME: slackSigningSecret.parameterName,
      },
      bundling: {
        target: 'node14.14', // Top-level awaitが使えるバージョン
        tsconfig: './slack/tsconfig.json',
        format: lambda.OutputFormat.ESM,
        nodeModules: ['@aws-sdk/client-ssm', '@slack/bolt'],
      },
    })
    slackBotToken.grantRead(botFunction)
    slackSigningSecret.grantRead(botFunction)

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

ssm.StringParameter.fromSecureStringParameterAttributes()でパラメータを参照し、slackBotToken.grantRead(botFunction)のようにしてLambdaに読み取り権限を与える。また環境変数を介してパラメータの名前を渡しておく。

さらにlambda.NodejsFunctionのオプションにbundlingを増やし、esbuildにオプションを渡す。./slack/tsconfig.jsonも少し調整してある。

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "target": "es2019",
    "module": "es2022",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true
  }
}

面倒な感じになっているが、こうするとTypeScriptからES modulesが生成され、top-level awaitが使えるようになる。AWS LambdaではNode.js 14ランタイムでtop-level awaitがサポートされており、特にLambdaのProvisioned Concurrencyを利用している場合にコールドスタート時のレイテンシが改善するとされている。

後は次のようにシークレットを読み取る関数を用意しておけばよい。

import { GetParametersCommand, SSMClient } from '@aws-sdk/client-ssm'
import { strict as assert } from 'assert'

export async function getSlackSecrets(): Promise<{ token: string; signingSecret: string }> {
  const [SlackBotTokenName, SlackSigningSecretName] = [
    process.env.SLACK_BOT_TOKEN_NAME,
    process.env.SLACK_SIGNING_SECRET_NAME,
  ]
  assert(SlackBotTokenName, 'SLACK_BOT_TOKEN_NAME is not set')
  assert(SlackSigningSecretName, 'SLACK_SIGNING_SECRET_NAME is not set')

  const ssmClient = new SSMClient({
    region: process.env.AWS_REGION,
  })
  const parametersResponse = await ssmClient.send(
    new GetParametersCommand({
      Names: [SlackBotTokenName, SlackSigningSecretName],
      WithDecryption: true,
    })
  )
  const parameters = parametersResponse.Parameters
  assert(parameters, 'Parameter Store values not found')
  const [{ Value: token }, { Value: signingSecret }] = parameters
  assert(token, 'Parameter Store value for SLACK_BOT_TOKEN is not set')
  assert(signingSecret, 'Parameter Store value for SLACK_SIGNING_SECRET is not set')

  return {
    token,
    signingSecret,
  }
}

シークレットの取得はtop-level awaitを使って次の1行で済む。

const { token: SLACK_BOT_TOKEN, signingSecret: SLACK_SIGNING_SECRET } = await getSlackSecrets()

ここまでやってはみたが、別にLambdaのProvisioned Concurrencyを使うわけではない。Top-level awaitを使おうとするとES modulesにする必要があって、現状のエコシステムでは少し複雑になる。これくらいならシークレットをそのまま環境変数に入れてしまった方がいいかもしれない。

費用

試しに実行してみた感じでは、128MBのメモリ割り当てで、コールドスタートなら平均的に250msくらい、ウォームスタートなら2msくらいのBilled Durationになった。メッセージに反応するくらいなら一瞬で処理できる。

app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される。実際に処理する必要があるかどうかは問わない。例えばSlackアプリが参加しているチャンネルに毎日1,000件のメッセージが投稿されるとすると、これに伴って呼び出されるLambdaはこれくらいの頻度であれば毎回コールドスタートになるだろうから、東京リージョンでは月々$0.03かかる。API Gatewayも呼び出し毎に課金され、月々にして$0.04かかる。合わせて月々$0.07になった。お小遣いで払えそうでよかった。

サービス 費用(月々)
AWS Lambda $0.03
Amazon API Gateway $0.04
合計 $0.07

AWS Pricing Calculator

ここから、Lambdaのアーキテクチャx86からArmにすることでLambdaの費用を2/3に抑えられる可能性がある。そもそもLambdaの無料枠が残っていればそれに収まる。

このSlackアプリはほとんど意味のあることをしていないが、ちゃんと実装して処理時間が呼び出しあたり100ms増えたとしても月々$0.10程度に収まるだろうし、データベースにDynamoDBを使ってもたかがしれていると思う。

イベントの購読

費用の計算で、「app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される」ことを前提にした。app.message()を使うにはmessage.channelsmessage.groupsmessage.immessage.mpimのようなmessage.*イベントを購読する。しかし例えば、Slackアプリに対してのメンションにだけ反応すればいいなら、app.event()app_mentionイベントだけを購読すればいい。呼び出し回数を大きく抑えられ、費用が下がる。

いかがでしたか

Slackアプリをサーバレスで構築するのは簡単で、趣味で使う範囲では十分に安い。Serverless Frameworkだと(ngrokを使えば)ローカルで開発しやすいが、そこに目を瞑ればCDKでも普通に開発できる。DynamoDBを組み合わせるときなどはCDKの方が楽だと思う。開発時にはCDK Watchdeploy --hotswapなどを使うことでデプロイを速く楽にできる。

みんなもやってみてね。