<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Archonic</title>
    <description>The latest articles on DEV Community by Archonic (@archonic).</description>
    <link>https://dev.to/archonic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F91143%2F267944a7-e135-4b16-bc92-6dd8180b270e.jpeg</url>
      <title>DEV Community: Archonic</title>
      <link>https://dev.to/archonic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/archonic"/>
    <language>en</language>
    <item>
      <title>CI/CD using GitHub Actions for Rails and Docker</title>
      <dc:creator>Archonic</dc:creator>
      <pubDate>Wed, 31 Aug 2022 23:36:15 +0000</pubDate>
      <link>https://dev.to/archonic/cicd-using-github-actions-for-rails-and-docker-35hh</link>
      <guid>https://dev.to/archonic/cicd-using-github-actions-for-rails-and-docker-35hh</guid>
      <description>&lt;p&gt;I've spent far too long doing something I technically didn't need, so now I'm going to spend more time writing an article about it 😉. Once upon a time, I was running tests on CodeShip. It was meh. It wasn't really designed for parelleization (at the time), it was slow as heck, I would get transient failures when Elasticsearch threw a fit and the results were just spat out in one huge text blob. I moved to CircleCI.&lt;/p&gt;

&lt;p&gt;Moving from Codeship to CircleCI was relatively easy. The interface on CircleCI was more responsive, it parsed the RSpec test results better and parallelization was easy to implement. I still managed to find things I didn't like: The parallel runners had lopsided timing which refused to update and worst of all, there was no way to have it notify when all parallel workers had either succeeded or failed. I could only get a pass or fail notification for each worker, meaning that each push to GitHub would result in &lt;strong&gt;10&lt;/strong&gt; Slack notifications 😱. I burned a few hours trying to fix that but only found plenty of posts saying it wasn't possible. I decided to switch services when I could find the time.&lt;/p&gt;

&lt;p&gt;When GitHub Actions was announced, it seemed a more attractive option since I could have both a git repo and CI/CD infra in one place. Oh and it was free. But would the switch be worth it? Would I be able to parallelize GitHub Actions, how fast would it be, how long would it take to setup, would I be able to finally stop being bombarded by 1 Slack notification per parallel worker? So many questions and only one way to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Setup
&lt;/h2&gt;

&lt;p&gt;My previous setup involved a &lt;em&gt;lot&lt;/em&gt; of building images and a &lt;em&gt;lot&lt;/em&gt; of waiting.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;[Manual]&lt;/strong&gt; Build the image locally, reach some working state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Optional]&lt;/strong&gt; Push the image to Google container registery (GCR) if there were changes to the OS or apk/apt packages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Manual]&lt;/strong&gt; Push to GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Automatic]&lt;/strong&gt; CircleCI pulls the latest image from GCR, pulls the branch or latest master commit from GitHub, re-runs bundle and yarn, precompiles assets, then runs RSpec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Manual]&lt;/strong&gt; If all 10 workers reported tests passing (&lt;em&gt;eye twitch&lt;/em&gt;), then I would trigger a deploy locally by running &lt;code&gt;git push hostname:master&lt;/code&gt; where &lt;code&gt;hostname&lt;/code&gt; is the git remote of my production server where &lt;a href="https://dokku.com/" rel="noopener noreferrer"&gt;Dokku&lt;/a&gt; is configured. This would build the image from scratch &lt;strong&gt;again&lt;/strong&gt;, then deploy that image.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If that sounds wasteful, that's because it is. There was utility in CircleCI pulling from GCR then rerunning steps - I only needed to push/pull when there were changes beneath the bundle layer, which was only OS and APT/APK library dependencies. Having to remember to push to GCR for the CircleCI tests to be valid did trip me up a few times though.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dream
&lt;/h2&gt;

&lt;p&gt;I had previously setup a GitHub Action to build the image and do nothing else with it. This simple sanity check is actually quite useful. It's possible after all to push a Dockerfile which can't build and it helped me troubleshoot an issue where building worked on my Mac, on Windows with Ubuntu WSL but not on Ubuntu (thanks Docker), which was required to deploy using Dokku. Getting that build action working was incredibly simple, but now was the time to see if I could reach the holy land: one repository service and CI/CD to rule them all.&lt;/p&gt;

&lt;p&gt;I won't lie, getting tests running on GH Actions was rough. This took a lot of guess and check and waiting, hence why I wrote an article to save you from my torment! You're welcome. "Build once use everywhere" is the dream, but seeing as there can be differences based on where we build (&lt;em&gt;cough&lt;/em&gt; thanks again Docker), I'm going to edit "the dream" to be build once use everywhere once we push to GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Setup
&lt;/h2&gt;

&lt;p&gt;The new workflow should look like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;[Manual]&lt;/strong&gt; Build the image locally, reach some working state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Manual]&lt;/strong&gt; Push to GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Automatic]&lt;/strong&gt; GitHub Actions workflow begins. This builds the image &lt;strong&gt;utilizing the GHA docker layer cache&lt;/strong&gt; and runs rspec on that image using docker-compose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Automatic]&lt;/strong&gt; If this succeeds, push the &lt;strong&gt;already built&lt;/strong&gt; image to ghcr.io&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Automatic]&lt;/strong&gt; Conditionally deploy to production by instructing Dokku to pull the image from ghcr.io.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is much more automated and depending on how warm the various caches are, could be faster. In this article I'm not going to cover my attempt to parallelize RSpec within GitHub Actions. The important part is that after pushing to GH, we can go all the way to production automatically if everything went well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problems encountered were &lt;em&gt;many&lt;/em&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1 - Lack of instructional resources for this particular use-case
&lt;/h3&gt;

&lt;p&gt;There's lots of GHA resources out there but I couldn't find any which build the image, then run tests on the output of the build. They all seem to be content to run tests on an ubuntu-latest stack, then build their own Dockerfile and assume the differences are irrelevant. My app requires a multipart Dockerfile build for tests to pass.&lt;br&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; The article you're currently reading!&lt;/p&gt;
&lt;h3&gt;
  
  
  2 - Lack of configurability with the &lt;code&gt;container&lt;/code&gt; statement
&lt;/h3&gt;

&lt;p&gt;You can run your steps on a Docker image of your choosing with the &lt;code&gt;container&lt;/code&gt; statement, but good luck running on a dynamic tag such as &lt;code&gt;pr-123&lt;/code&gt;. The tags output which is typically available with &lt;code&gt;${{ steps.meta.outputs.tags }}&lt;/code&gt; is not available in the container statement. There's also no access to &lt;code&gt;${{ github.ref }}&lt;/code&gt; or even the &lt;code&gt;env&lt;/code&gt;. That means I can't use the container statement 🤷‍♂️.&lt;br&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; Instead of uploading the image to a container registry only to pull it and run tests on it, it's better to only push valid images to the container registry and eliminate the network bottleneck. That means using &lt;code&gt;load:true&lt;/code&gt; in the build step then just using docker-compose to run on a locally available image.&lt;/p&gt;
&lt;h3&gt;
  
  
  3 - Providing env to docker without duplication
&lt;/h3&gt;

&lt;p&gt;Providing the environment to the &lt;code&gt;ubuntu-latest&lt;/code&gt; layer was easy by using &lt;code&gt;env&lt;/code&gt; in the workflow yaml file and &lt;code&gt;${{ secrets.EXAMPLE }}&lt;/code&gt; but providing the env to the &lt;code&gt;docker run&lt;/code&gt; command without a lot of duplication was a challenge. It has a very easy solution but it comes with a gotcha (see 4).&lt;br&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; Just dump the env to the .env file that docker compose is expecting. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-
  name: Run tests
  run: |
    env &amp;gt; .env
    docker-compose run web rails db:setup 
    docker-compose run web bundle exec rspec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4 - Commands just didn't work. Like, any of them.
&lt;/h3&gt;

&lt;p&gt;bundle, rails, rake, they all said &lt;code&gt;/usr/bin/env: ‘ruby’: No such file or directory&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; This one was tricky and it sure seemed like GHA was out to get me but I actually did it to myself with step 3. The $PATH variable in the base &lt;code&gt;ubuntu-latest&lt;/code&gt; environment won't suite your Dockerfile environment and it was just overridden. Check your &lt;code&gt;$PATH&lt;/code&gt; in your local environment with something like &lt;code&gt;docker-compose run web echo $PATH&lt;/code&gt; and make sure those paths are in your GH workflow yaml under &lt;code&gt;$PATH&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  5 - Elasticsearch takes a long time to boot
&lt;/h3&gt;

&lt;p&gt;Elasticsearch not being fully initialized before running the database seed step was causing this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Faraday::Error::ConnectionFailed: Couldn't connect to server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was never an issue locally, but locally I would typically run &lt;code&gt;docker-compose exec web rspec&lt;/code&gt; after running &lt;code&gt;docker-compose up&lt;/code&gt; which gives Elasticsearch plenty of time to boot.&lt;br&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; It was finally time to setup a proper healthcheck in docker-compose.yml. Here's my elasticsearch service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;elasticsearch:
  container_name: elasticsearch
  image: docker.elastic.co/elasticsearch/elasticsearch:7.4.2
  environment:
    - discovery.type=single-node
    - cluster.name=docker-cluster
    - bootstrap.memory_lock=true
    - "ES_JAVA_OPTS=-Xms1024m -Xmx1024m"
    - "logger.org.elasticsearch=error"
  healthcheck:
    test: curl --fail elasticsearch:9200/_cat/health &amp;gt;/dev/null || exit 1
    interval: 30s
    timeout: 10s
    retries: 5
  ulimits:
    memlock:
      soft: -1
      hard: -1
  volumes:
    - esdata:/usr/share/elasticsearch/data
  ports:
    - 9200:9200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You may need to change your healthcheck test depending on your version of ES. This requires a corresponding &lt;code&gt;depends_on&lt;/code&gt; statement in the web container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;depends_on:
  elasticsearch:
    condition: service_healthy
  postgres:
    condition: service_started
  redis:
    condition: service_started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It seems odd to me that you have to state that explicitly after defining a healthcheck statement. Maybe docker compose 4 will do it automatically. I ran into the same issue with Redis and the solution to that is in docker-ci.yml below.&lt;/p&gt;

&lt;h3&gt;
  
  
  6 - Test failures due to an apparent asset compilation issue
&lt;/h3&gt;

&lt;p&gt;As this point I had tests running and some passing, which was a huge relief. But all request tests were failing with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ActionView::Template::Error:
Webpacker can't find application.js in /app/public/packs-test/manifest.json. Possible causes:
1. You want to set webpacker.yml value of compile to true for your environment
  unless you are using the `webpack -w` or the webpack-dev-server.
2. webpack has not yet re-run to reflect updates.
3. You have misconfigured Webpacker's config/webpacker.yml file.
4. Your webpack configuration is not creating a manifest.
Your manifest contains:
{
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a surprising because we just baked the assets into the image on the build step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Tip o' the hat to &lt;a href="https://danielabaron.me/blog/debug-github-action/" rel="noopener noreferrer"&gt;Daniela Baron&lt;/a&gt; here, there's a real life saver of tool call &lt;a href="https://github.com/mxschmitt/action-tmate" rel="noopener noreferrer"&gt;tmate&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Insert this and you basically get a debug statement in your GH workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- # Creates a SSH tunnel!
  name: Setup tmate session
  uses: mxschmitt/action-tmate@v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just wait for the step and copy the &lt;code&gt;ssh&lt;/code&gt; statement to your terminal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fixktjfvmmpykgjl28du7.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fixktjfvmmpykgjl28du7.gif" alt="Master must go inside the tunnel"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This was a heck of an issue to troubleshoot. I've always baked production assets into the image. I've never had a missing assets error unless I'm messing with webpack configuration. This setup has always worked for all environments that have ever relied on it - Codeship, CircleCI and 2 very different production environments.&lt;/p&gt;

&lt;p&gt;Somehow, running this shows a populated manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker run web cat public/packs/manifest.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but this says no such file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker-compose run web cat public/packs/manifest.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;... wat? 🤔. How could the assets be obviously present in the image and then vanish when using &lt;code&gt;docker-compose&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;As with all things computers, this was another case of us shooting ourselves in the foot. Docker Compose mounts the volumes that you tell it to with a statement like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;volumes:
  - ".:/app"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That takes the current directory (&lt;code&gt;.&lt;/code&gt;) and mounts it into the &lt;code&gt;/app&lt;/code&gt; directory within the image. This is what ends up writing a folder with no compiled assets into the folder that already had assets compiled. This wasn't noticable on Codeship or CircleCI because they had an explicit asset precompilation step. I thought of 3 ways to solve this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;a href="https://github.com/marketplace/actions/docker-run-action" rel="noopener noreferrer"&gt;addnab/docker-run-action&lt;/a&gt; and give up on Docker Compose altogether 😅.&lt;/li&gt;
&lt;li&gt;Just recompile the assets despite having just compiled them. Easy but wasteful.&lt;/li&gt;
&lt;li&gt;Write a docker-compose.yml specifically for CI which does not mount local files.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I tried 1, &lt;a href="https://github.com/addnab/docker-run-action/issues/25#issuecomment-1212601441" rel="noopener noreferrer"&gt;had difficulty&lt;/a&gt; passing in the env and trouble with connections to &lt;code&gt;services&lt;/code&gt;. Then I tried 2 and it worked but it was &lt;em&gt;super&lt;/em&gt; slow. 3 seems to be the right approach and it brought runtime down from 31m to 12m!&lt;/p&gt;

&lt;h2&gt;
  
  
  Without further ado
&lt;/h2&gt;

&lt;p&gt;Here is the yamls you've been waiting for.&lt;/p&gt;

&lt;p&gt;docker-ci.yml&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;version: "3.7"
services:
  postgres:
    image: "postgres:14-alpine"
    environment:
      POSTGRES_USER: "example"
      POSTGRES_PASSWORD: "example"
    ports:
      - "5432:5432"
    volumes:
      - "postgres:/var/lib/postgresql/data"
  redis:
    image: "redis:5-alpine"
    command: ["redis-server", "--requirepass", "yourpassword", "--appendonly", "yes"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "6379:6379"
    volumes:
      - redis:/data
    sysctls:
      # https://github.com/docker-library/redis/issues/35
      net.core.somaxconn: "511"
  sidekiq:
    depends_on:
      - "postgres"
      - "redis"
      - "elasticsearch"
    build:
      context: .
      args:
        environment: development
    image: you/yourapp
    command: bundle exec sidekiq -C config/sidekiq.yml.erb
    volumes:
      - ".:/app"
      # don"t mount tmp directory
      - /app/tmp
    env_file:
      - ".env"
  web:
    build:
      context: .
      args:
        environment: development
    image: you/yourapp
    command: bundle exec rspec
    depends_on:
      elasticsearch:
        condition: service_healthy
      postgres:
        condition: service_started
      redis:
        condition: service_healthy
    tty: true
    stdin_open: true
    ports:
      - "3000:3000"
    env_file:
      - ".env"
  elasticsearch:
    container_name: elasticsearch
    image: docker.elastic.co/elasticsearch/elasticsearch:7.4.2
    environment:
      - discovery.type=single-node
      - cluster.name=docker-cluster
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms1024m -Xmx1024m"
      - "logger.org.elasticsearch=error"
    healthcheck:
      test: curl --fail elasticsearch:9200/_cat/health &amp;gt;/dev/null || exit 1
      interval: 30s
      timeout: 10s
      retries: 5
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - esdata:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
volumes:
  redis:
  postgres:
  esdata:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;main.yml&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name: Main

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  build:
    name: Build, Test, Push
    runs-on: ubuntu-20.04
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
      POSTGRES_USER: example
      POSTGRES_PASSWORD: example
      POSTGRES_HOST: postgres
      # Humour me here, this needs to be production for the sake of baking assets into the image
      RAILS_ENV: production
      NODE_ENVIRONMENT: production
      ACTION_MAILER_HOST: localhost:3000
      REDIS_URL: redis://redis:yourpassword@redis:6379
      DATABASE_URL: postgresql://example:example@postgres:5432/dbname?encoding=utf8&amp;amp;pool=5&amp;amp;timeout=5000
      ELASTICSEARCH_URL: elasticsearch:9200
      # Actually secret secrets
      EXAMPLE_KEY: ${{ secrets.EXAMPLE_KEY}}
      # Append our own PATHs because env &amp;gt; .env nukes it!
      PATH: /usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/local/bundle/bin:/usr/local/bundle/gems/bin:/usr/lib/fullstaq-ruby/versions/3.0.4-jemalloc/bin:/app/bin
    steps:
      -
        name: Checkout repo
        uses: actions/checkout@v3
        with:
          fetch-depth: 1

      - # Not required but recommend to be able to build multi-platform images, export cache, etc.
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      -
        name: Log in to the Container registry
        uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      -
        name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v3
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: ${{ steps.extract_branch.outputs.branch }}

      -
        name: Build with Docker
        uses: docker/build-push-action@v3
        with:
          context: .
          load: true
          cache-from: type=gha
          cache-to: type=gha,mode=max
          tags: |
            ${{ steps.meta.outputs.tags }}
            ${{ github.repository }}:latest

      # - # Creates a SSH tunnel!
      #   name: Setup tmate session
      #   uses: mxschmitt/action-tmate@v3

      -
        name: Run tests
        run: |
          env &amp;gt; .env
          ./run-tests.sh

      - # NOTE Building in this step will use cache
        name: Build and push Docker image
        uses: docker/build-push-action@v3
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;run-tests.sh&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Uses docker-compose to run tests on GitHub Actions

# Exit if a step fails
set -e

echo "============== DB SETUP"
docker-compose -f docker-ci.yml run web rails db:reset RAILS_ENV=test

echo "============== RSPEC"
docker-compose -f docker-ci.yml up --abort-on-container-exit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusions (for now)
&lt;/h2&gt;

&lt;p&gt;As you can see, GitHub Actions is more freehand than most CI services. They basically say "here's Ubuntu, have fun". Running the image you have built is possible but they certainly don't hold your hand. If you know of a GitHub Action that does hold your hand with regards to running tests on the image that was just built, sound off in the comments!&lt;/p&gt;

&lt;p&gt;If you have any suggestions for improvements, I'm all ears, although I can't say I'm very eager to make changes to this workflow any time soon 😫.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Was I able to parallelize GitHub Actions?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No. Just running on the built image was a real challenge. If you know how to parallelize GHA using docker-compose, let me know in the comments!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How long did it take to set up this GHA?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Too long. Embarrassingly long. But I saved you time, right?&lt;/p&gt;

&lt;p&gt;... right?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How fast is it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This comparison is not even close to fair. Locally and using CircleCI, I'm using an already built image and CircleCI has 10 parallel workers.&lt;/p&gt;

&lt;p&gt;CircleCI (warm): 4m20s 🪴&lt;br&gt;
Local (warm): 10m44s&lt;br&gt;
GHA (warm): 12m42s&lt;/p&gt;

&lt;p&gt;I am actually impressed with the time GHA gets considering that it's building the image, bundling (with a warm cache), precompiling assets and pushing the image to ghcr.io.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Was the switch worth it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Right now, no. The amount of time it took to set this up would take a &lt;em&gt;very&lt;/em&gt; long time to pay off. 12m vs 4m is not an improvement, although it's a big benefit to not have to think about when I should be pushing a fresh image to our container registry and it will be a huge benefit to be able to deploy (without having to rebuild) automatically.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Was I able to finally stop being bombarded by 1 Slack notification per parallel worker? &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yes! But I also went back to a serial workflow with just one worker. I could have easily specified one worker in CircleCI and got the same result.&lt;/p&gt;

&lt;p&gt;Stay tuned for deploy.yml for the Dokku portion of things, when I get around to writing it!&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Notes!
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You probably don't want to run deploy steps on every push to master! You might want to only run &lt;a href="https://github.com/orgs/community/discussions/25302#discussioncomment-3247370" rel="noopener noreferrer"&gt;when pushing a new tag&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;If you're copy and pasting, you need to check your PATH and write that in to main.yml. The one I've provided will very likely not work for you.&lt;/li&gt;
&lt;li&gt;Note the lack of a volume for the web container in docker-ci.yml. This is on purpose because we don't want to overwrite the assets that we just precompiled. Not precompiling assets a second time saves a lot of time. This also means you won't be able to access files written during the test (ex. a results.json file) then access them in a later step.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>githubactions</category>
      <category>docker</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Swapping Elasticsearch for Meilisearch in Rails feat. Docker</title>
      <dc:creator>Archonic</dc:creator>
      <pubDate>Thu, 23 Jun 2022 17:50:33 +0000</pubDate>
      <link>https://dev.to/archonic/swapping-elasticsearch-for-meilisearch-in-rails-feat-docker-3g6f</link>
      <guid>https://dev.to/archonic/swapping-elasticsearch-for-meilisearch-in-rails-feat-docker-3g6f</guid>
      <description>&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmiro.medium.com%2Fmax%2F700%2F1%2AGHsNVJCoYiYW6Qf83ZHQZA.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmiro.medium.com%2Fmax%2F700%2F1%2AGHsNVJCoYiYW6Qf83ZHQZA.png" title="Elasticsearch to Meilisearch" alt="A wise move for apps with simple search needs"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Elasticsearch is a comprehensive and highly configurable search engine and storage system for a multitude of app concerns. In this article we’re only going to be comparing it’s search engine capabilities within the context of a Dockerized Ruby on Rails app. If your app has a need for specifically weighted attribute boosting, results that get better with machine learning, mature highly available sharding capabilities, or multi-index searching, Elasticsearch is still what you want.&lt;/p&gt;

&lt;p&gt;If your search needs are somewhere between pg_search/ransack and Elasticsearch, Meilisearch is a new contender which is blazing fast (&amp;lt;50ms), much more resource efficient, has a sensible default configuration, a first-party Ruby library and Rails gem and an admin panel to try out searching before fully integrating within your app. With full text search, synonyms, typo-tolerance, stop words and customizable relevancy rules, Meilisearch has enough features to satisfy most applications — and that’s before their v1.0 release 👏. Multi-index searching is also &lt;a href="https://roadmap.meilisearch.com/tabs/4-in-progress" rel="noopener noreferrer"&gt;on the roadmap&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part Zero: But Why?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmiro.medium.com%2Fmax%2F480%2F1%2A1xVIzjzgJnmuWTJoaG3kAQ.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmiro.medium.com%2Fmax%2F480%2F1%2A1xVIzjzgJnmuWTJoaG3kAQ.gif" alt="Why go through the pain of switching? Performance and resource efficiency!"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First lets compare Elasticsearch and Meilisearch on the item you’re probably here to learn about — resource usage. Memory on the cloud is expensive and Elasticsearch is a known memory hog. On my Rails app which has fairly low usage, it’s using 3.5GB. That’s 2.7GB more than the next highest container which is Rails web workers running malloc instead of jemalloc (a topic for a different article!).&lt;/p&gt;

&lt;p&gt;So how much more efficient is Meilisearch? Let’s get a baseline with Elasticsearch first. We’ll be using &lt;a href="https://docs.meilisearch.com/movies.json" rel="noopener noreferrer"&gt;this movie database&lt;/a&gt; with ~32k rows.&lt;/p&gt;

&lt;p&gt;I have to note here that Elasticsearch took a lot more time to set up. It initially refused to start up because it needed more memory than the OS would allow it to allocate just to start. That limit needed to be expanded with &lt;code&gt;sysctl -w vm.max_map_count=262144&lt;/code&gt;. Then the JSON file needed a fair amount of manipulation because the bulk JSON API expects you to specify the index &lt;strong&gt;for every row&lt;/strong&gt;. This wasn’t evident in the documentation and &lt;a href="https://stackoverflow.com/a/52225439/1686604" rel="noopener noreferrer"&gt;an ancient StackOverflow&lt;/a&gt; answer came to my rescue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker network create elastic
docker run --name es01 --net elastic -p 9200:9200 -p 9300:9300 -it docker.elastic.co/elasticsearch/elasticsearch:8.2.3
curl --location --request POST 'https://localhost:9200/movies/_bulk/' \
--header 'Content-Type: application/x-ndjson' \
--header 'Authorization: Basic ---' \
--data-binary '@movies.json'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; reports that Elasticsearch is using &lt;strong&gt;5.2GB of memory&lt;/strong&gt;. Adding the movies to the index did not increase this — it uses 5.2GB by default with no data. You can of course set &lt;code&gt;ES_JAVA_OPTS&lt;/code&gt; and get that down. Even small apps however risk container evictions due to memory pressure when doing that. This was the main motivator for me to check out Meilisearch.&lt;/p&gt;

&lt;p&gt;Now let’s do the same thing with Meilisearch. It was quite a bit easier to setup and the bulk import was a breeze.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker run --rm -p 7700:7700 -v "$(pwd)/meili_data:/meili_data" getmeili/meilisearch
curl -i -X POST 'http://127.0.0.1:7700/indexes/movies/documents' \
  --header 'content-type: application/json' \
  --data-binary @movies.json
```



Letting Meilisearch run for a few minutes, the memory usage actually halved down to **96.7MB**.

Now let’s run a simple comparison benchmark. We’ll run 100 iterations of `q=batman&amp;amp;limit=10` for Meilisearch and `?q=batman&amp;amp;size=10` for Elasticsearch.

**Elasticsearch: 9.68ms average, 15ms peak.
Meilisearch: 5.17ms average. 11ms peak.**

**Meilisearch used 54.8x less memory and was 46.6% faster than Elasticsearch with the same data and the same queries.**

![That’s a lot faster and a lot easier to host.](https://miro.medium.com/max/480/1*JdDfBF36W6NwIHWthqVCZw.gif)

The image is also 36MB instead of 1.2GB — nice. Note that this is specifically a comparison of **default** configurations. What’s more is Meilisearch has an interface at localhost:7700 so we don’t even need to open Postman to poke around (sorry, no filtering or sorting on the admin interface at the moment).

Convinced? Ok read on and I’ll show you what switching from Elasticsearch to Meilisearch looked like for a real production app — [ScribeHub](https://scribehub.com). We also moved from Ankane’s excellent [Searchkick](https://github.com/ankane/searchkick/) gem to the first party [meilisearch-rails](https://github.com/meilisearch/meilisearch-rails/) gem and I’ll show you the changes there as well.

## Part One: DevOps

Begin by replacing your Elasticsearch container with a Meilisearch container in your docker-compose.yml:



```
meilisearch:
  image: getmeili/meilisearch:v0.27.0
  user: root
  ports:
    - "7700:7700"
  volumes:
    - "meili:/meili_data/"
  env_file:
    - .msenv
...
volumes:
  meili:
```



The first big difference is authentication. Meilisearch supports a direct front-end integration which doesn’t even touch Rails (neat!). That means if a master key is set, it will generate default keys with specific permissions on start up. If you’re just trying MS out locally, I recommend not setting the master key so that it will allow unauthenticated requests. If you intend to ship to production, I’d recommend setting the master key to ensure you understand how that works before you’re launching. We won’t be going into front-end only implementations in this article — we’re just going to focus on the ES to MS migration.

Something that almost made me [give up](https://github.com/meilisearch/meilisearch-rails/issues/148) right at the beginning was that the MS service will roll the keys if there is any change to it’s environment file. I kept dropping the default admin key into a common .env file which would roll the keys again and I would get auth errors when trying to reindex. It’s supposed to roll the keys if there’s a change to the master key, but rolling the keys on any change to the env file means **you should have a separate env file for the MS service**. I called it ‘.msenv’ as you can see above. I’ve seen it roll the keys even when there was no change to it’s own env file but that was a result of not mounting to the /meili_data directory.

If you’re setting a master key, run `SecureRandom.hex 32` from a Rails console and drop that into `MEILI_MASTER_KEY` in your .msenv file. You can also set the host and turn off anonymous analytics while you’re at it, which I personally think should default to disabled. Here’s my example .msenv:



```
# WARNING
# Every time any change is made to this file, Meilisearch will regenerate keys.
# That will invalidate current keys and make you sad.
MEILISEARCH_HOST=http://meilisearch:7700
MEILI_MASTER_KEY=&amp;lt;YOUR MASTER KEY&amp;gt;
MEILI_NO_ANALYTICS=true
```



Run `docker-compose up` and you should see this in the MS start up output:

&amp;gt; A Master Key has been set. Requests to Meilisearch won’t be authorized unless you provide an authentication key.

Now we’ll need to fetch the default admin API key. Here’s the curl request to fetch keys. I recommend saving the query in Postman or Insomnia so you don’t have to keep looking it up in the future.



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;curl --location --request GET '&lt;a href="http://localhost:7700/keys" rel="noopener noreferrer"&gt;http://localhost:7700/keys&lt;/a&gt;' \&lt;br&gt;
--header 'Authorization: Bearer '&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


Drop the default admin API key into `MEILISEARCH_API_KEY` in your Rails .env file and set `MEILISEARCH_HOST` to the same thing you set it to in .msenv so that’s available on the Rails side as well. Time to write your Meilisearch initializer file! You can tune timeouts and retries while you’re at it.



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;MeiliSearch::Rails.configuration = {&lt;br&gt;
  meilisearch_host: ENV['MEILISEARCH_HOST'],&lt;br&gt;
  meilisearch_api_key: ENV['MEILISEARCH_API_KEY'],&lt;br&gt;
  timeout: 1,&lt;br&gt;
  max_retries: 2&lt;br&gt;
}&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


Restart everything to pick up the environment changes and you should now be able to reindex a model in terms of permissions. But first we need a model to reindex.

## Part Deux: Rails Integration

This is where my path and yours differ, but I’ll provide an example model integration. Because ScribeHub has many searchable resources, I wrote a concern. schema_searchable.rb:



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;module SchemaSearchable&lt;br&gt;
  extend ActiveSupport::Concern&lt;br&gt;
  included do&lt;br&gt;
    include MeiliSearch::Rails&lt;br&gt;
    extend Pagy::Meilisearch&lt;br&gt;
  end&lt;br&gt;
  module ClassMethods&lt;br&gt;
    def trigger_sidekiq_job(record, remove)&lt;br&gt;
      MeilisearchEnqueueWorker.perform_async(record.class.name, record.id, remove)&lt;br&gt;
    end&lt;br&gt;
  end&lt;br&gt;
end&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


This DRYed things more with Elasticsearch but I’ll take all the code reduction I can get. Now you can drop `include SchemaSearchable` into any searchable model. Here’s an example of additions to our GlossaryTerm model:



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;include SchemaSearchable&lt;br&gt;
after_touch :index!&lt;/p&gt;

&lt;p&gt;meilisearch enqueue: :trigger_sidekiq_job, per_environment: true, primary_id: :ms_id do&lt;br&gt;
  attributes [:account_id, :id, :term, :definition, :updated]&lt;br&gt;
  attribute :updated do&lt;br&gt;
    updated_at.to_i&lt;br&gt;
  end&lt;br&gt;
  filterable_attributes [:account_id]&lt;br&gt;
end&lt;/p&gt;

&lt;p&gt;def ms_id&lt;br&gt;
  "gt_#{account_id}_#{id}"&lt;br&gt;
end&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


Note that Meilisearch does not have a data type for Ruby or Rails date time objects, so we’re converting it to Unix epoch with `to_i`. `after_touch :index!` keeps your index up to date when the model changes. `per_environment: true` will ensure you’re not polluting your development indexes with test data. `enqueue` will run index updates in the background per the method defined in schema_searchable.rb — but we still need that worker. Here is meilisearch_enqueue_worker.rb:



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;class MeilisearchEnqueueWorker&lt;br&gt;
  include Sidekiq::Worker&lt;br&gt;
  def perform(klass, record_id, remove)&lt;br&gt;
    if remove&lt;br&gt;
      klass.constantize.index.delete_document(record_id)&lt;br&gt;
    else&lt;br&gt;
      klass.constantize.find(record_id).index!&lt;br&gt;
    end&lt;br&gt;
  end&lt;br&gt;
end&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


If you’re able to start a fresh Rails console and run `Model.reindex!` without error, then you’re ready to edit your index action in the controller. Right now using the [active pagy search method](https://ddnexus.github.io/pagy/extras/meilisearch#active-mode) without creating an N+1 query means we need both `pagy_meilisearch` and `pagy_search` like so:



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def index&lt;br&gt;
  @pagy, @glossary_terms = pagy_meilisearch(&lt;br&gt;
    GlossaryTerm.includes(GlossaryTerm.search_includes).pagy_search(&lt;br&gt;
      params[:q],&lt;br&gt;
      **{&lt;br&gt;
        filter: "account_id = #{current_account.id}"&lt;br&gt;
      }&lt;br&gt;
    )&lt;br&gt;
  )&lt;br&gt;
end&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


The `search_includes` method on GlossaryTerm is just a list of associations needed to avoid N+1 queries. I like keeping that in the model:



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def self.search_includes&lt;br&gt;
  %i(&lt;br&gt;
    user&lt;br&gt;
  )&lt;br&gt;
end&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


Assembling the filter string can get tricky compared to Elasticsearch due to it being a string instead of a hash but it lets you assemble the logic with as many `AND` and `OR`’s as your heart desires. For things like filtering by tags with AND logic, you’ll need to do something like this:



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;filter = "discarded=false"&lt;br&gt;
if @conditions.key?(:tags)&lt;br&gt;
  @conditions[:tags].each do |tag|&lt;br&gt;
    filter += " AND tags='#{tag}'"&lt;br&gt;
  end&lt;br&gt;
end&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


In this case `@conditionals` is a hash which is populated by processing the query to extract things like tags and sort keys. The documentation has some [helpful notes about combining logic](https://docs.meilisearch.com/learn/advanced/filtering_and_faceted_search.html#filter-expressions).

Fixing up the tests should be all that remains and it’s pretty much just changing `index` for `index!` and `search_index.delete` for `clear_index!`. It was very cool seeing the tests pass again after such minimal test fixing.

Hope you enjoyed! We certainly did here at [ScribeHub](https://scribehub.com) and we eagerly await multi-index searching 😉.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>rails</category>
      <category>elasticsearch</category>
      <category>meilisearch</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
