TLDR
Cloud Run is a great platform to host your static site. The article describes the process of setting up one with focus on best practices and simplicity.
Hosting gmarik.info
My blogging setup had changed couple of times now:
- Blogging with jekyll, rack and heroku for free!
- Hosting static site with App Engine, Cloud Build and Hugo
The last platform was fast and worked well except few things:
- it’s difficult to tune redirects and requires coding
- AppEngine’s app.yaml is too restrictive and was designed for other other workflow
- Too much unnecessary AppEngine’s cruft.
So Cloud Run was an obvious candidate once it was announced:
#CloudRun looks perfect: a container + runtime with per second billing. https://t.co/JA0YuH7xsk
— gmarik (@gmarik) April 10, 2019
Requirements
Along with previous requirements :
https
supportgit push
style deploys- custom domain
- support for various static site compilers
- cheap
- fast
I wanted to:
- apply observability principles to the static site: monitor 404s
- have flexibility with redirects and maintaining legacy urls
- have a way to to filter out annoying scanners and what not
So with new the stack, Cloud Build takes care of:
Cloud Run takes care of:
- apply observability principles to the static site: monitor 404s with logging
https
support(it’s non-optional)- custom domain
- fast: warmup time for the container is almost unnoticeable
- cheap
And I went with nginx to take care of:
- have flexibility with redirects and ability to maintain legacy urls
- have a way to to filter out annoying web-scanners
Managing 404s is extremely important to ensure readers find what they’re looking for.
Prerequisites
- setup a GCP project or use an existing one.
- latest
gcloud
(orbeta
withgcloud components install beta
) - for
gcloud
, configure your environment with the project and account:
export CLOUDSDK_CORE_ACCOUNT[email protected]
export CLOUDSDK_CORE_PROJECT=your-project
Plan
- create Docker image and push it to Cloud Registry
- create Cloud Run Service
- continuos deploys with Cloud Build Triggers
- hooking up Hugo
Here’s the initial project’s structure:
$ tree
├── cloudbuild
│ ├── cloudbuild.yaml
│ └── cmd.sh
├── nginx
│ ├── docker-entrypoint.sh
│ ├── etc
│ │ └── nginx
│ │ └── conf.d
│ │ └── site01.conf
│ └── nginx.Dockerfile
└── site01
└── public
└── index.html
Hugo will be added later.
Create Docker Image
To test our image let’s build it locally first:
docker build \
--build-arg SITE=site01 \
-t gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest \
-f nginx/nginx.Dockerfile .
and run:
docker run -it -p 8080:8080 gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest
if everything is ok you should be able to see:
$ curl localhost:8080
<html>
<body>
hello cloud run world
</body>
</html>
Deploy image to Cloud Run
Before Cloud Run can deploy our image it has to be in a registry, so let’s push it to gcr:
$ docker push gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest
The push refers to repository [gcr.io/.../site01]
f1b5933fe4b5: Layer already exists
latest: digest: sha256:300bcf2fba9da6a120693a9edcc53453d135b80e21932df7f3ebdcc45f732fec size: 1567
Conveniently gcloud beta run deploy
creates the Cloud Run service if it doen’t exists, so let’s deploy right away:
$ gcloud beta run deploy --platform=managed --region=us-central1 --allow-unauthenticated --image=gcr.io/${CLOUDSDK_CORE_PROJECT}/site01:latest site01
Deploying container to Cloud Run service [site01] in project [yourrpoject] region [us-central1]
✓ Deploying new service... Done.
✓ Creating Revision...
✓ Routing traffic...
✓ Setting IAM Policy...
Done.
Service [site01] revision [site01-e6fbee39-d08f-4906-a016-ecd921635bdb] has been deployed and is serving traffic at https://site01-wy3lc5tzpa-uc.a.run.app
and test:
curl https://site01-wy3lc5tzpa-uc.a.run.app
<html>
<body>
hello cloud run world
</body>
</html>
Great success!
Continuos deploys with Cloud Build
- Connect the source repo by adding a trigger
- Configure automated build
- Configure permissions for Cloud Build to deploy Cloud Run (see Cloud Run tab)
Once everything is configured properly the site gets build on every git push
. Nice!
Hooking up Hugo
This means adding an intermediary step to produce the site content
echo site01/public >> .gitignore
because we wantpublic/
built with Hugorm site01
to prepare for generated content- initialize site with
hugo new site site01
- download a theme, ie Niello
(cd site01/themes/ && curl -L https://github.com/guangmean/Niello/archive/1.0.tar.gz|tar -xz)
- configure theme with
echo 'theme = "Niello-1.0"' >> site01/config.toml
- test locally with
hugo -s ./site01 server
git push origin
to have it built and deployed
Once the Cloud Build completes the static site is compiled and deployed.
The full source code is available at gmarik/starterkit-static_site-cloud-run-nginx-hugo
Appendix: Nginx’s Docker image
Running nginx on Cloud Run is the same as running nginx in Docker except the dynamic PORT
contract and it took me some time to figure it out
although some say it’s not so dynamic:
You can sort of safely hard code port 8080 in your nginx.conf as it’s very unlikely to change in the foreseeable future on Cloud Run — https://stackoverflow.com/a/57171522/928095
I didn’t want to hardcode so followed the hard way:
Back to the code.
The Dockerfile
looks simple but notice the docker-entrypoint.sh
bit:
FROM nginx:1.16-alpine as nginx
ARG SITE=site01
# Config
COPY nginx/etc/nginx /etc/nginx
# Sources
RUN mkdir -p /var/www/${SITE}/public
COPY ${SITE}/public /var/www/${SITE}/public
# Initialization
COPY nginx/docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
The docker-entrypoint.sh
takes care of the $PORT
contract, by replacing ${NGINX_PORT}
placeholder by a value from $PORT
environment variable:
#!/usr/bin/env sh
set -eu
## conform to service contract https://cloud.google.com/run/docs/reference/container-contract
NGINX_PORT=${PORT:-8080}
# and set the NGINX_PORT to $PORT
sed -i "s/\${NGINX_PORT}/${NGINX_PORT}/g" /etc/nginx/conf.d/*.conf
echo "nginx: testing config"
nginx -t
echo "nginx: starting on $NGINX_PORT"
exec "$@"
in conf.d/*.conf
, that may look like this:
# simplified gmarik.info.conf
server {
server_name www.gmarik.info;
listen ${NGINX_PORT};
listen [::]:${NGINX_PORT};
# do not use :PORT in redirect
port_in_redirect off;
root /var/www/gmarik.info/public;
index index.html;
error_page 404 /404.html;
}
after it runs as Docker ENTRYPOINT
and then starts up the nginx
command as specified in the Dockerfile
.
Appendix: cleaning up Cloud Run revisions
Currently there’s no UI to mass-delete unused revisions but it’s easily script-able:
export CLOUDSDK_CORE_ACCOUNT[email protected]
export CLOUDSDK_CORE_PROJECT=yourproject
export SITE_NAME=site01
REVISIONS=$(gcloud beta run revisions list --platform=managed|awk -v site=$SITE_NAME '$3==site {print $2}'|tail -n+5)
for r in $REVISIONS; do gcloud -q beta run revisions delete --platform=managed --region=us-central1 $r; done
NOTE: it keeps 4 revisions by skipping with tail -n+5
Example:
$ REVISIONS=$(gcloud beta run revisions list --platform=managed|awk -v site=$SITE_NAME '$3==site {print $2}'|tail -n+5)
$ echo $REVISIONS
site01-00077 site01-00076 site01-00075 site01-00074 ...
$ for r in $REVISIONS; do gcloud -q beta run revisions delete --platform=managed --region=us-central1 $r; done
Deleted revision [site01-00077].
Deleted revision [site01-00076].
...