はじめに
Haskellで関数型PG言語に出会い、挫折を繰り返し、Elmに出会って満足していた自分ですが、やっぱりHaskellでバックエンドを作りたくなるのがFuncational Programmerの性ってものです(たぶん)
Haskellを知ってから15年くらい経ち、その界隈の技術と私の知識もだいぶ進んだだろう、ということで、改めてMade by Full Functionarl Programming LanguageなWebアプリを作り、かつAWSで公開する取り組みを開始しました。
具体的な目標
- Webアプリを作る
- バックエンドをHaskellで作って
- フロントエンドをElmで作り
- それをAWSを使ってデプロイ、公開する
構成
- バックエンドはServantで作成
- 可能ならDBアクセスも実装する
- フロントエンドはElmで作る
- これらをDockerにまとめてAWSにデプロイする
実は、ここまでDockerにほぼノータッチで過ごしてきたので、その学習も兼ねています。
免責事項
この記事は完成しない可能性があります!!
ここからが、やったこと
開発PC
- MacBook Air M1, 2020 Sequoia 15.2
Gitリポジトリ
何はともあれ、ローカル環境で動作する最小のWebアプリを作成
Haskellと言えばstack。
これを使ってローカルで起動する最低限のWebアプリを作ります。
ここでの成果物はこれ。
https://github.com/jabaraster/servant-elm-study/blob/f062c63a9c362b643111cd789a693b5bf40d8cd5
今回は静的ファイルの配信も必要なのですがその実現にかなり遠回り。
終わってみればServantが提供しているserveDirectoryFileServer
などの関数を使えばいいのですが、そこに気付くのに時間がかかった!
server :: Server API
server =
indexHandler
:<|> serveDirectoryFileServer "./static" -- ここ!
:<|> usersHandler
全体的な構成としては
- フロントエンドでビルドした成果物のフォルダを
- Servantの
serverDirectioryFileServer
にて公開
となります。
ただ、何やら静的リソースをバイナリに含める方法があるらしく。
ここserveDirectoryEmbedded辺りを使うような気がしています。
Dockerで公開するためのビルド環境の構築
ここからが難しい。
こちらの記事を足がかりにして進めます。
https://qiita.com/lambda_funtaro/items/5ac47f83616f8c07d4db
大雑把な目標は
-
まずmuslを使ってシングルバイナリを作って
→ 上手くいかないのであきらめました。 - それを動かすDockerfileを書き
- Dockerをビルドし
-
AWS ECR/ECSを使ってデプロイする
→ ECSは結構料金が高いし設定が面倒なので、まずはLightsailを試してみることにします。
というものになります。
2025.1.26追記
さらに
- 静的ファイルをバイナリに埋め込む
という仕掛けを追加します。
静的ファイルをバイナリに埋め込む
シングルバイナリを作るのはかなり苦労しそうなので、先にこのテーマをメモ。
なお、この機能を組み込んだ直後の状態は以下のコミットをご覧ください(長くなるので先にご案内)。
https://github.com/jabaraster/servant-elm-study/commit/0058854667a7d297fff813009e0d451bf1c3558b
動機
なぜ静的ファイルをバイナリに埋め込みたいか、その理由はシングルバイナリにしたいからです。
作ったバイナリをサーバに配置するときに、静的ファイルを別途コピーするのは面倒ですから。
TemplateHaskellを使って静的ファイルを埋め込む
Servantが依存しているWAIというライブラリのドキュメントで、静的ファイルをTemplateHaskellを使ってバイナリに埋め込む方法が紹介されていました。
これを使うと次のようにして静的ファイルを配信するハンドラが書けます。
serveDirectoryWith $(mkSettings Embedded.staticFiles)
Embedded.staticFilesというのは自作の関数です。
コンパイル時に特定のフォルダ下のファイルの内容をソースコードに埋め込むためのコードを書いています。
ただ、実際にTemplateHaskellとして展開するのはWAIが提供しているWaiAppStatic.Storage.Embedded.mkSettings
という関数がやってくれるので、自作するのはmkSettings関数に渡す引数を作成することです。
staticFiles :: IO [EmbeddableEntry]
staticFiles =
listStaticFileNames
>>= mapM fileToEmb
listStaticFileNames :: IO [FilePath]
listStaticFileNames =
getDirectoryContents "public"
>>= return . filter (\x -> x /= "." && x /= ".." && x /= "index.html")
fileToEmb :: FilePath -> IO EmbeddableEntry
fileToEmb fileName = do
let path = "public" ++ [pathSeparator] ++ fileName
cnt <- BL.readFile path
return
EmbeddableEntry
{ eLocation = T.pack fileName
, eMimeType = defaultMimeLookup $ T.pack path
, eContent = Left (hash cnt, cnt)
}
index.htmlの扱い
今回想定しているのはSPAな作りのWebアプリであり、その場合エントリポイントとなるHTMLを配信しなければなりません。
ただ、Servantのルーティングの仕様なのか、前節で書いた方法でindex.htmlをバイナリに埋め込武のがどうしてもうまく行きませんでした。
例えば、次のように書いてもうまく動作しないのです(トップページがFile Not Foundになる)。
type API =
Raw
:<|> "public" :> Raw
:<|> "api" :> "users" :> Get '[JSON] [User]
server :: Server API
server =
serveDirectoryWebApp "public"
:<|> serveDirectoryWebApp "public"
:<|> liftIO usersHandler
なのでindex.htmlには別の方法を考えなければなりません。
2つの機能の合わせ技で実現します。
- Servantの機能を使って、HTML文字列をクライアントに返すハンドラ?を作成
- 1で作った関数を使ってTemplateHaskellにてソースコードに埋め込む仕組みを作成
Servantの機能を使って、HTML文字列をクライアントに返すハンドラ?を作成
正直ここはよく理解できていないですが、とりあえず以下のソースで実現できます。
newtype FileContent = FileContent {unRaw :: ByteString}
data HTML = HTML
instance Accept HTML where
contentType _ = "text" // "html" /: ("charset", "utf-8")
instance MimeRender HTML FileContent where
mimeRender _ = unRaw
makeHandlerFromHtml :: ByteString -> Handler FileContent
makeHandlerFromHtml = return . FileContent
1で作った関数を使ってTemplateHaskellにてソースコードに埋め込む仕組みを作成
今回はお手軽にTemplateHaskellを実装できる、QuasiQuoterという機能を使って実現しました。
genIndexHandler :: QuasiQuoter
genIndexHandler =
QuasiQuoter
{ quoteExp = undefined :: String -> Q Exp
, quotePat = undefined :: String -> Q Pat
, quoteType = undefined :: String -> Q Type
, quoteDec = genIndexHandlerCore :: String -> Q [Dec]
}
genIndexHandlerCore :: FilePath -> Q [Dec]
genIndexHandlerCore pathWithSpace = do
let name = mkName "indexHandler"
let path = strip pathWithSpace
html <- liftIO $ Prelude.readFile path
return
[ SigD name (AppT (ConT ''Handler) (ConT ''FileContent)) -- 関数宣言部
, FunD
name
[ Clause
[]
( NormalB $ AppE (VarE 'makeHandlerFromHtml) (LitE $ StringL html)
)
[] -- 実装定義部
]
]
genIndexHandlerCore
が心臓部です。
Haskellのソースコードを組み立てています。
一方、これを使ってindex.htmlのハンドラを作るには、以下のようにします。
[Embedded.genIndexHandler| ./public/index.html |]
工夫・開発中は静的ファイルの変更を検出したい
さてここまで静的ファイルをバイナリに埋め込む方法を追求してきましたが、開発中はこれでは不便です。
JSやCSSを更新したら毎回Haskell側をビルドしないといけないのは手間ですよね。
なので、リクエストがある度にファイルから内容を読み込んで返す機能を追加して、環境変数で切り替えられるようにします。
これを反映した、静的ファイルのハンドラは以下のようになります。
まずはpublicディレクトリ下のファイルを配信する箇所。
こちらは単純。
server :: Config -> Server API
server config =
indexHandler
:<|> ( if Config.runtimeEnv config == Dev
then (serveDirectoryWebApp "public")
else serveDirectoryWith $(mkSettings Embedded.staticFiles) -- index.html以外の静的ファイルもバイナリに埋め込む
)
:<|> liftIO usersHandler
もう1つ、index.htmlのハンドラ。ここは、QuasiQuoterを使ってHaskellのソースコードを作成するので、ちょっと複雑です。
dynamicHandlerという関数を追加した上で、環境変数によって呼び出す関数を切り替えます。
newtype FileContent = FileContent {unRaw :: ByteString}
data HTML = HTML
instance Accept HTML where
contentType _ = "text" // "html" /: ("charset", "utf-8")
instance MimeRender HTML FileContent where
mimeRender _ = unRaw
makeHandlerFromHtml :: ByteString -> Handler FileContent
makeHandlerFromHtml = return . FileContent
dynamicHandler :: FilePath -> Handler FileContent
dynamicHandler path = do
html <- liftIO $ Lazy.readFile $ strip path
return $ FileContent $ html
genIndexHandler :: QuasiQuoter
genIndexHandler =
QuasiQuoter
{ quoteExp = undefined :: String -> Q Exp
, quotePat = undefined :: String -> Q Pat
, quoteType = undefined :: String -> Q Type
, quoteDec = genIndexHandlerCore :: String -> Q [Dec]
}
genIndexHandlerCore :: FilePath -> Q [Dec]
genIndexHandlerCore pathWithSpace = do
let name = mkName "indexHandler"
let path = strip pathWithSpace
config <- liftIO $ Config.loadConfigWithDefault
if Config.runtimeEnv config == Dev
then do
-- 開発中は毎回ファイルを読み込む
return
[ SigD name (AppT (ConT ''Handler) (ConT ''FileContent)) -- 関数宣言部
, FunD
name
[ Clause
[]
( NormalB $ AppE (VarE 'dynamicHandler) (LitE $ StringL path)
)
[] -- 実装定義部
]
]
else do
-- 開発以外ではコンパイル時にファイルを埋め込む
html <- liftIO $ Prelude.readFile path
return
[ SigD name (AppT (ConT ''Handler) (ConT ''FileContent)) -- 関数宣言部
, FunD
name
[ Clause
[]
( NormalB $ AppE (VarE 'makeHandlerFromHtml) (LitE $ StringL html)
)
[] -- 実装定義部
]
]
これで開発スピードを落とさず、かつビルドの成果物が最小になる環境が出来上がりました!
Lightsailへのデプロイ
AWSのLightsailにデプロイするのに苦労しています。
AWSが用意しているサンプルのWebアプリはデプロイに成功するのですが、そのソースを使って自前でビルドしたDockerイメージを使ったデプロイが上手くいかないのです。
このようなログが出ているので、Dockerコンテナがいつまでも起動していないか、あるいは所定のポートが開いていないのか。
いずれにしろ、これ以上のログが出ないので調べようがないんですよね・・・
シングルバイナリを作る設定
・・・2025年2月の時点で、シングルバイナリ化は一旦諦めました。
先に、AWSにDockerイメージをデプロイしてWebアプリとして公開する手順を先に確立することにしました。
一応、何をやったかは残しておきます。
stackにはDockerを使ってビルドする機能があります。
stack.yamlに書く方法もあるのですが、まずはstack build
コマンドでDockerイメージを指定する方法を使います。
stack build \
--ghc-options ' -static -optl-static -optl-pthread -fPIC' \
--docker --docker-image "utdemir/ghc-musl:v25-ghc944" \
--no-nix
ここで困ったことが発生。
ghcをダウンロードするときに、コンテント長が想定と異なる、と言って怒られます。
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
Preparing to install GHC (musl) to an isolated location. This will not interfere with any system-level installation.
Preparing to download ghc-musl-9.6.6 ...
Download expectation failure: ContentLength header
Expected: 224131196
Actual: 198860724
For: https://downloads.haskell.org/ghc/9.6.6/ghc-9.6.6-x86_64-alpine3_12-linux.tar.xz
GHC9.6.6に関するバイナリの一覧はこちら
にあります。ここで確認したところ、
- ダウンロードしようとしているファイルはghc-9.6.6-x86_64-alpine3_12-linux.tar.xzなのに
- コンテント長はghc-9.6.6-x86_64-alpine3_12-linux-static.tar.xzのものを見ている
ということが分かります。
が、これがなぜ起こるのか、どうやって解消させるのかが分からない。
stack.yamlにはskip-ghc-check
というオプションもあり、これをtrueにしてみましたが、
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
wai-app-static > configure
wai-app-static > Configuring wai-app-static-3.1.9...
wai-app-static > build with ghc-9.4.4
wai-app-static > Preprocessing library for wai-app-static-3.1.9..
wai-app-static > Building library for wai-app-static-3.1.9..
wai-app-static > [1 of 9] Compiling WaiAppStatic.Types
wai-app-static > [2 of 9] Compiling Util
wai-app-static > [3 of 9] Compiling WaiAppStatic.Listing
wai-app-static > [4 of 9] Compiling WaiAppStatic.Storage.Filesystem
wai-app-static > [5 of 9] Compiling WaiAppStatic.Storage.Embedded.TH
wai-app-static > [6 of 9] Compiling WaiAppStatic.Storage.Embedded.Runtime
wai-app-static > [7 of 9] Compiling WaiAppStatic.Storage.Embedded
wai-app-static > [8 of 9] Compiling Network.Wai.Application.Static
Progress 1/3
Error: [S-7282]
Stack failed to execute the build plan.
While executing the build plan, Stack encountered the error:
[S-7011]
While building package wai-app-static-3.1.9 (scroll up to its section to see the error) using:
/xxxxxxxxxx/.stack/setup-exe-cache/x86_64-linux-dk85569e7f830e7dc606115fd702e078fb/Cabal-simple_DY68M0FN_3.8.1.0_ghc-9.4.4 --verbose=1 --builddir=.stack-work/dist/x86_64-linux-dk85569e7f830e7dc606115fd702e078fb/ghc-9.4.4 build --ghc-options " -fdiagnostics-color=always"
Process exited with code: ExitFailure (-11)
2024/12/19時点ではここまで。
進展したら追記します。
追記がなかったら力尽きたと察してください。
2024/12/23追記
このような記事に出合いました。
HaskellでWebアプリを作る際の共通的なインターフェイスを定義するWAIがシングルバイナリを作る際の障壁となっているのでは?という内容。
WAIが依存するNetworkがCのFFIに依存している、とあります
確かに、ビルドがうまく行っていないエラーメッセージに
While building package wai-app-static-3.1.9 (scroll up to its section to see the error) using:
とあります。
これは怪しい!
もしかすると、シングルバイナリを諦める必要があるかもしれません。
今回の目標はHaskell+Elmで作ったWebアプリケーションをAWSで公開することです。
シングルバイナリを作ることではありません。
この辺を見直してみます。
Lightsailへのデプロイ
別の記事で言及したようにWebアプリをAWSにDocker使ってデプロイする場合はLightsailを使うのが楽チンです。
ただ、落とし穴が2つほどあって
- Lightsailで動作するプラットフォームは
linux/x86_64
-
aws lightsail push-container-image
コマンドでCannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
というエラーが出る
順に対処法を示します。
Lightsailで動作するプラットフォームはlinux/x86_64
これは単純な話で、docker buildするときにプラットフォームを明示的に指定します。
docker build . --platform linux/x86_64 -t ...
aws lightsail push-container-imageコマンドのエラー
このエラーは、どうもMacで実行したときに生じるエラーのようです。
さて、このコマンドが今回やりたいことに絶対に必要かというとそうではありません。
というのは、このコマンドをローカルで作成したDockerイメージをLightsailの特定のリポジトリにプッシュするものであって、このリポジトリを使わない場合は、このコマンドを使う必要はないのです。
ただ、じゃあこのリポジトリを使わない場合にどのリポジトリを使うの?と言ったらそれは迷います。
Docker Hubみたいな全世界に公開されるリポジトリは論外として、じゃあAWSのECRを使うのは大袈裟だし、そもそもWebアプリをデプロイしたいだけなのに、そこから離れたサービスに別途リポジトリを作るのは、なんか回り道をしている気がします。
というわけで、Lightsailにデプロイしたいイメージは、Lightsailの中で管理するのが便利であり、そこにイメージをプッシュするコマンドがaws lightsail push-container-image
なわけですね。
というわけで、このエラーはなんとか解消したい。
調べたところ、
- Docker Desktopの設定を変更する
- DOCKER_HOSTを明示的に指定する
という2つが必要なようです。
Docker Desktopの設定については、以下の記事に詳しいです。
DOCKER_HOSTについては、以下の記事に詳しいです。
https://koko206.hatenablog.com/entry/2024/02/12/195159
さて、ここまでの情報を総合すると、LightsailにHaskell+Elmで作ったWebアプリをデプロイするには
- 事前に、Docker Desktopの設定を変更しておく(
Use containerd for pulling and storing images
を無効にする) -
--platform linux/x86_64
を付けてdocker build
する -
docker context ls
コマンドでdesktop-linuxのエンドポイントを調べる -
aws lightsail push-container-image
を実行するときに環境変数DOCKER_HOST
を前項で調べた値に設定する - Lightsailのコンソールで、プッシュしたイメージからコンテナを起動する
これで、やっとHaskell+ElmのWebアプリがデプロイできました!
やったーーーー!