Fazendo deploy de NixOS na nuvem

Introdução

Este artigo demonstra como fazer deploy automaticamente de uma máquina NixOS. Temos 2 formas principais de fazer deploy de uma máquina NixOS: iniciando diretamente uma máquina com uma imagem NixOS e fazendo deploy das nossas configurações remotamente, ou usando outro sistema Linux e sobrescrevendo o SO usando uma ferramenta como kexec. Você pode até escrever sua própria ISO para que já venha com suas configurações, mas esse é assunto para outro artigo. Vou mostrar como sobrescrever um SO com NixOS automaticamente com literalmente um clique.

Cloud-init

O papel do Cloud-init aqui será nos permitir acessar a máquina instalada via SSH. Ele instalará uma chave pública SSH; consequentemente, precisa de um user para manter essa chave. Isso precisa ser um arquivo de template, pois será interpretado pelo template do Terraform (tftpl). Crie um arquivo chamado templates/user-data.yaml.tftpl:

#cloud-config
users:
  - name: ${user}
    hostname: ${hostname}
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    ssh_authorized_keys:
      - ${ssh_public_login_key}
    lock_passwd: true

write_files:
  - path: /home/${user}/.ssh/id_rsa
    permissions: '0600'
    content: |
      ${indent(6, nixos_ssh_private_deploy_key)}
    owner: '${user}:${user}'
    defer: true

  - path: /root/.ssh/id_rsa
    permissions: '0600'
    content: |
      ${indent(6, nixos_ssh_private_deploy_key)}
    owner: 'root:root'
    defer: true

  # Você pode opcionalmente instalar uma chave de deploy para um repositório do github, contendo sua configuração NixOS por exemplo
  - path: /home/${user}/.ssh/id_ed25519
    permissions: '0600'
    content: |
      ${indent(6, playbook_ssh_private_deploy_key)}
    owner: '${user}:${user}'
    defer: true
  - path: /root/.ssh/id_ed25519
    permissions: '0600'
    content: |
      ${indent(6, playbook_ssh_private_deploy_key)}
    owner: 'root:root'
    defer: true

NixOS

Primeiro, precisamos criar o arquivo de configuração da máquina NixOS. Usaremos flakes, pois é uma boa prática e não é difícil de criar, no seu templates/flake.nix:

{
  description = "Simple NixOS config for cloud instance";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
    disko.url = "github:nix-community/disko";
  };

  outputs = { self, nixpkgs, disko, ... }: {
    nixosConfigurations.<user> = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        disko.nixosModules.disko
        ./disko-config.nix
        ({ pkgs, ... }: {
          networking.hostName = "<user>";
          time.timeZone = "UTC";

          services.openssh.enable = true;
          services.openssh.settings.PasswordAuthentication = false;

          users.users.<user> = {
            isNormalUser = true;
            extraGroups = [ "wheel" ];
            # preencha este campo com sua(s) chave(s) pública(s) ssh
            openssh.authorizedKeys.keys = [
              "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGoIMYLhnlfKRIbKMrD5ABT+KhtXL4HqVTCrkP36KJS <email>"
            ];
          };

          # Podemos instalar pacotes que serão usados pelo comando cloud-init, incluindo o próprio cloud-init.
          environment.systemPackages = with pkgs; [ git ansible cloud-init ];
          security.sudo.wheelNeedsPassword = false;
          system.stateVersion = "25.05";

          boot.loader.systemd-boot.enable = true;
          boot.loader.efi.canTouchEfiVariables = true;
          boot.kernelPackages = pkgs.linuxPackages_latest;

          # A configuração declarativa do NixOS nos permite definir serviços systemD poupando carga de ferramentas como ansible.
          systemd.services.<user> = {
            description = "Serviço do <user>";
            wantedBy = [ "multi-user.target" ];
            serviceConfig = {
              ExecStart = "${pkgs.bash}/bin/bash -c 'echo The systemd ran > /home/<user>/log'";
              User = "<user>";
              Group = "wheel";
              Restart = "on-failure";
              RestartSec = "5s";
            };
          };
        })
      ];
    };

    packages.x86_64-linux.<user> =
      self.nixosConfigurations.<user>.config.system.build.toplevel;

    packages.x86_64-linux.<user>-disko =
      self.nixosConfigurations.<user>.config.system.build.diskoScript;
  };
}

Nossa configuração de user está pronta; porém, precisamos configurar a partição do SO. Felizmente, o NixOS tem o pacote disko. Criaremos um módulo para a configuração do disko. Crie um arquivo chamado templates/disko-config.nix:

{
  disko.devices.disk.main = {
    device = "/dev/nvme0n1";
    type = "disk";
    content = {
      type = "gpt";
      partitions = {
        boot = {
          size = "1G";
          type = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B";
          content = {
            type = "filesystem";
            format = "vfat";
            mountpoint = "/boot";
          };
        };
        root = {
          size = "100%";
          content = {
            type = "filesystem";
            format = "ext4";
            mountpoint = "/";
          };
        };
      };
    };
  };
}

Terraform

O Terraform vai de fato fazer deploy de tudo. Ele vai unir a instalação do Linux, a execução do Cloud-init e a instalação do NixOS, através do provider Terraform do NixOS anywhere. Você pode usar qualquer provider de nuvem/infraestrutura que quiser. O provider da AWS é fácil de usar e seu ecossistema é grande. Este exemplo usará uma instância EC2. No seu main.tf configure:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.region
}

data "aws_ami" "instance" {
  most_recent = var.most_recent
  filter {
    name   = "name"
    values = [var.ami_filter_name]
  }

  filter {
    name   = "virtualization-type"
    values = [var.virtualization_type]
  }

  filter {
    name   = "is-public"
    values = [var.is_public]
  }

  filter {
    name   = "free-tier-eligible"
    values = [var.is_free_tier_eligible]
  }

  filter {
    name   = "architecture"
    values = [var.architecture]
  }

  owners = var.owners
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.instance.id
  instance_type = var.instance_type

  tags = {
    Name = var.instance_name
  }

  tenancy = var.tenancy

  user_data = templatefile(
    var.template_path,
    {
      user                 = var.user
      ssh_public_login_key = file(var.ssh_public_login_key)
      hostname             = var.hostname

      playbook_ssh_private_deploy_key      = file(var.playbook_ssh_private_deploy_key)
      playbook_ssh_private_deploy_key_path = var.playbook_ssh_private_deploy_key_path
      app_ansible_playbook_name            = var.app_ansible_playbook_name
      app_ansible_playbook_url             = var.app_ansible_playbook_url
      app_inventory_name                   = var.app_inventory_name

      nixos_ssh_private_deploy_key = file(var.nixos_ssh_private_deploy_key)
      nixos_config_url             = var.nixos_config_url
    }
  )
}

output "public_ip" {
  value       = aws_instance.web.public_ip
  description = "O endereço IPv4 da instância"
}

output "public_dns" {
  value       = aws_instance.web.public_dns
  description = "O nome DNS público da instância"
}

output "security_groups" {
  value       = aws_instance.web.security_groups
  description = "Os grupos de segurança associados à instância"
}

output "arn" {
  value       = aws_instance.web.arn
  description = "O ARN da instância"
}

output "tags" {
  value       = aws_instance.web.tags
  description = "O user da instância"
}

module "deploy" {
  source                 = "github.com/nix-community/nixos-anywhere/terraform/all-in-one"

  nixos_system_attr      = "../templates#packages.x86_64-linux.<your-user>"
  nixos_partitioner_attr = "../templates#packages.x86_64-linux.<your-user>-disko"

  target_host            = module.ec2.public_ip
  instance_id            = module.ec2.public_ip

  install_ssh_key = file("~/.aws/keys/login_keys/instance")

  deployment_ssh_key = file("~/.aws/keys/login_keys/instance")

  target_user = "<your-user>"

  install_user = module.ec2.tags["Name"]
}

Vamos interpretar o arquivo user-data usando a função embutida templatefile() do Terraform e ligá-lo ao user_data. Você pode configurar qualquer SO Linux que quiser, ex: Ubuntu. Você pode definir outputs úteis como o ARN ID, IP público ou nome de domínio para se conectar à instância sem acessar o painel web.

Depois disso, você pode definir as variáveis manualmente ou deixar este arquivo como modelo e criar outro arquivo que importa este como módulo em example/main.tf como boa prática:

module "ec2" {
  source = "../"

  region        = "<region>"
  instance_type = "<instance-type>"
  instance_name = "<instance-name>"

  most_recent           = true
  ami_filter_name       = "<image-name>"
  architecture          = "x86_64"
  is_public             = true
  is_free_tier_eligible = true
  owners                = ["<image-owner>"]

  virtualization_type = "hvm"

  tenancy              = "default"
  ssh_public_login_key = "<public-ssh-key>"
  hostname             = "<instance-name>"
  user                 = "<instance-name>"
  app_inventory_name   = "localhost,"

  # Este é o caminho para o template user-data interpretado pelo Terraform, será usado no cloud-init
  template_path        = "<path-to-user-data>"

  # Executar aplicação
  app_ansible_playbook_url             = "<ansible-playbook-repo>"
  app_ansible_playbook_name            = "<playbook-name>"

  # Estes são opcionais, caso queira usar um repositório de playbook Ansible para
  # executar o comando `ansible-pull` e este repositório é privado, você pode usar uma chave de deploy
  playbook_ssh_private_deploy_key      = "<public-ssh-key>"
  playbook_ssh_private_deploy_key_path = "<public-ssh-key>"

  # Configuração NixOS
  nixos_ssh_private_deploy_key = "~/.aws/keys/deploy_keys/nixos"
  nixos_config_url             = "[email protected]:<user>/nixos_config.git" # Você pode buscar uma configuração para seu NixOS de um repositório
}

output "public_ip" {
  value       = module.ec2.public_ip
  description = "O endereço IPv4 da instância"
}

output "public_dns" {
  value       = module.ec2.public_dns
  description = "O nome DNS público da instância"
}

output "security_groups" {
  value       = module.ec2.security_groups
  description = "Os grupos de segurança associados à instância"
}

output "arn" {
  value       = module.ec2.arn
  description = "O ARN da instância"
}

output "user" {
  value       = module.ec2.tags["Name"]
  description = "O user da instância"
}

module "deploy" {
  source                 = "github.com/nix-community/nixos-anywhere/terraform/all-in-one"

  nixos_system_attr      = "../templates#packages.x86_64-linux.<your-user>"
  nixos_partitioner_attr = "../templates#packages.x86_64-linux.<your-user>-disko"

  target_host            = module.ec2.public_ip
  instance_id            = module.ec2.public_ip

  install_ssh_key = file("~/.aws/keys/login_keys/instance")

  deployment_ssh_key = file("~/.aws/keys/login_keys/instance")

  target_user = "<your-user>"

  install_user = module.ec2.tags["Name"]
}

Note que temos duas etapas aqui, divididas em dois módulos: o deploy de uma máquina, e a segunda etapa, onde o Terraform executará os recursos do nixos-anywhere naquela máquina da primeira etapa, onde o kexec é executado internamente. Portanto, na segunda etapa temos duas chaves privadas; a que já está registrada na instância (deployment_ssh_key), que instalamos no script cloud-init, e a outra que será instalada após a conclusão da instalação do NixOS (install_ssh_key), sua respectiva chave pública será usada para acessarmos esta instância. A mesma lógica se aplica ao usuário, com a ressalva de que não podemos escolher o nome de user da instância a menos que tenhamos criado a ISO nós mesmos; verifique esses detalhes com seu provider de imagem.

Deploy

Execute estes comandos no mesmo nível do seu main.tf para instalar as dependências e executar o Terraform. Note que levará alguns minutos para fazer deploy completamente do software final.

terraform init
terraform apply