DEV Community

Cover image for Serverless function cold start is too slow, let's dockerize our app
Romain Trotard
Romain Trotard

Posted on • Edited on • Originally published at romaintrotard.com

Serverless function cold start is too slow, let's dockerize our app

I developed an application that I used to deploy on Vercel.
But, to deliver the best image based on the user's device, I decided to add runtime image processing, which makes it impossible for me to deploy on edge functions.
So I tried on serverless function but cold start is really really slow. Sometimes, I would experience a 5-second delay in loading my page even though I didn't have a heavy resource load. This was mainly due to the fact that I didn't have enough traffic on my website to avoid cold starts.

Note: "Cold start" typically refers to the initial startup time of a serverless function or application when there has been no recent traffic.

The solution I found was to dockerize my application and deploy it on my own server.


Dockerfile

I won't explain what Docker is since there are numerous excellent tutorials available on the web. However, to dockerize an application, the first step is to create a Dockerfile where we specify the necessary instructions. Personally, I use Astro, so here's an example of its content:

FROM node:18.16.0 AS runtime
WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

ENV HOST=0.0.0.0
ENV PORT=8888
EXPOSE 8888
CMD node ./dist/server/entry.mjs
Enter fullscreen mode Exit fullscreen mode

Note: You can find this code on the astro doc Build your Astro Site with Docker.


And that's not all.

My application requires some environment variables, so let's add one named DB_URL. The first thing to do is to declare that we will receive an ARG from outside:

ARG DB_URL
Enter fullscreen mode Exit fullscreen mode

Then, we pass this argument as an environment variable:

ENV DB_URL=${DB_URL}
Enter fullscreen mode Exit fullscreen mode

Now, let's talk about the deployment workflow.


Deployment workflow

The deployment is done through a Github Action Workflow named deploy. I trigger this workflow each time I push to the master branch, which performs the following steps:

  1. Get the previous version and increment it.
  2. Build and push the Docker image.
  3. Create a Github tag with the version and push it.
  4. Deploy the Docker image to my server.

Get the previous version and increment it

Instead of retrieving the latest image name from my registry, I push the version as a git tag. This way, I only need to fetch the latest tag, which is much easier and more efficient. Furthermore, I know the content each docker image thanks to that.

The git command to achieve this is straightforward:

LATEST_TAG=git describe --abbrev=0 --tags
Enter fullscreen mode Exit fullscreen mode

And now let's increment it. But before doing it, let's see how is structured my tag.

As I push a new version each time I push on master I decided not to use semver but just have vVersionNumber.

Note: If I have released my app manually, I would have chose to use semver, and chose the right version major / minor / patch when launching the Github Action Workflow.

To extract the number from the tag, I simply remove the leading 'v':

LATEST_VERSION=${LATEST_TAG#v}
Enter fullscreen mode Exit fullscreen mode

Then, the new version is calculated by incrementing the latest version:

NEW_VERSION=$(($LATEST_VERSION+1))
Enter fullscreen mode Exit fullscreen mode

Whole version step
- name: Get new version
  id: newVersion
  run: |
    PREVIOUS_VERSION=$(git describe --abbrev=0 --tags)
    echo "Previous Version: $PREVIOUS_VERSION"
    LATEST_VERSION=${LATEST_TAG#v}
    NEW_VERSION=$(($LATEST_VERSION+1))
    echo "New Version: $NEW_VERSION"
    echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"

Now it's time to build the image and push it on the registry.


Build and push docker image

I use Docker Hub as my registry, but you can use the one you prefer.
To log in to Docker Hub, I utilize the docker/login-action@v2 Github Action and retrieve my credentials from Github Secrets:

- name: Login to Docker Hub
  uses: docker/login-action@v2
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

I then set up Docker using docker/setup-buildx-action@v2:

- name: Set up Docker
  uses: docker/setup-buildx-action@v2
Enter fullscreen mode Exit fullscreen mode

An now let's build our image thanks to docker/build-push-action@v4:

- name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          build-args: |
            "DB_URL=${{ secrets.DB_URL }}"
          push: true
          tags: astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
Enter fullscreen mode Exit fullscreen mode

Note: As you can see, I didn't forget to pass my DB_URL env variable that I get from Github secrets.

Now that the docker image is created and pushed, I can create the git tag and push it for future releases.


Create and push git tag

The first step is to set up the git config. Personally, I've chosen to use a fake "GitHub Actions" user for this purpose:

- name: Setup git config
  run: |
    git config --local user.name "GitHub Action"
    git config --local user.email "githubAction@users.noreply.github.com"
Enter fullscreen mode Exit fullscreen mode

And now I can easily create and push the tag.

- name: Create and push git tag
  run: |
    git tag v${{ steps.newVersion.outputs.NEW_VERSION }}
    git push origin v${{ steps.newVersion.outputs.NEW_VERSION }}
Enter fullscreen mode Exit fullscreen mode

We are nearing the end of this workflow. Now it's time to deploy the Docker image to the server.


Deploy the docker image on my server

My server is already configured with a Docker registry, eliminating the need for logging in during the GitHub Actions workflow.

To accomplish this, I utilize the appleboy/ssh-action@v0.1.10 action, which connects to the server and performs the following steps:

  1. Pulls the latest image.
  2. Stops the existing container (if any).
  3. Deletes the previous container (if any).
  4. Runs a new container with the freshly pulled image.
- name: Deploy docker image to server
  uses: appleboy/ssh-action@v0.1.10
  with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      docker pull astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
      docker stop web || true
      docker rm web || true
      docker run -d -p 8888:8888 --restart unless-stopped --name web astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
Enter fullscreen mode Exit fullscreen mode

Note: This step is not perfect, as it lacks error recovery. However, for my specific use case, this limitation is not problematic since it involves a small website. In the event of any issues during deployment, I can address them manually.


Conclusion

As I mentioned previously, while my workflow may not be perfect, it is perfectly suited for my needs. With this workflow, I have eliminated cold starts and achieved an incredibly fast First Contentful Paint. It has been a significant improvement for my website.

In the future, I may consider using Ansible to enhance the process and make it more robust. Ansible's configuration management capabilities would allow me to automate and standardize the deployment process further. This would provide better error recovery and ensure consistency across deployments.

Overall, I'm satisfied with the current workflow, but I'm always open to exploring new tools and techniques to optimize my development and deployment processes.

If you want to see the whole Github Action Workflow, you can see my gist.


Feel free to comment and provide feedback. If you'd like to stay updated, you can follow me on Twitch or visit my Website. If you appreciate my work, you can also support me by buying me a coffee using this link

Top comments (0)