Serverless function cold start is too slow, let's dockerize our app
Romain Trotard
Posted on June 28, 2023
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
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
Then, we pass this argument as an environment variable:
ENV DB_URL=${DB_URL}
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:
- Get the previous version and increment it.
- Build and push the Docker image.
- Create a Github tag with the version and push it.
- 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
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}
Then, the new version is calculated by incrementing the latest version:
NEW_VERSION=$(($LATEST_VERSION+1))
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 }}
I then set up Docker using docker/setup-buildx-action@v2
:
- name: Set up Docker
uses: docker/setup-buildx-action@v2
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 }}
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"
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 }}
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:
- Pulls the latest image.
- Stops the existing container (if any).
- Deletes the previous container (if any).
- 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 }}
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 ☕
Posted on June 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.