Organisasi ingin bergerak cepat dan engineer ingin produktif. Begitupula dengan stakeholders yang ingin men-deliver perubahan dengan cepat tanpa harus mengorbankan kualitas. Dan juga pengguna akhir, ingin menggunakan layanan yang stabil.

Salah satu cara untuk mencapai itu adalah dengan membuat Staging server, sebuah server yang seolah-olah seperti Production dengan lingkungan yang hampir sama dengan yang digunakan di Production. Hampir setiap organisasi mungkin sudah memilki staging server sendiri, tulisan ini hanyalah untuk mereka yang mungkin belum memiliki staging server.

Ditulisan ini kita tidak membahas hal-hal di level aplikasi–seperti konfigurasi database khusus staging, konfigurasi environment variable untuk staging, dsb–melainkan hanya di level infrastruktur dengan requirements yang tidak terlalu kompleks.

Mengapa menggunakan Docker?

Banyak alasan mengapa menggunakan Docker (dan juga tidak menggunakannya), alasan gue antara lain:

  • Isolasi untuk dependensi level aplikasi. Bila aplikasi membutuhkan imagemagick, maka dependensi tersebut harusnya tidak terpasang di server (global).
  • Isolasi di sumber daya. Selain agar lebih mudah untuk di profiling, juga agar lebih 'aman' sehingga jika misal terdapat kesalahan yang menyebabkan memakan banyak sumber daya, maka yang terkena dampaknya hanya di aplikasi tersebut.
  • Menggunakan docker mempermudah di proses CI

Dan masih banyak lagi.

Mengapa menggunakan Traefik?

Traefik adalah sebuah edge router, yang menawarkan reverse proxy & load balancer out-of-the-box. Jika kamu familiar dengan Nginx, harusnya familiar juga dengan yang ditawarkan oleh Traefik.

Ada alasan khusus mengapa gue menggunakan Traefik daripada Nginx. Pertama, karena menggunakan Docker & container gue memiliki port yang dinamis. Tidak ada remapping port yang gue lakukan untuk menghindari konflik & mengingat-ngingat port mana saja yang sudah digunakan.

Juga, banyak hal-hal yang gue lakukan di level aplikasi bisa diatasi di traefik. Seperti auth forwarding, security-things response header, dsb.

Traefik adalah proyek Open Source & memiliki single-executable binary (thanks to Go). Namun di konteks ini, gue menggunakan Traefik sebagai container, bukan executable program.

Mengapa menggunakan Gitlab CI?

Banyak CI yang ada di internet, khususnya yang gratis. Gue menggunakan Gitlab karena sudah terbiasa dengan CI Gitlab, alias males menghafal sintaks untuk konfigurasinya lagi.

Silahkan gunakan CI apapun, pada dasarnya cuma beda dipenulisan konfigurasinya aja.

Workflow

Gue anggap lo menggunakan Docker juga, dan sudah mengenal sedikit tentang bagaimana cara kerja Traefik. Dan ya, sudah familiar sedikit dengan konsep CI khususnya menggunakan Gitlab CI.

Workflow disini sebenarnya mainstream, memiliki 5 stage & 2 lingkungan:

  1. Build
  2. Test
  3. Release
  4. Deploy (Staging)
  5. Deploy (Production)

Di stage build ini adalah... proses build? Seperti, install dependensi, copy file ini-itu, compile binary, dsb.

Di stage test, kita melakukan proses automate testing. Unit, integration, dsb.

Di stage release, image tersebut berarti sudah siap dan labelnya akan diubah menjadi staging.

Di stage deploy (staging), kita akan melakukan pull ke image tersebut (staging), dan melakukan proses "deploy" ke staging.

Di stage deploy (production), ini harus ada intervensi dari penanggung jawab, entah code owner, qa, team lead, ob, vp, siapapun. Kita tidak menerapkan continuous deployment (hanya continuous delivery), setelah staging server dianggap "OK".

Karena biasanya di stage staging ini kita melakukan smoke test/uat/apapun kamu menyebutnya. "Men-deploy" dari staging ke production semudah "klik deploy" yang ditawarkan di UI Gitlab.

Jadi, gambaran nya kira-kira seperti ini:

  1. Developer push branch baru
  2. CI akan melakukan build dan test
  3. Developer melakukan Merge/Pull Request.
  4. PR di merge.
  5. CI akan melakukan proses build , test , release
  6. Lalu, akan mendeploy ke production
  7. Jika OK, maka

Tech Stacks

  • 1  VM (Ubuntu, 1GB RAM. 1 core)
  • CI (gitlab.com CI)
  • Docker Image Registry (registry.gitlab.com)
  • Docker (+ docker-compose)
  • Traefik
  • Aplikasi apapun (untuk contoh disini adalah Node.js)

Ya, kita akan menggunakannya ke mesin $5/bulan dengan total 4 aplikasi yang berjalan di production :))

Membuat staging URL

Buat staging URL, anggap https://app-staging.evilfactory.id dan arahkan ke server (via A record) di DNS kalian.

Membuat instance traefik

Gue anggap kamu sudah meng-install Docker dan sudah mengerti sedikit tentang Docker Compose. Mari kita buat untuk service traefik kita.

version: '3'

services:
  traefik:
    image: traefik:1.7
    command:
      - --docker
      - --api
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Kode diatas singkatnya adalah:

  1. Gunakan sintaks docker-compose versi 3
  2. Kita buat "services", disini salah satu service nya bernama traefik.
  3. Di service tersebut kita menggunakan image traefikdengan versi 1.7 (latest stable)
  4. Lalu "mem-pass" parameter --docker untuk memberitahu traefik untuk me-listen docker, dan parameter --api untuk memberitahu traefik agar kita bisa mengakses dashboard sederhana dari traefik.
  5. Kita lakukan remapping port disini, port 80 ke port 80 di container (edge router) dan port 8080 ke port 8080 di container (traefik dashboard)
  6. Terakhir, kita "mount" docker.sock (Docker daemon) di server kita ke container traefik.

Lalu "deploy" service tersebut sebagai daemon dengan perintah:

docker-compose up -d traefik

Jika sudah, harusnya kita bisa akses traefik dashboard di <server_ip>:8080 .

Membuat aplikasi Node.js sederhana

Disini kita hanya membuat aplikasi web yang menampilkan <h2>Hello worls</h2>, dan memiliki 1 API endpoint yakni /healthcheck untuk... Sesuai namanya, healthcheck (apakah server up?).

Disini kita menggunakan fastify sebagai framework, silahkan install dependensi dan buat file index.js

// index.js

const fastify = require('fastify')({ logger: true })

const IS_STAGING = process.env.IS_STAGING

fastify.get('/healthcheck', (request, reply) => {
  reply.send('OK')
})

fastify.get('/', (request, reply) => {
  reply
    .type('text/html')
    .send(`<h2>Hello worls</h2>${IS_STAGING ? '(staging)' : ''}`)
})

fastify.listen(process.env.PORT, process.env.HOST)
Sumber

Lalu kita coba jalankan server kita:

PORT=3000 HOST=0.0.0.0 IS_STAGING=true node index.js

Jika sudah berjalan, kita test melakukan "healthcheck"

curl http://localhost:3000/healthcheck

Jika menampilkan OK , berarti tidak error.

Mengapa kita menggunakan host 0.0.0.0 bukan 127.0.0.1? Sederhananya, agar "mem-bind" aplikasi kita ke alamat IP dari yang dimiliki oleh server (container) kita.

Membuat Dockerfile

Karena aplikasi kita akan dibuat menjadi docker container, kita harus membuat Dockerfile untuk proses "build" ini. File nya sederhana saja:

FROM node:10-alpine
WORKDIR /app

COPY package.json ./
COPY yarn.lock ./

EXPOSE 3000

RUN yarn

COPY . .

CMD ["npm", "start"]
Sumber

Pertama, kita menggunakan base image node:10-alpine. Alpine linux yang sudah ter-bundle Node.js versi 10

Kedua, kita set /app sebagai workdir (working directory) kita.

Ketiga, copy package.json ke workdir. Untuk proses instalasi dependensi di container kita nanti

Keempat, copy yarn.lock ke workdir. Ini berguna agar dependensi yang di-install di local kita konsisten dengan yang ada di container.

Kelima, kita install dependensi yang dibutuhkan.

Keenam, kita copy source code kita ke container. Karena kita tidak menjadikan aplikasi kita sebagai executable binary, bukan?

Terakhir, jalankan aplikasi kita dengan membaca script start di package.json.

Perlu diingat, buat file .dockerignore dengan nilai node_modules atau dengan nilai yang sama di .gitignore untuk mencegah docker meng-copy file-file yang sebenarnya hanya dibutuhkan di development environment (dan node_modules of CORS).

Silahkan coba build image tersebut, misal dengan perintah seperti ini:

docker build -t 108kb/my-awesome-project:staging .

Jika sudah, coba test dengan menjalankan aplikasi kita via docker.

docker run --env IS_STAGING=true --env HOST=0.0.0.0 --env PORT=3000 -p 3000:3000 108kb/my-awesome-project:staging

Buka tab terminal baru, dan lakukan test yang sama seperti di section Node.js.

Membuat service baru di Docker Compose & integrasi dengan traefik

Mari kita automate proses deployment aplikasi di docker kita dengan docker-compose. Kita tambahkan konfigurasi lain docker-compose.yml kita, lalu buat file berikut:

app_staging:
  image: 108kb/my-awesome-project:staging
  labels:
    - "traefik.port=3000"
    - "traefik.frontend.rule=Host: app-staging.evilfactory.id"
  environment:
    - HOST=0.0.0.0
    - PORT=3000
    - IS_STAGING=true
Sumber

Silahkan ubah 108kb/my-awesome-project dengan nama sesuai kalian, dan ubah app-staging.evilfactory.id dengan url staging kalian.

Bagian label traefik.port=3000 adalah untuk memberitahu traefik bahwa aplikasi kita menggunakan port 3000, dan bagian traefik.frontend.rule=Host:... untuk memberitahu traefik bahwa "backend" ini yang bertanggung jawab untuk request ke app-staging.evilfactory.id.

Silahkan deploy service baru tersebut:

docker-compose up -d app_staging

Dan lakukan test:

curl http://localhost/healthcheck -H "Host: app-staging.evilfactory.id"

Jika response nya "OK", maka traefik berhasil me-route request tersebut ke container kita.

traefik dashboard

Setting Continuous Integration (Gitlab CI)

Disini kita akan menggunakan 5 fase dan 2 branch. Untuk fase:

  • Build
  • Test
  • Release
  • Deploy (Staging)
  • Deploy (Production)

Dan untuk branch ada canary dan master. Canary sederhananya untuk "nightly build" alias build terbaru (sebenarnya tidak sesederhana itu, untuk sekarang anggap aja seperti itu) untu di staging dan Master untuk kode yang stabil, production.

Rules nya adalah:

  • Setiap perubahan harus berdasarkan branch canary.
  • Branch master sifatnya merge-only, tidak ada yang bisa push langsung ke branch master. Begitupula dengan canary
  • canary untuk staging
  • master untuk production

Mengapa base nya dari canary bukan master?

Karena... Continuous Integration?

Branch canary adalah perubahan yang sifatnya sudah teruji secara otomatis dan siap untuk dibawa ke Staging, sedangkan master adalah perubahan yang sifatnya sudah teruji oleh manusia (QA). Pada pembahasan disini kita tidak membahas seputar Canary Release dan Deploy Preview, jadi, mari kita persingkat menjadi kondisi seperti diatas.

Gue anggap kamu sudah menyiapkan hal-hal yang berkaitan dengan Gitlab, untuk kondisi server, singkatnya seperti ini:

  • User khusus (non sudoers) di group docker khusus untuk deploy
  • Koneksi SSH khusus untuk user tersebut menggunakan password, selainnya hanya via publickey

Untuk point nomor 2, bisa edit sshd_config nya menjadi seperti ini:

# /etc/ssh/sshd_config

Match User <some_user_name>
  AuthenticationMethods password

Jangan lupa restart service sshd nya agar perubahan yang terjadi bisa diterapkan.

Sekarang, mari kita buat file .gitlab_ci.yml nya

image: docker:stable

services:
  - docker:dind

variables:
  STAGING_URL: https://app-staging.evilfactory.id
  PRODUCTION_URL: https://app.evilfactory.id

before_script:
  - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY

stages:
  - build
  - test
  - release
  - staging
  - production

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

test:
  stage: test
  script:
    - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker run $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA node -e "console.log('ok')"

staging:
  stage: release
  only:
    - canary
  script:
    - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:staging
    - docker push $CI_REGISTRY_IMAGE:staging

deploy_staging:
  stage: staging
  only:
    - canary
  script:
    - echo "TODO: Deploy to staging server"
  environment:
    name: staging
    url: $STAGING_URL

deploy_production:
  stage: production
  only:
    - master
  script:
    - echo "TODO: Deploy to production server"
  environment:
    name: production
    url: $PRODUCTION_URL
  when: manual
Sumber

Pusing? Santai, dikit-dikit kita bahas.

Disini, kita menggunakan service docker in docker di CI kita, karena, kita membutuhkan perintah docker disini.

Kekurangannya, salah satunya adalah proses build menjadi lebih lama karena belum ada nya proses "caching" yang membuat proses tersebut menjadi lebih cepat.

Di variables, kita men-set env variable yang dibutuhkan (sebenarnya biar rapih aja sih)

Di before_script, maksudnya adalah "eksekusi perintah-perintah berikut sebelum yang lain disetiap job", untuk make sure kita sudah login ke docker registry nya gitlab.

Untuk stages, adalah untuk seperti ini:

Gitlab Pipeline

Dibagian yang paling atas (Build, Test, Staging dan Production) itu adalah stages. Sedangkan yang dibawah stages tersebut adalah "Job".

Lalu "top-level indent" dibawah stages tersebut adalah job-job yang ada yang isinya:

  • Memberitahu perintah apa yang harus dieksekusi (script)
  • Stage apa untuk job tersebut
  • Environment apa yang terkait dengan job tersebut
  • Terakhir, "apa yang membuat job tersebut ter-trigger". Sebagai contoh, misal, bila ada push ke branch canary.

Untuk diatas, kita hanya sampai ke release terlebih dahulu agar tidak terlalu kehilangan fokus. Job-job diatas kita bahas dikit-dikit:

  • Build: Build container image seperti biasa lalu push ke registry dengan tag commit SHA yang ada
  • Test: Pull container yang sudah dibuild tersebut, lalu jalankan test yang ada di container tersebut. Misal npm run test , karena kita tidak membuat test apapun (di level aplikasi), jadi mari kita persingkat hanya untuk mengeksekusi Node.js di container kita sebagai test
  • Release: Anggap automate testing kita sudah pass semua, di stage ini kita pull image tersebut, ubah tag nya dari commit SHA menjadi staging. Bisa diubah menjadi latest atau nightly atau preview terserah gimana cocoknya kamu aja.

Untuk Deploy (staging & server) kita skip dulu untuk saat ini, sampai kita lihat Gitlab CI kita berjalan sesuai dengan yang kita harapkan. Push kode tersebut ke repository, dan lihat prosesnya di CI/CD > Pipelines.

Jika hasilnya kurang lebih seperti gambar di Gitlab Pipeline (diatas), berarti konfigurasi kita benar.

Continuous Delivery

Sampai saat ini kita sudah sampai ke tahap Continuous Integration, sekarang mari kita terapkan Continuous Delivery.

Apa perbedaan Continuous Delivery dengan Integration? Atau dengan Deployment?

Lihat gambar dibawah

Sumber dari Atlassian

Untuk melakukan deploy ke production, harus dilakukan secara manual. Di script .gitlab-ci.yml tersebut sudah kita definisikan (di bagian when: manual), sekarang mari kita buat script untuk melakukan proses deployment ke staging nya secara otomatis.

Flow nya seperti ini:

  1. SSH ke staging server kita
  2. Pull image yang memiliki tag staging
  3. Deploy service via docker-compose

Tahap opsional nya, setelah berhasil di deploy ke staging server, kirim notif ke group Slack/Discord/Telegram/apapun itu untuk ngasih tau tim.

Mari kita buat script nya, kira-kira seperti ini:

script:
  - apk update && apk add sshpass openssh
  - export IMAGE_NAME=$CI_REGISTRY_IMAGE:staging
  - sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker pull $IMAGE_NAME
  - sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker-compose up -d $STAGING_APP_NAME
  - echo "> Done. Check it at $STAGING_URL"
Sumber

Dengan kondisi:

  • Kita menggunakan sshpass untuk meng-eksekusi command di server kita
  • Kita install openssh karena alpine by default tidak memilikinya
  • docker-compose.yml berada di home directory
  • Personal token untuk akses registry gitlab sudah dibuat
  • Server sudah login ke docker registry gitlab

Nilai dari $SSH_SERVER gue adalah <user>@<ip> -p<port>, karena gue mengubah port default untuk SSH. Dan si <user> bisa mengakses docker. Dan jangan lupa untuk nilai environment variable SSHPASS di Repository > I/CD > Varibales karena kita menggunakan sshpass -e

Perlu diketahui bahwa Gitlab akan membaca environment variable kamu (yang di CI/CD) bila kamu men-setting branch tersebut sebagai "protected". Bisa disetting di Settings > Repository > Protected Branches alias di https://gitlab.com/<username>/<project>/-/settings/repository

Struktur directory home gue adalah seperti ini:

$ pwd
/home/<user>

$ tree -L 1
.
|-- app
|-- data
`-- docker-compose.yml

2 directories, 1 file

Untuk membuat personal access token, bisa akses di Profile > Access Tokens, alias disini. Beri scope setidaknya read_registry. Token tersebut harap disimpan karena akan kita gunakan untuk login ke gitlab registry.

Jika server belum login ke docker registry gitlab, silahkan login terlebih dahulu dengan perintah seperti dibawah:

$ docker login -u <username_gitlab> -p <personal_access_token> registry.gitlab.com

Dan dari script diatas, yang kita lakukan hanyalah:

  • Pull image container dengan label staging
  • Deploy :))

Disini kita harus mengubah image kita menjadi registry.gitlab.com dilanjutkan dengan nama image sebelumnya. Misal menjadi seperti ini registry.gitlab.com/108kb/my-awesome-project di file docker-compose.yml kita.

Juga, jangan lupa untuk membuat variable bernama STAGING_APP_NAME dan PRODUCTION_APP_NAME di .gitlab-ci.yml kita.

Lalu setiap perubahan yang terjadi di branch selain canary akan melakukan build dan test, dan di branch canary akan melakukan build, test, release dan deploy_production .

Setelah itu kita bisa akses di, misalnya: https://app-staging.evilfactory.id.

Oops, there is a typo!

Ya, yang seharusnya Hello World, malah Hello Worls. Thanks to our QA!

Gak apa-apa, yang penting tahap build dan test sudah pass, yang artinya tidak ada kesalahan fatal di level aplikasi. Silahkan buat branch baru, push, dan merge request ke canary. Contohnya seperti ini nanti jadinya:

Merge request

Branch tersebut akan ter-merge ke canary secara otomatis bila job kita pass. Yang berarti, akan men-trigger proses deployment ke staging server secara otomatis. Yang nanti nya akan menjadi seperti ini bila job sebelumnya pass

Deployed to staging

Yang bila kita klik View App, akan diarahkan ke staging URL kita yang sudah didefinisikan.

Deploy to production

Oke sebagai bonus.

Prosesnya hampir sama dengan diatas, bedanya, untuk bisa men-deploy kita harus klik tombol dibawah, dengan kondisi branch canary berhasil di merge ke master:

Lalu proses yang dilakukan adalah:

  • Pull staging image
  • Build image baru berdasarkan image tersebut
  • Beri tag image tersebut menjadi stable. Atau latest, terserah. Btw kita melakukan "versioning" untuk aplikasi kita di level repository.
  • Push image
  • Di server, pull image dengan tag stable.
  • Deploy!

Hal-hal yang terkait dengan "environment variable" di level aplikasi, didefinisikan di docker-compose.yml. Berikut script untuk stage production nya:

deploy_production:
  stage: production
  script:
    - apk update && apk add sshpass openssh
    - export IMAGE_NAME=$CI_REGISTRY_IMAGE:stable
    - docker pull $CI_REGISTRY_IMAGE:staging
    - docker tag $CI_REGISTRY_IMAGE:staging $CI_REGISTRY_IMAGE:stable
    - docker push $CI_REGISTRY_IMAGE:stable
    - sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker pull $IMAGE_NAME
    - sshpass -e ssh -o StrictHostKeyChecking=no $SSH_SERVER docker-compose up -d $PRODUCTION_APP_NAME
    - echo "> Done. Check it at $PRODUCTION_URL"
  environment:
    name: production
    url: $PRODUCTION_URL
  when: manual
  only:
    - master

Sumber

Berikut hasilnya:

Deployed to production

Lalu kita bisa akses https://app.evilfactory.id (atau klik View app) dan aplikasi pun akan berdasarkan staging server kita (namun tanpa embel-embel (staging)). Dan tanpa typo yang disengaja tersebut yang sudah kita fix :))

P.S

Kamu bisa menggunakan Gitlab CI meskipun sumber kode kamu berada di GitHub, dengan cara seperti ini (New Project > CI/CD for external repo):

CI/CD for external repo

Sebagai penutup, berikut link-link yang terkait dengan tulisan ini: