Skip to main content

Deployment of a Gitlab-CI-built application with ArgoCD

··1850 words·9 mins
Ops Kubernetes ArgoCD Gitlab CI/CD
ALT
Author
ALT
Table of Contents
2024-12-25 update I’ve ditched the system in favor of something far more simple after migrating my cluster, Gitlab Pages!

As recently mentioned I’ve changed the website’s theme. Doing so, I realized my deployment method, which solely relied on Gitlab CI/CD and Helm, was as simple as it was unsatisfactory: I had to give access to my cluster to Gitlab (although it’s not too dire with proper RBAC management) and some other minor grievances.

Since I’ve been using a ArgoCD more and more lately, I figured: why not make it so Gitlab only handles the “build” and packaging part of things, and let Argo do the rest? As an added bonus it GREATLY complexifies the whole thing, what’s not to love?

But yeah, while a Gitlab Pages deployment would have been way, way easier and smoother (and I strongly recommend it over what I’m about to describe if it fits your use-case), it only works for Gitlab-hosted web applications, which restricts the scope of applicability greatly. This deployment pattern however should work with any and all application that can be built using Gitlab CI/CD and deployed by ArgoCD on a Kubernetes cluster.

If you’re reading this on www.northamp.fr, it’s likely that it worked :)

Diagram
#

blog-deployment-pattern
Bit of a messy diagram, but it contains nearly everything relevant. Light orange resources are ArgoCD-managed.

In a nutshell, the main ideas are:

  • Rely on Gitlab to store
    • Website data
    • Containers & Helm chart
      • I haven’t bothered properly publishing the latter for now, it lives in the same repo as the blog and that’s fine by me
    • ArgoCD manifests (GitOps!)
  • … and rely on Gitlab CI/CD for
    • Container builds
    • ArgoCD manifest updates
  • … then on ArgoCD by extensively (ab)using application of applications pattern

Gitlab (CI/CD) is only used for GitOps and application builds, while ArgoCD handles all the CD side of things, giving the possibility to use both to their fullest extent with Gitlab environments, Argo’s UI, etc..!

Setting it up!
#

This won’t be a textbook procedure, and will assume that the reader has a good grasp on Kubernetes already. It’ll also be opinionated towards certain solutions (i.e. I use Traefik as ingress, Let’s Encrypt as ) and those are assumed to be set up and ready for use. Basically, you may have to extensively tailor the solution to your own needs (but feel free to contact me if necessary :).

Repositories
#

As shown in the diagram, I rely on three different repos:

  • The website’s own repo
  • A manually managed GitOps repo
  • A CI/CD-managed GitOps repo

The last two could well be just one repo, but I chose to separate them as I don’t like committing to repositories in the context of a Gitlab CI to begin with, and even less when I frequently, manually make modifications to it myself (those applications aren’t gonna update themselves!).

Website
#

As you’ve probably understood by now, the guinea pig for this deployment pattern has been this very website, my blog. It’s a simple Hugo website, built using Gitlab CI/CD, containerized with Kaniko, and pushed to Gitlab registry. Its repository structure is something like:

website/
  |-> .gitlab-ci.yml
  |-> Dockerfile
  |-> helm/
    |-> <chart that deploys the website>
  |-> <miscellaneous Hugo dirs and configs>

I won’t post the entire website’s code directory as it’d be pointless; what’s more interesting is the Gitlab CI manifest though:

Not guaranteed to be up to date forever!
variables:
  GIT_SUBMODULE_STRATEGY: recursive
  GIT_SUBMODULE_DEPTH: 1

stages:
  - build
  - dockerize
  - deploy

.build:
  stage: build
  image: hugomods/hugo
  script:
    - hugo version
    - echo "Building Hugo website with baseURL ${BASEURL}"
    - hugo -d public_html --baseURL ${BASEURL}
    - echo -n "BASEURL=${BASEURL}" > url.env
  artifacts:
    paths:
      - public_html
    reports:
      dotenv: url.env
    expire_in: 1 day

build staging:
  extends: .build
  variables:
    BASEURL: https://${CI_COMMIT_REF_NAME}.${STAGING_URL}/
  rules:
    - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
      when: on_success

build production:
  extends: .build
  variables: 
    BASEURL: https://${PRODUCTION_URL}/
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: on_success

dockerize:
  stage: dockerize
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - mkdir -p /kaniko/.docker
    - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"username\":\"${CI_REGISTRY_USER}\",\"password\":\"${CI_REGISTRY_PASSWORD}\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor --context ${CI_PROJECT_DIR} --dockerfile ${CI_PROJECT_DIR}/Dockerfile --destination ${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_NAME}-${CI_PIPELINE_IID}

argocd:create manifest:
  stage: deploy
  image: alpine:latest
  variables:
    GIT_STRATEGY: none
  before_script:
    - apk add git
    - git config --global user.email "noreply@${CI_SERVER_HOST}"
    - git config --global user.name "ci-bot"
  script:
    - git clone https://oauth2:$ACCESS_TOKEN@a.gitlab.instance.net/auto-gitops-gitlab-repo.git
    - cd auto-gitops
    - mkdir -p applications/blog
    - |
      cat > applications/blog/${CI_COMMIT_BRANCH}.yaml << EOF
        apiVersion: argoproj.io/v1alpha1
        kind: Application
        metadata:
          name: blog-${CI_COMMIT_BRANCH}
          namespace: 'argocd'
        spec:
          destination:
            namespace: 'blog'
            server: 'https://kubernetes.default.svc'
            finalizers:
              - resources-finalizer.argocd.argoproj.io
          source:
            path: path/to/helm/chart
            repoURL: 'https://a.gitlab.instance.net/website-repo.git'
            targetRevision: ${CI_COMMIT_BRANCH}
            helm:
              parameters:
                - name: fullnameOverride
                  value: blog-${CI_COMMIT_BRANCH}
                - name: image.tag
                  value: ${CI_COMMIT_REF_NAME}-${CI_PIPELINE_IID}
                - name: ingress.enabled
                  value: 'true'
                - name: 'ingress.hosts[0].host'
                  value: $(basename ${BASEURL})
                - name: 'ingress.hosts[0].paths[0].path'
                  value: /
                - name: 'ingress.hosts[0].paths[0].pathType'
                  value: ImplementationSpecific
              values: |-
                imagePullSecrets:
                  - name: blog-registry-credentials
                ingress:
                  annotations:
                    kubernetes.io/ingress.class: traefik
                    traefik.ingress.kubernetes.io/router.tls: "true"
                    cert-manager.io/cluster-issuer: letsencrypt-prod
                  tls:
                    - secretName: blog-${CI_COMMIT_BRANCH}-tls
                      hosts:
                        - $(basename ${BASEURL})
          project: selfhost
          syncPolicy:
            automated:
              prune: true
      EOF      
    - git add .
    - git commit -m "update application for blog on branch ${CI_COMMIT_BRANCH}"
    - git push -o ci.skip https://oauth2:$ACCESS_TOKEN@a.gitlab.instance.net/auto-gitops-gitlab-repo.git --all
    - echo "Done! Site should be deployed within 5 minutes to ${BASEURL}"
  environment:
    name: blog-$CI_COMMIT_REF_NAME
    url: "https://${BASEURL}"
    on_stop: "argocd:delete manifest"

argocd:delete manifest:
  variables:
    GIT_STRATEGY: none
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add git
    - git config --global user.email "noreply@${CI_SERVER_HOST}"
    - git config --global user.name "ci-bot"
  script:
    - git clone https://oauth2:$ACCESS_TOKEN@a.gitlab.instance.net/auto-gitops-gitlab-repo.git
    - cd auto-gitops
    - rm applications/blog/${CI_COMMIT_BRANCH}.yaml
    - git add .
    - git commit -m "remove application for blog on branch ${CI_COMMIT_BRANCH}"
    - git push -o ci.skip https://oauth2:$ACCESS_TOKEN@a.gitlab.instance.net/auto-gitops-gitlab-repo.git --all
    - echo "Done! Site deployed on ${BASEURL} should be going down soon"
  when: manual
  environment:
    name: blog-$CI_COMMIT_REF_NAME
    action: stop

Important things here are:

  • I’ve set up two different build jobs, one for the master branch (which is assumed to be production) and the other for, well, any other branch (which are assumed to be staging). This is because Hugo needs the base_url setting properly set up upon building the website. Said URL is passed on to next jobs (through dotenv reports) for later usage
  • I’m using Kaniko to build the website, not much to say here except that the resulting tag contains the pipeline unique ID, to trigger new ArgoCD deployments as much as necessary (remember to enable container registry cleanup!)

The ArgoCD manifest generation job is the main star of the show here: it commits a new (or replacement) manifest that’s relevant to the build that just took place. That manifest uses a Helm chart contained within the repo that contains every basic templates necessary to deploy the website. When combined with the other repos I’ll talk about next, it lets ArgoCD pick up on every changes done within the website code, and, depending on the branch where said change happened, will make it deploy on the production URL or a staging one!

And to keep things clean, I’m using Gitlab environments to trigger a job that deletes the manifest when the branch is deleted (which makes Argo remove every resources, since auto-prune is on).

You’ll need to create a CI/CD variable named ACCESS_TOKEN containing an access token with read_repository and write_repository permissions on the auto-gitops-gitlab-repo for the last two jobs!

Manual GitOps repo
#

That repository has existed for way longer than I’ve decided to change that blog’s deployment, and contains most of my service deployments, following the classic “app of apps” ArgoCD pattern. It’s structured like:

manual-gitops-gitlab-repo/
  |-> README.md
  |-> applications/
    |-> application_example.yaml
    |-> master-blog.yaml
    |-> blog-common.yaml
  |-> application_example/
    |-> README.md # bit of documentation on the deployed app, could well be ported to the wiki but I'm lazy :)
  |-> master-blog/
    |-> README.md 
  |-> blog-common/
    |-> pullsecret.yaml
    |-> README.md
  |-> applications.yaml # Contains the manifest for the app of apps ("master-apps") created on Argo - just archived here

I also won’t post the directory since it’s largely irrelevant, but the interesting applications are:

  • applications.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: master-apps
  namespace: argocd
spec:
  destination:
    namespace: applications
    server: 'https://a-kube-cluster'
  project: whatever
  source:
    path: applications
    repoURL: 'https://a.gitlab.instance.net/manual-gitops-gitlab-repo.git' # this repository!
    targetRevision: HEAD
  syncPolicy:
    syncOptions:
    - CreateNamespace=true

master-apps
Partial view of the master app from the Argo UI

The fabled app of apps, there’s plenty of literature on the net about it. Moving on…

  • applications/master-blog.yaml:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: master-blog
  namespace: argocd
spec:
  project: whatever
  source:
    repoURL: 'https://a.gitlab.instance.net/auto-gitops-gitlab-repo.git' # the gitlab-ci managed repo!
    path: applications/blog
    targetRevision: HEAD
  destination:
    server: 'https://a-kube-cluster'
    namespace: applications
  syncPolicy:
    automated:
      prune: true
    syncOptions:
      - CreateNamespace=true

master-blog
Blog ‘master’ app, bit barren but there’d be one app per branch here

This application picks up what goes on in the auto-gitops repo, I’ll get back to it later.

  • applications/blog-common.yaml:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: common-blog
  namespace: argocd
spec:
  project: whatever
  source:
    repoURL: 'https://a.gitlab.instance.net/manual-gitops-gitlab-repo.git' # this one!
    path: common-blog
    targetRevision: HEAD
  destination:
    server: 'https://a-kube-cluster'
    namespace: blog
  syncPolicy:
    automated:
      prune: true
    syncOptions:
      - CreateNamespace=true

This one however grabs manifests created in this very repository under the common-blog dir; I use it to create the pull secret used to download the images. It’s not lumped in the individual deployments (because it’d be pointless), nor created manually because I want everything to be managed by Argo. So yeah, all it contains is a single secret resource:

blog-common
Common resources; only one so far!

And with that setup, everything is working properly, save for one last task: create the auto-gitops repo!

Auto GitOps repo
#

Since it’s managed with CI/CD, all there is to do is create the repo, create an Access Token able to read/write to it and give it to Argo and Gitlab CI.

I’ve added a README to it but it’s not even mandatory. While it’s empty until the first pipeline run that would generate a manifest, after a few branches and deployments it’d look like:

auto-gitops-gitlab-repo/
  |-> README.md
  |-> applications/
    |-> blog/ # 
      |-> master.yaml # Default branch, lands on production URL. Generated by a CI job!
      |-> example_branch.yaml # Staging branch, deploys on another domain as staging. Generated by a CI job!
    |-> another_website/
      |-> master.yaml
      |-> example_branch.yaml

Thus, after running a pipeline, the following file would be generated:

deploy-fix-manifest
Screenshot instead of the YAML since it’s just the result of the job above, and it drives home the point that it’s not to be manually used :)

Which would be picked up and deployed by ArgoCD:

deploy-fix-app
Screenshot instead of the YAML since it’s just the result of the job above, and it drives home the point that it’s not to be manually used :)

… And all is right with the world :)

Wrapping it up
#

Not much more to be said, beyond the fact that it is again very convoluted and could likely be simplified; this automated deployment pattern however fits nearly every containerized Gitlab-hosted-Argo-deployed projects I can think of.

The main weak point I can envision is the manifest deletion job: if it runs before another job that creates the manifest anew, the deployment will live on forever til manually deleted from the auto-gitops repo.

If I notice more issues or fix some, I’ll update this article as necessary. I may also make a boilerplate group project to showcase it entirely, instead of letting the reader (you!) skim through the entire thing without seeing a concrete example.