امروز ما یک وبلاگ از آدام گوردون بل در Earthly را نشان می دهیم که در مورد چگونگی کارکرد BuildKit ، فناوری توسعه یافته توسط Docker و جامعه و نوشتن یک پیشخوان ساده می نویسد. Earthly از BuildKit در محصول خود استفاده می کند.

مقدمه

ظروف چگونه ساخته می شوند؟ معمولاً از مجموعه عباراتی مانند `RUN`،` FROM` و `COPY` که در یک Dockerfile قرار می گیرند و ساخته می شوند. اما چگونه این دستورات به یک تصویر ظرف و سپس یک ظرف در حال اجرا تبدیل می شوند؟ ما می توانیم با درک مراحل درگیر و ایجاد یک تصویر ظرف ، شهودی برای چگونگی این کار ایجاد کنیم. ما یک تصویر را به صورت برنامه نویسی ایجاد خواهیم کرد و سپس یک پیشانی نحوی پیش پا افتاده ایجاد خواهیم کرد و از آن برای ساختن تصویر استفاده خواهیم کرد. ما می توانیم از Buildpacks استفاده کنیم ، می توانیم از ابزارهای ساخت مانند Bazel یا sbt استفاده کنیم ، اما تا کنون ، رایج ترین روش ساخت تصاویر استفاده از `docker build` با Dockerfile است. تصاویر پایه آشنا Alpine ، Ubuntu و Debian همگی از این طریق ایجاد شده اند.

در اینجا مثالی از Dockerfile آورده شده است:

 FROM alpine
کپی README.md README.md
RUN echo "standard docker build"> /built.txt "Counscribe19659008] ما در این آموزش از تغییرات موجود در این Dockerfile استفاده خواهیم کرد. 

ما می توانیم آن را به این شکل بسازیم:

اما چه اتفاقی می افتد وقتی شما docker را صدا می کنید build`؟ برای درک این موضوع ، به اندکی پس زمینه احتیاج داریم. دستور up ، پورت ها برای نمایش و حجم ها برای نصب. هنگامی که یک تصویر را docker می کنید ، در زمان اجرا یک کانتینر شروع می شود.

من می خواهم به طور مشابه در مورد تصاویر و ظروف فکر کنم. اگر تصویری مانند یک اجرایی ، سپس یک ظرف مانند یک فرآیند است. شما می توانید چندین ظرف را از یک تصویر اجرا کنید ، و یک تصویر در حال اجرا اصلاً تصویری نیست بلکه یک ظرف است.

در ادامه قیاس ما ، BuildKit یک کامپایلر است ، دقیقاً مانند LLVM اما در حالی که یک کامپایلر کد منبع و کتابخانه ها را می گیرد و یک BuildKit ta قابل اجرا تولید می کند یک Dockerfile و یک مسیر پرونده ایجاد کرده و یک تصویر ظرف ایجاد می کند.

ساخت Docker با استفاده از BuildKit ، یک Dockerfile را به یک تصویر Docker ، تصویر OCI یا قالب تصویر دیگر تبدیل می کند. در این مرور ، ما در درجه اول مستقیماً از BuildKit استفاده خواهیم کرد.

این آغازگر استفاده از BuildKit از طریق خط فرمان برخی زمینه های مفید در استفاده از BuildKit ، `buildkitd` و` buildctl` را فراهم می کند. با این حال ، تنها پیش شرط امروز اجرای "ساخت installkit" یا مراحل معادل سیستم عامل مناسب است.

کامپایلرها چگونه کار می کنند؟

یک کامپایلر سنتی کد را به زبانی سطح بالا می گیرد و به پایین می آورد زبان سطح در بیشتر کامپایلرهای پیش از موعد معمول ، هدف نهایی کد ماشین است. کد ماشین یک زبان برنامه نویسی سطح پایین است که CPU شما آن را می فهمد.

ℹ️ Fun Fact: Machine Code VS. اسمبلی

کد ماشین به صورت باینری نوشته می شود. این مسئله درک آن را برای انسان سخت می کند. کد اسمبلی نمایشی متن ساده از کد ماشین است که به گونه ای طراحی شده است که تا حدی برای انسان قابل خواندن است. به طور کلی یک نقشه برداری 1-1 بین دستورالعمل هایی که دستگاه می فهمد (در کد ماشین) و OpCodes در Assembly

وجود دارد که با استفاده از پیش فرض Clang برای LLVM ، کامپایل C کلاسیک "Hello، World" به کد مونتاژ x86 به نظر می رسد این:

ایجاد یک تصویر از dockerfile به روشی مشابه عمل می کند:

BuildKit از Dockerfile و زمینه ساخت عبور می کند ، که این فهرست کار موجود در نمودار بالا است. به عبارت ساده شده ، هر خط در dockerfile در تصویر حاصل به لایه تبدیل می شود. یکی از تفاوت های مهم ایجاد ساختمان تصویر با کامپایل کردن ، این زمینه ساخت است. ورودی یک کامپایلر محدود به کد منبع است ، در حالی که "docker build" به سیستم پرونده میزبان به عنوان ورودی اشاره می کند و از آن برای انجام اعمالی مانند "COPY" استفاده می کند.

There's a Catch

نمودار قبلی تدوین "سلام ، جهان" در یک مرحله جزئیات مهم را از دست داد. سخت افزار کامپیوتر یک چیز منفرد نیست. اگر هر کامپایلر یک نقشه نگاری دستی از یک زبان سطح بالا به کد دستگاه x86 باشد ، انتقال به پردازنده Apple M1 کاملاً چالش برانگیز است زیرا مجموعه دستورالعمل متفاوتی دارد.

نویسندگان کامپایلر با تقسیم تدوین به مراحل مختلف بر این چالش غلبه کرده اند. فازهای سنتی جلو ، باطن و وسط هستند. مرحله میانی را بعضاً بهینه ساز می نامند ، و در درجه اول با نمایش داخلی (IR) سرو کار دارد. در عوض ، شما فقط به یک باطن جدید نیاز دارید. در اینجا مثالی از آنچه در LLVM به نظر می رسد آورده شده است:

Intermediate Representations

این روش چندگانه باطن به LLVM این امکان را می دهد تا ARM ، X86 و بسیاری دیگر از معماری های ماشین را با استفاده از نمایندگی LLVM Intermediate (IR) به عنوان پروتکل استاندارد هدف قرار دهد. LLVM IR یک زبان برنامه نویسی قابل خواندن توسط انسان است که باطن ها باید بتوانند آن را به عنوان ورودی استفاده کنند. برای ایجاد یک باطن جدید ، باید یک مترجم از LLVM IR به کد دستگاه مورد نظر خود بنویسید. این ترجمه کار اصلی هر backend است.

هنگامی که این IR را دارید ، پروتکلی دارید که فازهای مختلف کامپایلر می تواند از آن به عنوان رابط استفاده کند و می توانید نه تنها بسیاری از backend ها بلکه بسیاری از frontend ها را نیز بسازید. LLVM دارای زبانهای مختلفی از جمله C ++ ، Julia ، Objective-C ، Rust و Swift است.

اگر می توانید ترجمه ای از زبان خود به LLVM IR بنویسید ، LLVM می تواند آن IR را به كد ماشین برای تمام باطنهایی كه پشتیبانی می كند ترجمه كند. این عملکرد ترجمه وظیفه اصلی یک کامپایلر frontend است.

در عمل ، چیزهای زیادی بیش از این است. Frontend ها باید فایلهای ورودی را رمزگذاری و تجزیه کنند و آنها باید خطاهای دلپذیر را بازگردانند. باطن ها معمولاً برای انجام بهینه سازی های خاص هدف و کاربرد اکتشافی دارند. اما برای این آموزش ، نکته مهم این است که داشتن یک نمایش استاندارد در نهایت پلی است که بسیاری از قسمتهای جلویی را با بسیاری از قسمتهای عقب بهم متصل می کند. این رابط مشترک نیاز به ایجاد یک کامپایلر برای هر ترکیبی از زبان و معماری ماشین را برطرف می کند. این یک ترفند ساده اما بسیار نیرومند است!

BuildKit

تصاویر ، برخلاف اجرایی ها ، سیستم فایل جدا شده خود را دارند. با این وجود ، کار ساخت یک تصویر بسیار شبیه به یک کامپایل اجرایی است. آنها می توانند نحو متفاوتی داشته باشند (dockerfile1.0، dockerfile1.2) و نتیجه باید چندین معماری ماشین را هدف قرار دهد (arm64 در مقابل x86_64). ] - BuildKit Readme

این شباهت در مورد سازندگان BuildKit از بین نرفت. BuildKit نمایندگی متوسط ​​خود را دارد ، LLB. و در مواردی که LLVM IR دارای مواردی مانند فراخوانی عملکرد و استراتژی های جمع آوری زباله باشد ، LLB دارای سیستم پرونده های نصب شده و دستورات اجرایی است. مستقیماً.

ساخت برنامه ای تصویری

بسیار خوب ، پس زمینه کافی. بیایید به صورت برنامه نویسی LLB را برای یک تصویر تولید کنیم و سپس یک تصویر بسازیم.

ℹ️ استفاده از Go

در این مثال ، ما از Go استفاده خواهیم كرد كه به ما اجازه می دهد از كتابخانه های موجود BuildKit استفاده كنیم ، اما انجام این كار به هر زبان با پشتیبانی از Protector Buffer امكان پذیر است.

Import تعاریف LLB:

 واردات (
"github.com/moby/buildkit/client/llb"
)

ایجاد LLB برای یک تصویر آلپی:

  func createLLBState () llb.State {
 بازگشت llb.Image ("docker.io/library/alpine").
   پرونده (llb.Copy (llb.Local ("زمینه") ، "README.md" ، "README.md")).
   اجرا (llb.Args ([] رشته {"/ bin / sh" ، "-c" ، "echo " بصورت برنامه نویسی ساخته شده  "> /built.txt"})).
ریشه ()
}  

ما با استفاده از `llb.Image` معادل` FROM` را به دست می آوریم. سپس ، ما با استفاده از `File` و` Copy` ، یک فایل را از سیستم فایل محلی در تصویر کپی می کنیم. سرانجام ، ما یک دستور را اجرا می کنیم تا متن برخی از فایل ها را تکرار کند. LLB عملیات بیشتری دارد ، اما شما می توانید بسیاری از تصاویر استاندارد را با این سه بلوک بازسازی کنید.

آخرین کاری که باید انجام دهیم این است که این را به بافر پروتکل تبدیل کرده و به صورت استاندارد خارج کنیم:

 func main () {

dt، err: = createLLBState (). Marshal (text.TODO ()، llb.LinuxAmd64)
اگر اشتباه است! = صفر {
وحشت (خطا)
}
llb.WriteTo (dt، os.Stdout)
} 

بیایید بررسی کنیم که این با استفاده از گزینه "dump-llb" از buildctl تولید می شود:

  اجرا شود. /writellb/writellb.go |
 buildctl اشکال زدایی dump-llb |
 jq. 

این LLB قالب بندی شده JSON را دریافت می کنیم:

 {
  "Op": {
    "Op": {
      "منبع": {
        "identifier": "محلی: // زمینه" ،
        "attrs": {
          "local.unique": "s43w96rwjsm9tf1zlxvn6nezg"
        }
      }
    } ،
    "محدودیت ها": {}
  } ،
  "خلاصه": "sha256: c3ca71edeaa161bafed7f3dbdeeab9a5ab34587f569fd71c0a89b4d1e40d77f6" ،
  "OpMetadata": {
    "cap": {
      "source.local": درست است ،
      "source.local.unique": درست است
    }
  }
}
{
  "Op": {
    "Op": {
      "منبع": {
        "identifier": "docker-image: //docker.io/library/alpine: جدیدترین"
      }
    } ،
    "سکو": {
      "معماری": "amd64" ،
      "سیستم عامل": "linux"
    } ،
    "محدودیت ها": {}
  } ،
  "خلاصه": "sha256: 665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7" ،
  "OpMetadata": {
    "cap": {
      "source.image": درست است
    }
  }
}
{
  "Op": {
    "ورودی": [
      {
        "digest": "sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7",
        "index": 0
      },
      {
        "digest": "sha256:c3ca71edeaa161bafed7f3dbdeeab9a5ab34587f569fd71c0a89b4d1e40d77f6",
        "index": 0
      }
    ] ،
    "Op": {
      "فایل": {
        "اقدامات": [
          {
            "input": 0,
            "secondaryInput": 1,
            "output": 0,
            "Action": {
              "copy": {
                "src": "/README.md",
                "dest": "/README.md",
                "mode": -1,
                "timestamp": -1
              }
            }
          }
        ]
      }
    } ،
    "سکو": {
      "معماری": "amd64" ،
      "سیستم عامل": "linux"
    } ،
    "محدودیت ها": {}
  } ،
  "خلاصه": "sha256: ba425dda86f06cf10ee66d85beda9d500adcce2336b047e072c1f0d403334cf6" ،
  "OpMetadata": {
    "cap": {
      "file.base": درست است
    }
  }
}
{
  "Op": {
    "ورودی ها": [
      {
        "digest": "sha256:ba425dda86f06cf10ee66d85beda9d500adcce2336b047e072c1f0d403334cf6",
        "index": 0
      }
    ] ،
    "Op": {
      "exec": {
        "متا": {
          "args": [
            "/bin/sh",
            "-c",
            "echo "programmatically built" > /built.txt"
          ] ،
          "cwd": "/"
        } ،
        "mounts": [
          {
            "input": 0,
            "dest": "/",
            "output": 0
          }
        ]
      }
    } ،
    "سکو": {
      "معماری": "amd64" ،
      "سیستم عامل": "linux"
    } ،
    "محدودیت ها": {}
  } ،
  "خلاصه": "sha256: d2d18486652288fdb3516460bd6d1c2a90103d93d507a9b63ddd4a846a0fca2b" ،
  "OpMetadata": {
    "cap": {
      "exec.meta.base": درست است ،
      "exec.mount.bind": درست است
    }
  }
}
{
  "Op": {
    "ورودی ها": [
      {
        "digest": "sha256:d2d18486652288fdb3516460bd6d1c2a90103d93d507a9b63ddd4a846a0fca2b",
        "index": 0
      }
    ] ،
    "Op": صفر است
  } ،
  "خلاصه": "sha256: fda9d405d3c557e2bd79413628a435da0000e75b9305e52789dd71001a91c704" ،
  "OpMetadata": {
    "cap": {
      "محدودیت": درست است ،
      "platform": درست است
    }
  }
} 

با نگاهی به خروجی ، می توانیم ببینیم که چگونه کد ما به LLB ترسیم می شود.

در اینجا "کپی" ما به عنوان بخشی از FileOp است:

 "اقدام": {
              "کپی 🀄": {
                "src": "/README.md" ،
                "dest": "/README.md" ،
                "حالت": -1 ،
                "مهر زمان": -1
              } 

در اینجا نقشه ساخت ما برای استفاده در دستور "COPY" ما در نظر گرفته شده است:

 "Op": {
      "منبع": {
        "identifier": "محلی: // زمینه" ،
        "attrs": {
          "local.unique": "s43w96rwjsm9tf1zlxvn6nezg"
        }
      } 

به طور مشابه ، خروجی حاوی LLB است که با دستورات "RUN" و "FROM" ما مطابقت دارد.

ساخت LLB ما

برای ساختن تصویر خود ، ابتدا باید "buildkitd" را شروع کنیم:

 docker run --rm - privileged -d - name buildkit moby / buildkit
صادرات BUILDKIT_HOST = docker-container: // buildkit 

برای ساختن تصویر خود ، ابتدا باید "buildkitd" را شروع کنیم:

 اجرا شود. /writellb/writellb.go |
buildctl ساخت
- زمینه محلی =.
- نوع خروجی = تصویر ، نام = docker.io / agbell / test ، push = true 

پرچم خروجی به ما اجازه می دهد تا مشخص کنیم BuildDit از چه باطنی استفاده می کند. ما از آن می خواهیم یک تصویر OCI ایجاد کند و آن را به سمت docker.io فشار دهید.

ℹ️ Real-World Usage

در ابزار دنیای واقعی ، ممکن است بخواهیم به صورت برنامه نویسی از «buildkitd» در حال اجرا مطمئن شویم و درخواست RPC را مستقیماً به آن بفرستیم و همچنین پیام های خطای دوستانه ارائه دهیم. برای اهداف آموزشی ، ما از همه اینها صرف نظر خواهیم کرد.

ما می توانیم آن را اینگونه اجرا کنیم:

> docker run -it --pull always agbell / test: latest / bin / sh 

و ما می توانیم سپس نتایج دستورات برنامه ای `COPY` و` RUN` را مشاهده کنید:

 / # cat built.txt
به صورت برنامه نویسی ساخته شده است
/ # ls README.md
README.md 

ما می رویم! مثال کد کامل می تواند یک مکان عالی برای ساخت تصویر docker برنامه ریزی شده شما باشد.

یک True Frontend for BuildKit

یک جلوی کامپایلر واقعی کارایی بیش از انتشار IR با کدگذاری شده ندارد. یک پیشخوان مناسب پرونده ها را می گیرد ، آنها را نشانه می گیرد ، تجزیه می کند ، یک درخت نحو تولید می کند و سپس آن درخت نحو را به نمای داخلی نشان می دهد. Mockerfiles مثالی از چنین پیشانی است:

  # نحو = r2d4 / مسخره
apiVersion: v1alpha1
تصاویر: - نام: نسخه ی نمایشی
از: ubuntu: 16.04
بسته:
نصب:
- حلقه
- گیت
- gcc
 

و از آنجا که Docker build از دستور "# نحو" پشتیبانی می کند ، حتی می توانیم Mockerfiles را مستقیماً با "docker build" بسازیم.

 docker build -f mockerfile.yaml 

برای پشتیبانی از دستور # syntax ، تمام آنچه لازم است این است که frontend را در یک تصویر docker قرار دهید که درخواست gRPC را در قالب صحیح بپذیرد ، آن تصویر را در جایی منتشر کنید. در آن مرحله ، هر کسی فقط با استفاده از "# syntax = yourimagename" می تواند از "docker build" frontend شما استفاده کند.

ساخت نمونه مثال خود ما برای "docker build"

ساخت توکنایزر و تجزیه کننده به عنوان سرویس gRPC فراتر از محدوده این مقاله است. اما می توانیم با استخراج و اصلاح پیشانی موجود ، پاهای خود را خیس کنیم. جدا کردن نمای استاندارد dockerfile از پروژه moby آسان است. من قطعات مربوطه را به صورت یک نسخه آزمایشی مستقل بیرون آورده ام. بیایید برخی تغییرات جزئی را در آن انجام دهیم و آزمایش کنیم.

تاکنون ، ما فقط از دستورات docker "FROM" ، "RUN" و "COPY" استفاده کرده ایم. در سطح سطح ، با دستورات بزرگ خود ، نحو Dockerfile شباهت زیادی به زبان برنامه نویسی INTERCAL دارد. اجازه دهید این دستورات را به معادل INTERCAL خود تغییر داده و قالب Ickfile خود را توسعه دهیم. ] ماژول های موجود در پرونده dockerfile ، تجزیه فایل ورودی را به چندین مرحله مجزا تقسیم می کنند که اجرای آن از این طریق جریان دارد:

برای این مقاله آموزشی ، ما فقط قصد داریم تغییرات پیش پا افتاده ای در قسمت پیش رو ایجاد کنیم. ما تمام مراحل را دست نخورده باقی خواهیم گذاشت و بر روی سفارشی سازی دستورات موجود به سلیقه خود تمرکز خواهیم کرد. برای این کار ، تمام کاری که ما باید انجام دهیم تغییر دستور `command.go`:

 بسته است

// ثابت های رشته های فرمان را تعریف کنید
ساختار (
کپی = "ذخیره"
اجرا = "لطفا"
از = "از_بیا"
...
)

و سپس می توانیم نتایج دستورات "STASH" و "PLEASE" خود را مشاهده کنیم:

 / # cat built.txt
جلو سفارشی ساخته شده است
/ # ls README.md
README.md 

من این تصویر را به dockerhub هل داده ام. هر کسی می تواند با اضافه کردن "# syntax = agbell / ick" به یک Dockerfile موجود ، ساخت تصاویر را با استفاده از قالب "ickfile" ما شروع کند. نیازی به نصب دستی نیست!

ab فعال کردن BuildKit

BuildKit به طور پیش فرض در Docker Desktop فعال است. به طور پیش فرض در نسخه فعلی Docker برای Linux فعال نیست (`نسخه 20.10.5`). برای دستورالعمل ساختن docker برای استفاده از BuildKit ، متغیر محیط زیر را تنظیم کنید `DOCKER_BUILDKIT = 1` یا تغییر موتور پیکربندی .

نتیجه گیری

ما یاد گرفته ایم که یک ساختار سه فاز وام گرفته شده از کامپایلرها در ساخت تصاویر ، این که نمایشی متوسط ​​به نام LLB کلید آن ساختار است. ما با توانمندی دانش ، دو جبهه برای ساخت تصاویر تولید کرده ایم.

این غواصی عمیق در جبهه ها هنوز چیزهای زیادی برای کشف باقی نمی گذارد. اگر می خواهید بیشتر بیاموزید ، پیشنهاد می کنم به دنبال کارگران BuildKit باشید. کارگران ساختمان واقعی را انجام می دهند و رمز و راز ساخت "docker buildx" ، و ساخت های چند معماری هستند. `docker build` همچنین از کارگران از راه دور و نصب های کش پشتیبانی می کند ، که هر دو می توانند منجر به ساخت سریعتر شوند.

Earthly برای ساختن نحو تکرار ساخت خود از BuildKit استفاده می کند. بدون آن ، نحو بسته بندی شده مانند Makefile ما امکان پذیر نیست. اگر می خواهید یک فرآیند saner CI داشته باشید ، باید آن را بررسی کنید.

همچنین در مورد نحوه کار کامپایلرهای مدرن چیزهای بیشتری برای کشف وجود دارد. کامپایلرهای مدرن اغلب دارای مراحل مختلف و بیش از یک نمایش متوسط ​​هستند و آنها اغلب قادر به انجام بهینه سازی های بسیار پیچیده هستند.