Back-End

22 jan, 2018

Implantação Ruby on Rails em nível de produção no Kubernetes Engine do Google Cloud – Parte 02

Publicidade

Continuando a primeira parte deste artigo, vou terminar minhas impressões sobre a implantação Ruby on Rails em nível de Produção no Kubernetes Engine do Google Cloud.

O Balanceador de Carga

Muitos artigos irão expor esses pods diretamente através de um serviço diferente, chamado Balanceador de Carga. Não tenho tanta certeza do quão bem isso se comporta sob pressão e se possui terminação SSL, etc. Por isso, eu decidi passar completamente com um Balanceador de Carga Ingress usando o NGINX Controller.

Antes de tudo, decidi criar um node-pool separado para ele. Por exemplo, como este:

gcloud container node-pools create web-load-balancer \
--cluster=my-web-production \
--node-labels=role=load-balancer \
--machine-type=g1-small \
--num-nodes 1 \
--max-nodes 3 --min-nodes=1 \
--enable-autoscaling

Assim como quando criamos o exemplo large-pool, aqui você deve cuidar da adição de –node-labels para que o controlador seja instalado aqui em vez do default-pool. Você precisará saber o nome da instância do node, podemos fazê-lo da seguinte forma:

$ gcloud compute instances list
NAME                                             ZONE        MACHINE_TYPE   PREEMPTIBLE  INTERNAL_IP  EXTERNAL_IP      STATUS
gke-my-web-production-default-pool-123-123       us-west1-a  n1-standard-4               10.128.0.1   123.123.123.12   RUNNING
gke-my-web-production-large-pool-123-123         us-west1-a  n1-highcpu-8                10.128.0.2   50.50.50.50      RUNNING
gke-my-web-production-web-load-balancer-123-123  us-west1-a  g1-small                    10.128.0.3   70.70.70.70      RUNNING

Vamos salvar assim por enquanto:

export LB_INSTANCE_NAME=gke-my-web-production-web-load-balancer-123-123

Você pode reservar manualmente um IP externo e dar-lhe um nome como este:

gcloud compute addresses create ip-web-production \
        --ip-version=IPV4 \
        --global

Para o bem do exemplo, vamos dizer que gerou um IP reservado “111.111.111.111”. Então, vamos buscá-lo e salvá-lo assim, por enquanto:

export LB_ADDRESS_IP=$(gcloud compute addresses list | grep "ip-web-production" | awk '{print $3}')

Finalmente, vamos ligar este endereço à instância do node do balanceador de carga:

export LB_INSTANCE_NAT=$(gcloud compute instances describe $LB_INSTANCE_NAME | grep -A3 networkInterfaces: | tail -n1 | awk -F': ' '{print $2}')
gcloud compute instances delete-access-config $LB_INSTANCE_NAME \
    --access-config-name "$LB_INSTANCE_NAT"
gcloud compute instances add-access-config $LB_INSTANCE_NAME \
    --access-config-name "$LB_INSTANCE_NAT" --address $LB_ADDRESS_IP

Uma vez que tivermos feito isso, poderemos adicionar o resto da configuração de Implantação do Ingress. Isso será meio longo, mas é, na maior parte, padrão. Vamos começar por definir outro aplicativo da web que chamaremos de default-http-backend, que será usado para responder a solicitações HTTP, caso nossos pods da web não estejam disponíveis por algum motivo. Vamos chamá-lo de deploy/default-web.yml:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: default-http-backend
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: default-http-backend
    spec:
      terminationGracePeriodSeconds: 60
      containers:
      - name: default-http-backend
        # Any image is permissable as long as:
        # 1. It serves a 404 page at /
        # 2. It serves 200 on a /healthz endpoint
        image: gcr.io/google_containers/defaultbackend:1.0
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
            scheme: HTTP
          initialDelaySeconds: 30
          timeoutSeconds: 5
        ports:
        - containerPort: 8080
        resources:
          limits:
            cpu: 10m
            memory: 20Mi
          requests:
            cpu: 10m
            memory: 20Mi

Não é necessário mudar nada aqui, e agora você já deve estar familiarizado com o modelo de Implantação. Mais uma vez, agora você sabe que precisa expô-lo através de um NodePort. Então, vamos adicionar um deploy/default-web-svc.yml:

kind: Service
apiVersion: v1
metadata:
  name: default-http-backend
spec:
  selector:
    app: default-http-backend
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: NodePort

Mais uma vez, não é necessário mudar nada. Os próximos três arquivos são as partes importantes. Primeiro, vamos criar um Balanceador de Carga NGINX e chamá-lo de deploy/nginx.yml:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nginx-ingress-controller
spec:
  replicas: 1
  template:
    metadata:
      labels:
        k8s-app: nginx-ingress-lb
    spec:
      # hostNetwork makes it possible to use ipv6 and to preserve the source IP correctly regardless of docker configuration
      # however, it is not a hard dependency of the nginx-ingress-controller itself and it may cause issues if port 10254 already is taken on the host
      # that said, since hostPort is broken on CNI (https://github.com/kubernetes/kubernetes/issues/31307) we have to use hostNetwork where CNI is used
      hostNetwork: true
      terminationGracePeriodSeconds: 60
      nodeSelector:
        role: load-balancer
      containers:
        - args:
            - /nginx-ingress-controller
            - "--default-backend-service=$(POD_NAMESPACE)/default-http-backend"
            - "--default-ssl-certificate=$(POD_NAMESPACE)/cloudflare-secret"
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          image: "gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.5"
          imagePullPolicy: Always
          livenessProbe:
            httpGet:
              path: /healthz
              port: 10254
              scheme: HTTP
            initialDelaySeconds: 10
            timeoutSeconds: 5
          name: nginx-ingress-controller
          ports:
            - containerPort: 80
              name: http
              protocol: TCP
            - containerPort: 443
              name: https
              protocol: TCP
          volumeMounts:
            - mountPath: /etc/nginx-ssl/dhparam
              name: tls-dhparam-vol
      volumes:
        - name: tls-dhparam-vol
          secret:
            secretName: tls-dhparam

Observe o nodeSelector para tornar o rótulo do node que adicionamos quando criamos o novo node-pool.

Você pode querer mexer com os rótulos, o número de réplicas se você precisar. Mas aqui, você notará que ele monta um volume que eu nomeei como tls-dhparam-vol. Este é um Diffie Hellman Ephemeral Parameters. É assim que o geramos:

sudo openssl dhparam -out ~/documents/dhparam.pem 2048

kubectl create secret generic tls-dhparam --from-file=/home/myself/documents/dhparam.pem

kubectl create secret generic tls-dhparam --from-file=/home/myself/documents/dhparam.pem

Além disso, observe que estou usando a versão “0.9.0-beta_5” para a imagem do controlador. Funciona bem, sem problemas até agora. Mas fique atento às notas de versão para versões mais recentes também e faça seus próprios testes.

Novamente, vamos expor esse controlador Ingress através do Serviço do Balanceador de Carga. Vamos chamá-lo de deploy/nginx-svc.yml:

apiVersion: v1
kind: Service
metadata:
  name: nginx-ingress
spec:
  type: LoadBalancer
  loadBalancerIP: 111.111.111.111
  ports:
  - name: http
    port: 80
    targetPort: http
  - name: https
    port: 443
    targetPort: https
  selector:
    k8s-app: nginx-ingress-lb

Lembre-se do IP externo estático que reservamos acima e salvamos no LB_INGRESS_IP ENV var? Este é o que devemos colocar na seção spec:loadBalancerIP. Este é também o IP que você irá adicionar como “Um registro” no seu serviço DNS (digamos, mapeando seu “www.my-app.com.br” no CloudFlare).

Finalmente, podemos criar a configuração Ingress por si própria, vamos criar um deploy/ingress.yml como este:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: "nginx"
    nginx.org/ssl-services: "web-svc"
    kubernetes.io/ingress.global-static-ip-name: ip-web-production
    ingress.kubernetes.io/ssl-redirect: "true"
    ingress.kubernetes.io/rewrite-target: /
spec:
  tls:
    - hosts:
      - www.my-app.com.br
      secretName: cloudflare-secret
  rules:
    - host: www.my-app.com.br
      http:
        paths:
        - path: /
          backend:
            serviceName: web-svc
            servicePort: 80

Tenha cuidado com as anotações acima que conectam tudo. Elas vinculam o serviço NodePort que criamos para os pods da web com o controlador ingress nginx e adiciona a terminação SSL através dessa spec:tls:secretName.

Como você cria isso? Primeiro, você deve comprar um certificado SSL – novamente, usando CloudFlare como o exemplo.

Quando você terminar de comprar, o provedor deve lhe dar os arquivos secretos a serem baixados (mantenha-os seguros! Uma pasta pública de depósito não é segura!). Então, você deve adicioná-los à infraestrutura desse jeito:

kubectl create secret tls cloudflare-secret \
--key ~/downloads/private.pem \
--cert ~/downloads/fullchain.pem

Agora que editamos toda uma série de arquivos, podemos implantar toda a pilha do balanceador de carga:

kubectl create -f deploy/default-web.yml
kubectl create -f deploy/default-web-svc.yml
kubectl create -f deploy/nginx.yml
kubectl create -f deploy/nginx-svc.yml
kubectl create -f deploy/ingress.yml

Esta configuração NGINX Ingress baseia-se no artigo do site de Zihao Zhang. Também há exemplos no repositório incubador de kubernetes. Você pode querer verificar isso também.

Se você fez tudo certo até agora, https://www.my-app-com.br deve carregar seu aplicativo da Web. Você pode querer verificar o Tempo para o Primeiro-Byte (TTFB). Você pode fazer isso através do CloudFlare da seguinte maneira:

curl -vso /dev/null -w "Connect: %{time_connect} \n TTFB: %{time_starttransfer} \n Total time: %{time_total} \n" https://www.my-app.com.br

Ou, se você estiver tendo TTFB lento, pode ignorar o CloudFlare fazendo isso:

curl --resolve www.my-app.com.br:443:111.111.111.111 https://www.my-app.com.br -svo /dev/null -k -w "Connect: %{time_connect} \n TTFB: %{time_starttransfer} \n Total time: %{time_total} \n"

TTFB deve estar na proximidade de um segundo ou menos. Qualquer coisa muito além pode significar um problema em seu aplicativo. Você deve verificar os tipos de máquina da instância do node, o número de trabalhadores carregados por pod, a versão do proxy CloudSQL, a versão do controlador NGINX e assim por diante. Este é um procedimento de teste e erro, até onde eu sei. Inscreva-se em serviços como o Loader ou mesmo o Web Page Test para informações.

Atualizações Rolling

Agora que tudo está pronto e funcionando, como realizamos as Atualizações Rolling que mencionei no início? Primeiro, você dá um git push para o repositório do Registro de Contêiner e aguarda a criação da imagem do Docker.

Lembre-se que eu disse para deixar um gatilho marcar a imagem com um número de versão aleatório? Vamos usá-lo (você pode vê-lo na lista do Histórico de Compilação no Registro do Contêiner, no console do Google Cloud):

kubectl set image deployment web my-app=gcr.io/my-project/my-app:1238471234g123f534f543541gf5 --record

Você deve usar o mesmo nome e imagem que está declarado no deploy/web.yml de cima. Isso começará a lançar a atualização adicionando um novo pod, depois encerrando um pod e assim por diante, até que todos eles sejam atualizados, sem tempo de inatividade para seus usuários.

As Atualizações Rolling devem ser realizadas com cuidado. Por exemplo, se sua nova implantação requer uma migração de banco de dados, então você deve adicionar uma janela de manutenção (que significa: faça isso quando há, de pouco para nenhum tráfego, assim como no meio da noite). Então, você pode executar o comando migrar assim:

kubectl get pods # to get a pod name

kubectl exec -it my-web-12324-121312 /app/bin/rails db:migrate

# you can also bash to a pod like this, but remember that this is an ephemeral container, so file you edit and write there disappear on the next restart:

kubectl exec -it my-web-12324-121312 bash

Para reimplantar tudo sem restaurar para uma Atualização Rolling, você deve fazer isso:

kubectl delete -f deploy/web.yml && kubectl apply -f deploy/web.yml

Você encontrará uma explicação mais completa no artigo de Ta-Ching.

Bônus: Instantâneos Automáticos

Um item que eu tinha na minha lista “Eu queria/precisava”, no início, é a capacidade de ter um armazenamento montável, persistente com backups/instantâneos automáticos. O Google Cloud fornece metade disso por enquanto. Você pode criar discos persistentes para montar em seus pods, mas não tem um recurso para fazer backup automaticamente. Pelo menos, ele possui APIs para inserir o instantâneo manualmente.

Para este exemplo, vamos criar um novo disco SSD e formata-lo primeiro:

gcloud compute disks create --size 500GB my-data --type pd-ssd

gcloud compute instances list

O último comando é para que possamos copiar o nome de uma instância de node. Digamos que seja gke-my-web-app-default-pool-123-123. Vamos anexar o disco my-data a ele:

gcloud compute instances attach-disk gke-my-web-app-default-pool-123-123 --disk my-data --device-name my-data

gcloud compute ssh gke-my-web-app-default-pool-123-123

O último comando ssh está na instância. Podemos listar os discos anexados com o sudo lsblk e você verá o disco de 500GB, provavelmente, como /dev/sdb, mas certifique-se de que está correto porque vamos formatá-lo!

sudo mkfs.ext4 -m 0 -F -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/sdb

Agora podemos sair da sessão SSH e separar o disco:

gcloud compute instances detach-disk gke-my-web-app-default-pool-123-123 --disk my-data

Você pode montar este disco em seus pods, adicionando o seguinte ao seu yaml de implantação:

spec:
  containers:
    - image: ...
      name: my-app
      volumeMounts:
        - name: my-data
          mountPath: /data
          # readOnly: true
   # ...
   volumes:
     - name: my-data
       gcePersistentDisk:
         pdName: my-data
         fsType: ext4

Agora, vamos criar um arquivo de implantação CronJob como deploy/auto-snapshot.yml:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: auto-snapshot
spec:
  schedule: "0 4 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: auto-snapshot
            image: grugnog/google-cloud-auto-snapshot
            command: ["/opt/entrypoint.sh"]
            env:
            - name: "GOOGLE_CLOUD_PROJECT"
              value: "my-project"
            - name: "GOOGLE_APPLICATION_CREDENTIALS"
              value: "/credential/credential.json"
            volumeMounts:
              - mountPath: /credential
                name: editor-credential
          volumes:
            - name: editor-credential
              secret:
                secretName: editor-credential

Como já fizemos antes, você precisará criar outra Conta de Serviço com permissões de editor na seção “IAM & admin” do console do Google Cloud, depois baixe a credencial JSON e, finalmente, carregue-a assim:

kubectl create secret generic editor-credential \
--from-file=credential.json=/home/myself/download/my-project-1212121.json

Observe também que, como um trabalho cron normal, há um parâmetro de cronograma que você pode querer mudar. No exemplo, “0 4 * * *”, significa que ele executará o instantâneo todos os dias às 4 da manhã.

Confira o repositório original desta solução para obter mais detalhes.

E isso deve ser tudo por enquanto!

Como eu disse no começo, este não é um procedimento completo, apenas destaques de algumas das partes importantes. Se você é novo no Kubernetes, você acabou de ler sobre Implantação, Service, Ingress, mas você tem ReplicaSet, DaemonSet e muito mais para brincar.

Eu acho que isso também já está extenso de mais para adicionar uma explicação de configuração de alta disponibilidade de várias regiões, então vamos parar por aqui.

Quaisquer correções ou sugestões são mais do que bem-vindas porque ainda estou no processo de aprendizagem, e há uma série de coisas que ainda não conheço.

***

Artigo traduzido com autorização do autor. Publicado originalmente em: http://www.akitaonrails.com/2018/01/09/my-notes-about-a-production-grade-ruby-on-rails-deployment-on-google-cloud-kubernetes-engine