diff --git a/blog.cabal b/blog.cabal index e1bf90c310b32f0086600536804d8d23c9e67b79..b5b685aca5e52c6380c7a0f284d0b23aef0ed78e 100644 --- a/blog.cabal +++ b/blog.cabal @@ -17,7 +17,9 @@ executable blog default-language: Haskell2010 ghc-options: -threaded build-depends: base == 4.*, + lens, hakyll == 4.7.*, + hakyll-serve, filepath == 1.4.*, split == 0.2.*, random, @@ -33,10 +35,12 @@ executable server build-depends: base == 4.*, safe == 0.3.*, text, + lens, bytestring == 0.10.*, + hakyll-serve, warp == 3.*, warp-tls == 3.*, wai == 3.*, - wai-extra <= 3.0.14, + wai-extra >= 3.0.14, wai-app-static == 3.*, streaming-commons == 0.1.* diff --git a/images/posts/haskell-containers.png b/images/posts/haskell-containers.png new file mode 100644 index 0000000000000000000000000000000000000000..04fef08ce3e7b1adaf7dc38ec8e52495451cff4f Binary files /dev/null and b/images/posts/haskell-containers.png differ diff --git a/posts/2016-04-20-haskell-travis-docker.md b/posts/2016-04-20-haskell-travis-docker.md new file mode 100644 index 0000000000000000000000000000000000000000..a4b50c5f3af2db139612b50f5746bfb52ee91a8c --- /dev/null +++ b/posts/2016-04-20-haskell-travis-docker.md @@ -0,0 +1,161 @@ +--- +title: Continuous integration for Haskell projects with Docker +date: Wed April 20 14:21:00 EDT 2016 +author: Eduardo Trujillo +uuid: ccdac18a-4592-4560-9505-8940bb69df46 +--- + +At my current job, we are starting to adopt Haskell to write some of our +backend APIs and, like other projects at the company, we are using Docker for +deploying them to staging and production environments. + +Working with Haskell required some re-thinking on how we do certain parts of +our workflow, but it also led to a much improved image size, with some extra +room for improvement. + + + +Unlike PHP or JavaScript, Haskell is a compiled language, meaning that some of +previous approaches we were using for building Docker images did not port that +well. + +For dynamic languages, we followed roughly these steps to build our images: + +- Use a base image with packages for the software we need (Debian, Alpine). +- Install server software, such as Nginx, Node.js, and PHP. +- Install language tools and package managers. +- Copy application sources. +- Download dependencies. +- Final optimizations and cleanup. + +This is a hybrid approach where we have both build and runtime dependencies on +the same Docker image, which is useful because image builders such as Quay.io +can automatically build the docker image for you on every commit without the +need of an additional step in the CI pipeline, and this also has the slight +advantage of having enough tooling inside a container for light debugging. + +As a result, the image size is not the smallest it could be since there is a +lot of things that are not commonly used during runtime. However, the added +convenience sort of out weighs the issues of having a larger image for these +cases. + +For Haskell projects, though, things are bit different, and not in a bad way: + +The community has made an excellent build tool called [Stack][3]. `stack` takes +care of mostly everything related to setting up your project: Installing GHC, +pulling dependencies, building, testing, coverage reports, documentation. When +paired with Nix, it can even pull non-Haskell dependencies for reproducible +builds. + +<div class="callout-quote"> +Stack takes care of mostly everything related to setting up your project. +</div> + +If we try to do a hybrid image approach like above using Stack, we mainly have +to do the following on a Dockerfile: + +- Download and install Stack. +- Copy application sources. +- Install GHC (`stack setup`). +- Compile project (`stack build`). + +This works, but it is extremely slow and the resulting images are huge +(+800MB!). + +On every Docker build, `stack` would have to download and install GHC, and then +proceed to download and compile every dependency of the project, which tended +to a good 30 minutes on a [Quay.io][2] worker node. + +When developing locally, you only have to go through this process every now and +then because most of it is cached in directories such as `~/.stack` and +`.stack-work`. + +Looking for faster build times and smaller images, I decided to experiment with +splitting the build and runtime aspects of the project. + +The build part was already setup since we were using Travis CI for running +unit and some integration tests. When compared to basic Docker image builders, +Travis CI has the clear benefit of having a cache that can be reused across +builds without too much work. This cache allowed us to keep our built +dependencies across builds, which reduced the build time to under 5 minutes. + +This enable caching of Stack builds, you just need to add the work directories +to the cache section of `.travis.yml`: + +```yaml +cache: + directories: + - "$HOME/.stack" + - .stack-work +``` + +So, getting the runtime half working meant taking the resulting build from the +previous steps and building a Docker container with enough dependencies and +data files for running the application. + +FPComplete has [a great article][1] out there on how to create minimal Docker +images for Haskell projects. + +The main difficulty with this process is that Haskell programs are not built +statically by default, meaning that you have to identify all the libraries the +binary is linked against and include them in the final runtime image. + +<div class="callout-quote"> +The main difficulty with this process is that Haskell programs are not built +statically... +</div> + +In order to keep things simple, I decided to stick to using a base image, which +we could use to pull in any dependencies we don't have, like `libcurl-dev`. + +I initially tried to use Alpine, since its known for being one of the smallest +images out there. However, getting a Haskell program running in it was not +trivial since it requires cross-compiling GHC. + +So I settled with `debian`, which is a larger image, but has almost everything +we need out of the box. + +Building a Docker image on Travis CI is a fairly simple process. Pushing it to +a registry and correctly tagging it was the annoying part. After a couple of +hours of trial and error, I made a small shell script for authenticating with +the registry and pushing a tagged image matching the current git tag and +branch. + +This script is called on the `after-success` step of the Travis CI build: + +```bash +#!/bin/bash +set -euo pipefail +IFS=$'\n\t' + +docker build -t myapp . + +# If this is not a pull request, update the branch's docker tag. +if [ $TRAVIS_PULL_REQUEST = 'false' ]; then + docker tag myapp quay.io/myorg/myapp:${TRAVIS_BRANCH/\//-} \ + && docker push quay.io/myorg/myapp:${TRAVIS_BRANCH/\//-}; + + # If this commit has a tag, use on the registry too. + if ! test -z $TRAVIS_TAG; then + docker tag myapp quay.io/myorg/myapp:${TRAVIS_TAG} \ + && docker push quay.io/myorg/myapp:${TRAVIS_TAG}; + fi +fi +``` + +As a result, we now have Docker images for our Haskell projects that are about +80 MB, which is not terrible, but can definitely be improved on. + +The next steps for me are investigating how to make our images even smaller by +using a smaller base image, and automate the deployment of development +and staging environments by having Travis CI notify a scheduler that a new +image has been built. + +I'm including some of my scripts and an example Dockerfile on a +[GitHub Gist][4] for reference. You will most likely have to modify them to +meet your needs. + +[1]: https://www.fpcomplete.com/blog/2015/05/haskell-web-server-in-5mb +[2]: https://quay.io +[3]: http://haskellstack.org/ +[4]: https://gist.github.com/etcinit/d484b72de336836a956eb51b3da231ad diff --git a/server.hs b/server.hs index 3c57426e272c94672fa41a1112ce0e1551cc98d5..ee457166c7f94f66c1767175a99bae2d5089d3ff 100644 --- a/server.hs +++ b/server.hs @@ -1,193 +1,93 @@ {-# LANGUAGE OverloadedStrings #-} -import Control.Concurrent (forkIO) -import qualified Data.ByteString as BS (ByteString, pack) -import Data.Maybe (fromMaybe, mapMaybe) -import Data.Monoid ((<>)) -import Data.Streaming.Network (HostPreference) -import Data.String (fromString) -import qualified Data.Text as T (Text, concat, pack) -import qualified Data.Text.Encoding as TE (encodeUtf8) -import Network.Wai (Application, Middleware, - pathInfo) -import Network.Wai.Application.Static (defaultWebAppSettings, - ss404Handler, - ssAddTrailingSlash, - ssIndices, ssMaxAge, - ssRedirectToIndex, - staticApp) -import Network.Wai.Handler.Warp (defaultSettings, runSettings, - setHost, setPort) -import Network.Wai.Handler.WarpTLS (runTLS, - tlsSettingsChain) -import Network.Wai.Middleware.AddHeaders (addHeaders) -import Network.Wai.Middleware.ForceDomain (forceDomain) -import Network.Wai.Middleware.ForceSSL (forceSSL) -import Network.Wai.Middleware.Gzip (def, gzip) -import Network.Wai.Middleware.RequestLogger (logStdout) -import Network.Wai.Middleware.Vhost (redirectTo) -import Safe (lastMay) -import System.Environment (lookupEnv) -import WaiAppStatic.Types (MaxAge (MaxAgeSeconds), - toPiece) - --- | The core application. --- It serves files from `_site` whic is where Hakyll will place the generated --- site. -staticSite :: Maybe String -> Application -staticSite path = staticApp - (defaultWebAppSettings $ fromString $ fromMaybe "_site" path) - { ssIndices = mapMaybe (toPiece . T.pack) ["index.html"] - , ssRedirectToIndex = False - , ssAddTrailingSlash = True - , ss404Handler = Just redirectApp - , ssMaxAge = MaxAgeSeconds 3600 - } - --- | 404 handler. --- We will redirect users to a 404 page if we can't locate the resource they --- are looking for. -redirectApp :: Application -redirectApp req sendResponse = sendResponse $ redirectTo "/" - --- | Gzip compression middleware. -gzipMiddleware :: Middleware -gzipMiddleware = gzip def - --- | Domain redirection middleware. --- When the site is live, we want to redirect users to the right domain name --- regarles of whether they arrive from a www. domain, the server's IP address --- or a spoof domain which is pointing to this server. -domainMiddleware :: Middleware -domainMiddleware = forceDomain - (\domain -> case domain of - "localhost" -> Nothing - "chromabits.com" -> Nothing - _ -> Just "chromabits.com") - --- | Common headers middleware. -headersMiddleware :: Middleware -headersMiddleware = addHeaders - [ ("X-Frame-Options", "SAMEORIGIN") - , ("X-XSS-Protection", "1; mode=block") - , ("X-Content-Type-Options", "nosniff") - ] - --- | Strict Transport Security middleware. -stsHeadersMiddleware :: Middleware -stsHeadersMiddleware = addHeaders - [("Strict-Transport-Security", "max-age=31536000; includeSubdomains")] - --- | Content Security Policy middleware. --- Here we add the CSP header which includes the policies for this blog. -cspHeadersMiddleware :: Middleware -cspHeadersMiddleware = addHeaders - [("Content-Security-Policy", TE.encodeUtf8 $ glue policies)] - where - glue :: [T.Text] -> T.Text - glue [] = "default-src 'none'" - glue [x] = x - glue xs = T.concat $ map (\x -> T.concat [x, "; "]) (init xs) ++ [last xs] - - policies :: [T.Text] - policies = [ "default-src 'self'" - , "script-src 'self' 'unsafe-inline' https://use.typekit.net" - <> " https://cdn.mathjax.org https://connect.facebook.net" - <> " https://*.twitter.com https://cdn.syndication.twimg.com" - <> " https://gist.github.com" - <> " https://*.google-analytics.com" - , "img-src 'self' https: data: platform.twitter.com" - , "font-src 'self' data: https://use.typekit.net" - <> " https://cdn.mathjax.org" - , "style-src 'self' 'unsafe-inline' https://use.typekit.net" - <> " platform.twitter.com https://assets-cdn.github.com" - , "frame-src https://www.youtube.com https://www.slideshare.net" - <> " staticxx.facebook.com www.facebook.com" - ] - --- | De-indefify middleware. --- Redirects any path ending in `/index.html` to just `/`. -deindexifyMiddleware :: Middleware -deindexifyMiddleware app req sendResponse = - if lastMay (pathInfo req) == Just "index.html" - then sendResponse $ redirectTo newPath - else app req sendResponse - where - newPath :: BS.ByteString - newPath = TE.encodeUtf8 $ processPath oldPath - - processPath :: [T.Text] -> T.Text - processPath xs = case xs of - [] -> "/" - _ -> T.concat $ map prefixSlash xs - - oldPath :: [T.Text] - oldPath = init $ pathInfo req - - prefixSlash :: T.Text -> T.Text - prefixSlash x = T.concat ["/", x] - --- | Serves a WAI Application on the specified port. --- The target port is printed to stdout before hand, which can be useful for --- debugging purposes. -listen :: Int -> Application -> IO () -listen port app = do - let settings = setHost "*6" (setPort port defaultSettings) - - -- Inform which port we will be listening on. - putStrLn $ "Listening on port " ++ show port ++ "..." - -- Serve the WAI app using Warp - runSettings settings app - --- | Serves a WAI Application on the specified port. --- The target port is printed to stdout before hand, which can be useful for --- debugging purposes. -listenTLS :: Int -> Application -> IO () -listenTLS port app = do +import Control.Lens +import Data.Monoid ((<>)) +import Data.Maybe (fromMaybe) +import System.Environment (lookupEnv) +import Hakyll.Serve.Main (TLSConfiguration(..), Stage(..), + defaultServeConfiguration, port, stage, middleware, stagingTransform, + tlsConfiguration, tlsPort, prodTransform, path, serve) +import Hakyll.Serve.Middleware (Directive(..), (<#>), gzipMiddleware, + domainMiddleware, securityHeadersMiddleware, stsHeadersMiddleware, + cspHeadersMiddleware, deindexifyMiddleware, forceSSLMiddleware, + loggerMiddleware) +import Hakyll.Serve.Listeners (TLSSettings, tlsSettingsChain) + +directives :: [Directive] +directives + = [ DefaultSrc ["'self'"] + , ScriptSrc [ + "'self'", "'unsafe-inline'", "https://use.typekit.net", + "https://cdn.mathkax.org", "https://connect.facebook.net", + "https://*.twitter.com", "https://cdn.syndication.twimg.com", + "https://gist.github.com" + ] + , ImgSrc ["'self'", "https:", "data:", "platform.twitter.com"] + , FontSrc [ + "'self'", "data:", "https://use.typekit.net", "https://cdn.mathjax.org" + ] + , StyleSrc [ + "'self'", "'unsafe-inline'", "https://use.typekit.net", + "platform.twitter.com", "https://assets-cdn.github.com" + ] + , FrameSrc [ + "https://www.youtube.com", "https://www.slideshare.net", + "staticxx.facebook.com", "www.facebook.com" + ] + ] + +getTLSSettings :: IO TLSSettings +getTLSSettings = do certPath <- lookupEnv "BLOG_TLS_CERT" chainPath <- lookupEnv "BLOG_TLS_CHAIN" keyPath <- lookupEnv "BLOG_TLS_KEY" - let tlsSettings = tlsSettingsChain - (fromMaybe "cert.pem" certPath) - [fromMaybe "fullchain.pem" chainPath] - (fromMaybe "privkey.pem" keyPath) - let settings = setHost "*6" (setPort port defaultSettings) - - -- Inform which port we will be listening on. - putStrLn $ "Listening on port " ++ show port ++ " (TLS)..." - -- Serve the WAI app using Warp - runTLS tlsSettings settings app + return $ tlsSettingsChain + (fromMaybe "cert.pem" certPath) + [fromMaybe "fullchain.pem" chainPath] + (fromMaybe "privkey.pem" keyPath) -- | The entry point of the server application. main :: IO () main = do - stage <- lookupEnv "BLOG_STAGE" - path <- lookupEnv "BLOG_PATH" - - let liveMiddleware = logStdout - $ cspHeadersMiddleware - $ headersMiddleware - $ domainMiddleware - $ forceSSL - $ deindexifyMiddleware - $ gzipMiddleware - $ staticSite path - - -- Depending on the stage we will choose a different set of middleware to - -- apply to the application. - case fromMaybe "dev" stage of - -- "Production" - "live" -> do - forkIO $ listenTLS 443 $ stsHeadersMiddleware liveMiddleware - listen 80 liveMiddleware - "staging" -> do - forkIO $ listenTLS 8443 liveMiddleware - listen 8080 liveMiddleware - -- "Development" - _ -> listen 9090 (logStdout - $ headersMiddleware - $ deindexifyMiddleware - $ gzipMiddleware - $ staticSite path - ) + rawStage <- lookupEnv "BLOG_STAGE" + rawPath <- lookupEnv "BLOG_PATH" + + tlsSettings <- getTLSSettings + + let liveMiddleware + = mempty + <#> loggerMiddleware + <#> cspHeadersMiddleware directives + <#> securityHeadersMiddleware + <#> domainMiddleware "chromabits" + <#> forceSSLMiddleware + <#> deindexifyMiddleware + <#> gzipMiddleware + let prodMiddlware = (mempty <#> stsHeadersMiddleware) <> liveMiddleware + + let tlsConf = TLSConfiguration (const liveMiddleware) tlsSettings 8443 + + let serveConf + = defaultServeConfiguration + & stage .~ case rawStage of + Just "live" -> Production + Just "staging" -> Staging + _ -> Development + & port .~ 9090 + & middleware .~ mempty + <#> loggerMiddleware + <#> securityHeadersMiddleware + <#> deindexifyMiddleware + <#> gzipMiddleware + & path .~ rawPath + & stagingTransform .~ + ((set tlsConfiguration $ Just tlsConf) + . (set middleware liveMiddleware) + . (set port 8080)) + & prodTransform .~ + ((set tlsConfiguration $ Just (tlsConf & tlsPort .~ 443)) + . (set middleware prodMiddlware) + . (set port 80)) + + serve serveConf diff --git a/site.hs b/site.hs index 31ad74a6ebca7b2aec523a7232851abd4930155e..b3003eca6c1fab0a43958324057f218b41344bc4 100644 --- a/site.hs +++ b/site.hs @@ -3,16 +3,17 @@ import Control.Applicative import Control.Monad (liftM) -import Control.Monad.IO.Class +import Control.Lens ((&), (.~)) import qualified Data.Map as M import Data.Maybe (fromMaybe) -import Data.Monoid (mappend) import Data.List (intersperse, isSuffixOf) import Data.List.Split (splitOn) import Hakyll +import Hakyll.Serve (ServeConfiguration, defaultServeConfiguration, + hakyllConfiguration, hakyllServeWith) import Text.Highlighting.Kate.Styles (haddock) import Text.Pandoc.Options -import System.FilePath (combine, splitExtension, takeFileName) +import System.FilePath (splitExtension) import System.Random (randomRIO) -------------------------------------------------------------------------------- @@ -21,6 +22,9 @@ data SiteConfiguration = SiteConfiguration , siteGaId :: String } +serveConf :: ServeConfiguration +serveConf = defaultServeConfiguration & hakyllConfiguration .~ hakyllConf + -------------------------------------------------------------------------------- hakyllConf :: Configuration hakyllConf = defaultConfiguration @@ -37,18 +41,18 @@ siteConf = SiteConfiguration , siteGaId = "UA-47694260-1" } -feedConf :: String -> FeedConfiguration -feedConf title = FeedConfiguration - { feedTitle = "Chromabits: " ++ title - , feedDescription = "Personal blog" - , feedAuthorName = "Eduardo Trujillo" - , feedAuthorEmail = "ed@chromabits.com" - , feedRoot = "https://chromabits.com" - } +-- feedConf :: String -> FeedConfiguration +-- feedConf title = FeedConfiguration +-- { feedTitle = "Chromabits: " ++ title +-- , feedDescription = "Personal blog" +-- , feedAuthorName = "Eduardo Trujillo" +-- , feedAuthorEmail = "ed@chromabits.com" +-- , feedRoot = "https://chromabits.com" +-- } -------------------------------------------------------------------------------- main :: IO () -main = hakyllWith hakyllConf $ do +main = hakyllServeWith serveConf $ do let writerOptions = defaultHakyllWriterOptions { writerHtml5 = True , writerHighlightStyle = haddock @@ -159,7 +163,7 @@ main = hakyllWith hakyllConf $ do teaser <- loadAndApplyTemplate "templates/project-teaser.html" siteCtx $ dropMore compiled - saveSnapshot "teaser" teaser + _ <- saveSnapshot "teaser" teaser saveSnapshot "content" full >>= loadAndApplyTemplate "templates/default.html" siteCtx diff --git a/stack.yaml b/stack.yaml index e64e9f5cc31ec37a380632a5be582c900a63b0ba..35b9bae3d1bac560f47722d5ada10cc95915deee 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,11 +1,12 @@ # For more information, see: https://github.com/commercialhaskell/stack/blob/release/doc/yaml_configuration.md # Specifies the GHC version and set of packages available (e.g., lts-3.5, nightly-2015-09-21, ghc-7.10.2) -resolver: lts-5.2 +resolver: lts-5.11 # Local packages, usually specified by relative directory name packages: - '.' +- '/home/etcinit/src/github.com/etcinit/hakyll-serve' # Packages to be pulled from upstream that are not in the resolver (e.g., acme-missiles-0.3) extra-deps: []