Featured image of post 构建下一代个人开发环境

构建下一代个人开发环境

背景

我之前在记一次SSL握手异常的诊断文章末尾提到,有空要研究一下声明式的系统NixOS了。最近正好,为了探索新的个人开发环境方案,研究了一下Nix以及这个生态,有了初步了解。基本解决了我的开发环境构建问题,这里稍作总结,如果你刚好也在寻觅,盼对你有些许启发。

个人开发环境的几次迭代

我曾经尝试过多个方式,总的来说有四个迭代版本,你可以看下你当前落在哪个阶段?:)

  1. 最传统方式:我们的个人开发环境,从一台机器开始安装各种软件,搞各种配置,在切换机器后,我们又要再来一次。美其名曰纯手工打造,古法造就,其实就是不可自动化、工业化嘛:D
  2. 基于一些工具半自动化:如之前在这篇文章基于Cloud-init定制化虚拟机尝试定制部分工具或配置,但我觉得不是正解,因为cloud-init只有最开始,后续的变更不便,当然还有其它缺点不一一细数。那么这阶段你如果看过我这个初窥门径之—Ansible快速入门与实践的话,使用Ansible也能达到部分功能,它还是太“过程式”了,何况是否远程操作我们并不看重。至于terraform等,从我的经验来看,也不是特别适合解决这个问题。
  3. 容器化:用一个Dockerfile描述环境及配置,将其封装在一个容器中,这使用起来确实方便多了。我曾经给团队制作了一个通用镜像,有不少同学在使用,也获得一些反馈,包括我自己也觉得最大弊端可能是:对于开发环境,隔离不是硬需求,特别是容器的隔离性,导致如在内部做的一些fs操作在重建容器后(可能有新版本)都消失,除非主动保存或只在挂载卷上进行操作,这也太限制了。本来我还想有机会分享一下容器化方案,借助Dagger等重构一下构建流程以满足更好的可定制性,但是在找到下一代方案后,我放弃了在这条路上继续前进的可能性。
  4. 声明式开发环境:基于devbox或nix定义一套所需要的程序及配置,在需要的时候一键构造出来。裸金属,原生的味道。

这篇文章主要讲一下这个声明式开发环境如何构建。

我们的目标是什么?

想要更好的管理个人开发环境,方便复用和迁移。比如团队级共享一套,新人就相当友好的快速获得了自己的开发环境,不会反复趟进一些不必要的坑里。另外当你像我一样有多台开发机器,想获得一致的开发体验,比如我特别喜欢之前推荐的这套nvim的配置,但是依赖特别多,跨不同OS时不时遇上一些失败。所以我想要的是:

  • 软件安装能力。能快速方便的安装所需软件,特别是可以指定某个版本(比如操作k8s集群就要求你的kubectl是上下2个版本差)。
  • 外部程序构建安装能力。如go install一样可以将外部某些程序安装在系统内。
  • 程序的配置功能。有一些配置你经常要重复搞也很烦,比如gitconfig,比如kubeconfig,zsh等,希望这块也能被管理好。
  • 敏感信息管理。有些敏感信息比如.ssh/config等,在仓库中或许还需要加密保存,在部署时解密。

而这些信息的描述,应该是可被版本工具如git管理起来,方便持续迭代和追踪。简言之,希望这套环境是可重现(reproducible)的,且可跨多种系统。

Devbox是个好的开始,但不是终点

无意间看到devbox,甚是欣喜,它仿佛为我的目标而定。人家的标语是:

Portable, Isolated Dev Environments on any Machine。

Devbox creates isolated, reproducible development environments that run anywhere. No Docker containers or Nix lang required”。

基本使用

它的使用很简单,你可以配置一个json便定义出一个环境以及所需要的程序了。比如我们可以这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
    "packages": [
      "go@1.20",
      "docker",
      "kubectl",
      "istioctl@1.14",
      "vault",
      "earthly",
      "k9s"
    ],
    "env": {
      "WELCOME": "Welcome!"
    },
    "shell": {
      "init_hook": [
        "export PS1='📦 devbox> '"
      ],
      "scripts": {
        "hello": "echo \"Hello kevinlin\""
      }
    },
    "nixpkgs": {
      "commit": "faa1bc7353d7c0cbd92cb6bf25e794ee2e0b8282"
    },
    "include": [
      "path:plugins/vault.json",
      "path:plugins/earthly.json",
      "path:plugins/k9s.json"
    ]
  }

上述我们在packages中定义好了环境所需要的程序及指定版本的程序,以及它的配置使用include来声明。比如earthly配置文件定义,我们先在earthly.json中定义了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
    "name": "earthly",
    "version": "0.0.1",
    "match": "^earthly.*$",
    "readme": "设置earthly需要的环境变量",
    "env": {
        "EARTHLY_CONFIG": "{{ .DevboxDir }}/config.yml"
    },
    "create_files": {
        "{{ .DevboxDir }}/config.yml": "earthly/config.yml"
    },
    "init_hook": [
        "echo \"正在设置软件 \\033[33m earthly\\033[0m\""
    ]
}

上面通过create_files指定了在某个目录(plugins/earthly/config.yml)放置我们的程序配置,此处配置略。

定制软件

前文提过,有些软件我们需要通过它来构建,比如我们项目内部使用的公司内的仓库,这块可以借助devbox支持的flake.nix来实现(关于flake是什么,它是Nix中重要的一个实验特性)。 我们先稍微修改一下packages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 "packages": [
    "go@1.20",
    "docker",
    "kubectl",
    "istioctl@1.14",
    "vault",
    "earthly",
    "path:deps/lucian",
    "k9s"
  ],

然后我们定义一个flake.nix文件,里面指定如何构建这个程序(下面是go程序的示例):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
  description = "lucian 用于生成相关协议测试文件的工具";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

  outputs = { self, nixpkgs }:

  let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; };
  
    go-src= pkgs.fetchgit {
      url = "https://xxxx/tools.git";
      rev = "master"; 
      sha256 = "...";
    };
  in

  {
    packages.${system} = {

      lucian = pkgs.buildGoModule rec {
        pname = "lucian";
        version = "0.1.0";
        src = go-src;
        vendorHash = "...";

        subPackages = [ "cmd/lucian" ];
      };
    };

    defaultPackage.${system} = self.packages.${system}.lucian;
  };
}

这样在我们使用devbox shell后,便会获得一个具备上述定制的各种软件和配置的一个交互shell了。如果你擅长在终端开发,后面都不用看了。如果你有时也会用vscode等,便会遇上一个难题,它不能兼容vscode远程开发场景(特别是补全等都会失效,因为环境变量等无法传递给vscode远程开发的终端).即便官方提供了插件也是一用就崩(可能是疏于维护?)。且从devbox所提供的能力来看,单个仓库单独进入某个shell还行,而我想,除非我切换,它应该一直就处在我声明的开发环境中?!所以说,这个非终点,但它给了一个很好的示例,也指明了其底层所依赖的Nix或是此问题的终解。

注:以上代码,可以参考我的仓库DevEnvBootstrap

Nix比较完美解决问题

我这里不想再科普或搬运一些资料,关于什么是Nix,我摘要了几个关键点。

  • Declarative, reproducible Development environments
  • Declarative, reproducible package builds
  • Largest packages repository(binary deployment)
  • Declarative Linux system

它的多版本支持以及依赖完全独立是相当吸引人的特性。其它特点挺多,这里不一一言表。 这里我们需要借助Nix的home-manager模块来方便管理个人软件及配置,还需要借助flake更好的组织我们的各个nix文件。

使用flake.nix定义整体结构

我们只要简单定义一个flake.nix来声明一些inputs/outputs即可用于传递依赖和获得构建集(软件包)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
  description = "kevin's HomeManager Flake";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    # nvimdots使用
    nvimdots.url = "github:ayamir/nvimdots";

    # 给指定版本的kubectl使用的nixpkgs
    nixpkgs-for-kubectl.url = "github:NixOS/nixpkgs/e7e54ace729a1e88177c0121d05f35352b05aed8";

    # home-manager,用于管理用户配置
    home-manager = {
      # url = "github:nix-community/home-manager/release-23.05";
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

  };
  outputs = { self, nixpkgs, nixpkgs-for-kubectl,  home-manager, nvimdots, ... } @inputs: 
    let 
      # 全局你可能只需要修改这一个变量
      user = "kevin";
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
          specialArgs = {
              inherit user nvimdots nixpkgs-for-kubectl;
              pkgs-for-kubectl = import nixpkgs-for-kubectl {
              system = system;
              config.allowUnfree = true;
            };
            };
    in {
        # 使用home-manager来管理我们的各个软件安装及配置
        homeConfigurations = {
          kevin = home-manager.lib.homeManagerConfiguration {
            inherit pkgs ;
            modules = [
                ./home-manager/home.nix
            ];
            extraSpecialArgs = specialArgs;
          };
        };

        defaultPackage.${system} = self.homeConfigurations.kevin.activationPackage;
   };
}

以上我作了简要注释。关键干活的还是 ./home-manager/home.nix

使用home.nix定义软件与配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{ config, pkgs, user, lib, nvimdots, pkgs-for-kubectl, ... }:

let 
  # 一些测试
  # userCheck = pkgs.lib.throwIfNot (user=="kevin") "User must be kevin but ${user}" user;
in
rec {
  home.username = (builtins.trace "user is ${user}" user );
  home.homeDirectory = "/data/home/${user}";
  home.stateVersion = "23.05";

  home.packages = with pkgs; [ 
    python3
    git
    openssh
    curl
    htop
    lsof
    neofetch

    tmux 
    byobu

    #docker 
    go

    #k9s 
    ( import ./apps/k9s.nix { inherit lib stdenv fetchurl; } )

    # go example
    ( import ./apps/go-example.nix { inherit (pkgs) lib buildGoModule fetchFromGitHub; } ) # 显式指明lib/buildGoModule/fetchFromGitHub属于pkgs包

    kubecm
    helm
    vault

    istioctl 
    earthly 
  ] ++ [ 
    # 这个版本是v1.23.4
    pkgs-for-kubectl.kubectl 
  ];


  imports = [
    nvimdots.nixosModules.nvimdots
    ./apps/zsh.nix
    ./apps/nvim.nix
    ./apps/nvimdots.nix
    # 设置tmux的配置
    ./apps/tmux.nix

    # 设置一些环境变量
    ./envs/vars.nix

    # 设置一些配置
    ./configs/kubeconfig.nix
    ./configs/gitconfig.nix
    ./configs/neofetch.nix
    ./configs/earthly.nix
  ];
} 

这里通过home.packages定义了一些依赖的软件,并且使用import xx.nix来引用其它nix构建一些我们所需要的自定义软件(可能不会在软件源中的)。同时也通过传递固定来源的kubectl包URL来安装指定版本的工具。

对于程序的配置,比如zsh的配置,我们可以这样定义apps/zsh.nix

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
  programs.zsh = {
    enable = true;
    enableCompletion = true;
    enableAutosuggestions = true;
    syntaxHighlighting.enable = true;

    oh-my-zsh = {
      enable = true;
      plugins = [ "docker-compose" "docker" ];
      theme = "dst";
    };
    initExtra = ''
      bindkey '^f' autosuggest-accept

      # goproxy
      go env -w GOPROXY="https://kevin:......@goproxy.woa.com|direct"
      go env -w GOPRIVATE=""
      go env -w GOSUMDB="...."

      #earthly
      export EARTHLY_SECRETS="GOPROXY=$(go env GOPROXY),GOSUMDB=$(go env GOSUMDB)"
      neofetch
    '';

    shellAliases = {
        k = "kubectl";
        km = "kubecm";
        b = "byobu";

    };
  };
}

这里展示了其插件oh-my-zsh的配置,alias设定以及一些其它额外的指令在initExtra中。

对于私有的一些配置如gitconfig我们可以在configs/gitconfig.nix这样定义:

1
2
3
4
{
    # 设置git配置。 其指向的../files/gitconfig是你打算声明并同步到~/.gitconfig的文件。
    home.file.".gitconfig".source = ../files/gitconfig;
}

对于私有的仓库等,上面有import ./apps/go-example.nix,我们也看下它的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{ lib, buildGoModule, fetchFromGitHub }:

buildGoModule rec {
  pname = "go-dnsmasq";
  version = "unstable-2022-07-19"; # 添加实际的提交日期

  src = fetchFromGitHub {
    owner = "kevin1sMe";
    repo = pname;
    rev = "6505b037c29de3b4cc6e1ddfab570cefd360c395"; # 添加你想要打包的版本的commit hash
    sha256 = "sha256-3b4K0kx10SJ9OAv6cEK52PBSRJC/bUuULKdufBFPapA="; # 添加正确的SHA-256哈希
  };

  vendorSha256 = "sha256-0DqS/scc4MD+KVs9lGC6t0hHNCaYLf56k0W1bk5cE9s="; # 添加正确的SHA-256哈希

  subPackages = [ "cmd/dnsmasq" ]; # 打包的子目录路径

  meta = with lib; {
    description = "A golang dnsmasq application"; # 添加合适的描述
    homepage = "https://github.com/kevin1sMe/go-dnsmasq";
    license = licenses.mit; # 确保许可证匹配实际项目许可证
    platforms = platforms.unix;
    maintainers = with maintainers; [ kevinlin ]; # 将其替换为你的名字
  };
}

有了以上的一些功能后,我们的目标基本实现了。现在如果安装了home-manager只要执行一个命令:

1
home-manager switch --flake .#kevin

再等一会(首次可能要等3-5分钟,视你网速而定),一个如你所愿的环境原地诞生了。如果没有安装home-manager,你只要有nix也可以一行命令搞定:

1
nix run nixpkgs#home-manager -- switch --flake .#kevin

后续你增删改各种设定,切换是秒级(几秒级)。

后记

这个方案我在Ubuntu22.04TLS测试过后,迁移到CentOS,Debain以及公司的TencentOS都不需要修改一行代码,毕竟都是Linux嘛:) 整个过程相当顺畅,一行命令回车,站起来,取杯水,喝完,一个干净清爽的开发环境就归你了。基于MacOS,理论上只要稍作修改即可同时支持,未来我也会继续打磨它。

很遗憾为了不把文章写得又臭又长,本文有不少概念我没有细讲,总结来说,在声明式的加成下,结合Nix及函数式很好的可重现构建的能力,你的环境会很稳定,只要成功过,想坏真难。

再次注:以上实践或代码呈现非最终版本,显然有优化空间,比如对于nix传参就可以通过callPackages等重构。整个过程用了个把月非常零碎时间边学边实践,只是阶段性验证了可行性,请理解,如有错误欢迎指出。

感谢阅读。

参考资料

看到这参考资料你也假装读完了,再次感谢阅读:P

-EOF