داکر رجیستری، مخزنی از ایمیج‌ها با ورژن‌های مختلف است که به افراد اجازه می‌ده با دسترسی به این مخزن، از ایمیج موردنظرشون استفاده کنن و کانتینرشون رو اجرا کنن. docker hub یکی از معروف‌ترین رجیستری‌های عمومی داکر برای آپلود و کار با ایمیج‌هاست. این رجیستری امکاناتی مثل خدمات میزبانی و رجیستری‌های عمومی و خصوصی رو در اختیار کاربران قرار می‌ده.

بیش‌‌تر افراد و شرکت‌ها برای استقرار نرم‌افزارها و سرویس‌هاشون ترجیح می‌دن رجیستری خصوصی خودشون رو داشته باشن. در این حالت به جای قرار دادن مستقیم کلاسترها در داکرهاب عمومی، می‌تونن کلاسترهای فرضی خودشون رو به یک داکر رجیستری خصوصی وصل کنن. 

حالا اگه از داکر رجیستری خودتون برای push کردن و انجام به روزرسانی‌های مداوم استفاده می‌کنین، احتمالاً متوجه شدین که به مرور زمان، فضای mount رجیستری، پر می‌شه و حالا وقت اون رسیده که بتونین رجیستری‌تون رو پاکسازی کنین. در این پست می‌خوایم نحوه‌ی انجام این کار رو بهتون آموزش بدیم. 

چرا باید پاکسازی رو انجام بدین؟

در حالی که نصب و میزبانی از داکر رجیستری بسیار ساده است و به راحتی با دستور docker یا با استفاده از  docker-compose-file قابل انجامه، مدیریت خوشه‌ی داکر و پاک کردن ایمیج‌های قدیمی و غیرضروری داکر، می‌تونه یک کار چالش برانگیز باشه، اما در وهله‌ی اول  چرا باید پاکسازی رو انجام بدین؟

داکر رجیستری، ایمیج‌های داکر رو روی فایل‌‌ سیستم ذخیره می‌کنه. پس همیشه باید فضای ذخیره‌سازی کافی روی میزبان داکر رجیستری وجود داشته باشه تا بتونین ایمیج‌های داکر رو ذخیره کنین و مطمئن باشین که همیشه برای ایمیج‌های جدید داکر، فضای ذخیره‌‌ی کافی وجود داره.

بسته به اندازه‌ی پروژه‌‌ ممکنه هر روز صدها ایمیج داکر ساخته بشه و به همراه سورس‌کدها در اختیار کاربر قرار بگیره. بعد از تست هر ایمیج، می‌شه اونو از رجیستری پاک یا حذف کرد. در حقیقت، می‌شه گفت اکثر ایمیج‌های داکر رو می‌شه حذف کرد، به جز ایمیج‌هایی که دارای tag های ویژه‌ای هستن، تگ‌هایی مثل develop ،master ،latest و version.

در این پست، نحوه‌ی تنظیم یک bash script رو برای خودکارسازی عملیات پاکسازی داکر رجیستری توضیح می‌دیم. حالا ببینیم برای تنظیم این script، چه کارهایی رو باید انجام بدین؟!

- اول باید داکر رجیستری رو به کمک docker-compose file زیر مستقر کنین. با نوشتن دستورات زیر، یک کانتینر داکر رجیستری، به صورت محلی شروع به کار می‌کنه: 

$> git clone git@github.com:wshihadeh/docker-registry.git
$> cd docker-registry
$> docker-compose up -d

- قدم بعدی اینه که برای این داکر رجیستری، script پاکسازی رو پیاده‌سازی کنین. 

قبل از نوشتن script پاکسازی، می‌خوایم بگیم که چطوری داکر رجیستری، ایمیج‌های داکر رو در فایل سیستم ذخیره می‌کنه، و چه ابزاری رو می‌تونین برای انجام پاکسازی در داکر استفاده کنین؟!

آشنایی با ساختار یک ایمیج‌ 

همون طور که در تصویر زیر می‌بینین، یک ایمیج داکر می‌تونه چند تا tag مختلف داشته باشه. علاوه بر این هر ایمیج، یک digest داره که مقداری منحصر به فرد برای اون ایمیج محسوب می‌شه. 

هر ایمیج توسط این digest شناسایی می‌شه. اگه دو ایمیج با نام یکسان myImage داشته باشین و اونا رو با master tag، به رجیستری Push کنین، در داخل داکر رجیستری، توسط دو digest مختلف شناسایی می‌شن.

یک tag هم از چند لایه تشکیل شده. به لیستی از لایه‌ها برای اون digest خاص، یک manifest گفته می‌شه.

علاوه بر این هر لایه با یک blob در ارتباطه. 

حالا لایه‌هایی وجود دارن که توسط هیچ کدوم از tag ها استفاده نمی‌شن، به این لایه‌ها، لایه‌های بلااستفاده گفته می‌شه. در این صورت blob های مربوطه هم بلااستفاده محسوب می‌شن. در نتیجه برای کم کردن فضای دیسک، می‌تونین این لایه‌ها رو حذف کنین. 

همون‌طور که می‌بینین، لایه‌ها بین manifest ها (tag ها) به اشتراک گذاشته می‌شن.

هر کدوم از manifest ها به لایه‌ای اشاره می‌کنن. حالا تا زمانی که یک manifest به لایه‌ای اشاره می‌کنه، نمی‌تونین اون لایه رو پاک‌/حذف کنین.

images,tags,layers and blobs

images, tags, layers, and blobs

ذخیره‌سازی ایمیج‌ها در فایل سیستم

حالا که با ساختار ایمیج‌ها آشنا شدین، می‌تونیم بگیم که داکر رجیستری، با استفاده از object‌های زیر، ایمیج‌ها رو در فایل سیستم ذخیره می‌کنه:

  • Blobs: لایه‌های واقعی که در کنار همدیگه، یک ایمیج داکر رو تشکیل می‌دن. blob‌ها فایل‌هایی هستن که فضای دیسک رو زیاد اشغال می‌کنن و توسط Garbage Collection، پاک‌سازی می‌شن.  
  • Image Manifest: متادیتایی که می‌تونه blob‌ها رو به ترتیب صحیح در ایمیج داکر به هم مرتبط کنه.
  • Manifest List: لیستی از Image manifest‌ها، برای یک یا چند سیستم عامل. (یک manifest list، از ایمیج‌هایی ایجاد شده که برای استفاده در سیستم‌های مختلف، دارای عملکردی یکسان هستند). این object رو می‌شه با یک tag مشخص کرد.
  • Tag:  این object به یک image) manifest یا list) اشاره می‌کنه.  

Garbage collection چیست و چه کاری انجام می‌ده؟

داکر رجیستری، دستوری به نام garbage-collect داره که می‌تونه عملیات پاکسازی در فایل سیستم داکر رجیستری رو انجام بده. با این حال، این دستور، blobها رو فقط زمانی از فایل سیستم حذف می‌کنه، که هیچ manifest ای، به اون‌ها اشاره نکرده باشه.

پس در زمان نوشتن script پاکسازی، باید مراقب حذف reference‌های مربوط به blob‌های قدیمی و بلااستفاده‌ باشین. برای ساده‌تر کردن این کار، script پاکسازی شما باید کارهای زیر رو به ترتیب انجام بده: 

۱.  tag‌های قدیمی داکر رو حذف کنه: تمام tag‌های قدیمی‌تر از ۳۰ روز، باید از رجیستری حذف بشن (می‌تونیم برای tag‌های ویژه، استثنا قائل بشیم).

Manifest .۲‌هایی که به هیچ وجه tag‌ گذاری نشدند رو، حذف کنه: این manifest‌ها فضای دیسک رو اشغال می‌کنن. 

۳. دستور garbage-collect رو اجرا کنه، تا blob‌ها و لایه‌های بلااستفاده، پاکسازی بشه.

حالا مشخص شد که چه چیزهایی باید در script پاکسازی، پیاده‌سازی بشه. قبل از این‌که بخوایم هر کدوم از مراحل بالا رو توضیح بدیم، بیاین بخش‌های مختلف script رو با هم بررسی کنیم.

آشنایی با دستورات مختلف در script پاکسازی

در تنظیم script پاکسازی، باید آیتم‌های زیر رو در نظر بگیرین (این تنظیمات می‌تونه از یک محیط به محیط دیگه متفاوت باشه):

REGISTRY_URL: این دستور، نشون‌دهنده‌ HTTP URL رجیستری است.

REGISTRY_DIR: این مسیر، نشون‌دهنده‌ پوشه‌ داده‌های رجیستری است.

MAX_AGE_SECONDS: این عدد نشون‌دهنده‌ حداکثر طول عمر یک tag در ثانیه است.

DOCKER_REGISTRY_NAME: در این دستور، نام کانتینر رجیستری داکر قرار می‌گیره. 

DOCKER_REGISTRY_CONFIG: در این دستور، مسیر config file رجیستری قرار می‌گیره.

DRY_RUN: این دستور یک flag بولین، برای پشتیبانی از اجرای dry-run پاکسازی است.

EXCLUDE_TAGS: این دستور لیست tag‌هایی رو که باید از پاکسازی خارج بشن، تعریف می‌کنه.

انجام مراحل پاکسازی

در بخش قبلی گفتیم که script پاکسازی، باید سه اقدام رو برای عملیات پاکسازی انجام بده. در ادامه هر کدوم از این مراحل رو با هم بررسی می‌کنیم:

۱. حذف tag‌های قدیمی داکر از فایل سیستم‌

اولین کاری که این script باید انجام بده، اینه که tag‌های قدیمی داکر رو از فایل سیستم حذف کنه. برای انجام این مرحله، باید بدونین که tag‌ها چطوری در فایل سیستم‌ ذخیره می‌شن.

- داکر رجیستری، ایمیج‌ها رو در مسیر زیر (در فایل سیستم) ذخیره می‌کنه:

${REGISTRY_DIR}/docker/registry/v2/repositories

مثلا، اگه داکر رجیستری، میزبان دو ایمیج داکر به اسم‌های nginx و redis و فضای نامی به اسم shih باشه، پوشه‌ی shih در مسیر بالا، با دو زیر پوشه به اسم‌های nginx و redis وجود خواهد داشت. 

-در فایل سیستم، tag‌های یک ایمیج، در پوشه‌ی ایمیج و در مسیر زیر ذخیره می‌شن:

${image_folder}/_manifests/tags/*

-تصویر بعدی ساختار فایل یک رجیستری رو نشون می‌ده که میزبان ایمیج mysql است:

 file structure for a registry

در زیر نمونه کدی برای آشنایی با  تابع remove_image_tags آورده شده است:

REPO_DIR=${REGISTRY_DIR}/docker/registry/v2/repositories
# Loop over all namespaces/repositories
for repo in $REPO_DIR/*; do
  # Loop over images in a namespace
  for image in $repo/*; do
    for tag_path in $image/_manifests/tags/*; do
      local tag=$(basename $tag_path)
      if ! [[ $tag =~ $EXCLUDE_TAGS ]]; then
        if (($tag_age > $MAX_AGE_SECONDS)); then
          rm -rf ${tag_path}
        fi
      fi
    done
  done
done

با توجه به نمونه کد بالا و اطلاعات گفته شده‌ در رابطه با ساختار فایل tag‌های داکر، می‌تونین مراحل زیر رو برای پاکسازی همه‌ی tagهای قدیمی دنبال کنین:

  • ایجاد Loop روی همه‌ی tagها در همه‌ی ایمیج‌ها.
  • برای tagهایی که در عملیات پاکسازی وجود دارن، کارهایی که در کد بالا گفته شده رو انجام بدین. 
  • اگر طول عمر tagها، بیش‌تر از $MAX_AGE_SECONDS باشه، tagها رو حذف کنین.

۲.حذف همه‌ manifestهای بدون tag 

قدم بعدی، حذف همه‌ی manifestهای بدون tag است. 

برای این‌که بتونین manifestهای بلااستفاده‌ای که هیچ tag‌ای بهشون اشاره نکرده رو پیدا کرده و حذف کنین، خیلی مهمه که ساختار رجیستری رو برای هر دو آیتم manifestها و tagها، به خوبی بررسی کنین. با این‌کار می‌تونین بدون پاک کردن یا خراب کردن اطلاعات رجیستری داکر، عمل پاکسازی رو انجام بدین.

-داکر رجیستری، برای هر کدوم از ایمیج‌های push شده به رجیستری، سه زیر پوشه ایجاد می‌کنه. مثلا، اگه شما در فضای نام shih، یک ایمیج mysql رو به رجیستری push کنین، در مسیر
{REGISTRY_DIR}/docker/registry/v2/repositories}$، ساختار فایل زیر رو مشاهده می‌کنین:

file structure

-همون‌طور که در تصویر بالا می‌بینین، رجیستری داکر سه زیر پوشه رو برای ایمیج mysql ایجاد کرده:

 layers _: این پوشه شامل زیرپوشه‌هایی برای هر کدوم از لایه‌ها در ایمیج داکر است. علاوه بر این مپ یا لینکی به digest مربوطه رو هم نشون می‌ده. 

manifests_: این پوشه، دو زیر پوشه‌ی tags و revisions رو ذخیره می‌کنه: 

  • دایرکتوری revisions، شامل تمام revisionهای ایمیج داکر می‌شه (حتی ایمیج‌هایی که هیچ tag‌ای بهشون اشاره نکرده باشه).
  • از طرف دیگه، در دایرکتوری tags، برای هر کدوم از tag‌های ایمیج، یک زیر پوشه وجود داره. 

 tag‌ها فقط لینکی به revision‌های ایمیج هستن. در نتیجه، داکر رجیستری، ایمیج‌های داکر رو بر اساس tag‌ها و revisions‌ها، pull می‌کنه. مثلا، هر دو دستور زیر برای pull کردن ایمیج‌های داکر معتبر هستن:

docker pull myregistry:5000/shih/mysql:5.9
docker pull myregistry:5000/shih/mysql@sha256:c3490dcf10ffb6530c

uploads_: دایرکتوری موقتی docker _uploads، موقع docker push مورد استفاده قرار می‌گیره. معمولاً این دایرکتوری باید خالی باشه.

تابع delete_mainifests_without_tags

در زیر نمونه کدی برای آشنایی با  تابع delete_mainifests_without_tags آورده شده است:

manifests_without_tags = diff(_manifests/revisions, _manifests/tags)
for manifest in $manifests_without_tags; do
  repos == manifest_repos(manifest)
  for repo in $repos
   delete_manifest(manifest)
  done
done

حالا با توجه به نمونه کد بالا، برای پیدا کردن همه‌ manifest‌ها و از بین بردن اون‌ها، می‌تونین مراحل زیر رو دنبال کنین:

  • پیدا کردن همه‌ manifests‌هایی که هیچ‌ tag‌ای ندارن:
    manifest‌هایی که در پوشه‌ revisions وجود دارن، اما tagهای مربوطه رو نشون نمی‌دن، همون manifestهای بدون tag در نظر گرفته شده و با خیال راحت حذف می‌شن.
  • بعد روی Manifest‌های پیدا شده، loop بزنین و کارهایی که در کد بالا گفته شده رو انجام بدین. 
  • تمام ریپازیتوری/فضای‌نام‌هایی که حاوی manifest مورد نظر هستن رو پیدا کنین.
  • manifest رو از هر کدوم از فضای‌نام‌های پیدا شده، حذف کنین.

-حالا در کد بالا، تابع Delete_manifest رو مشاهده می‌کنین که به دو روش قابل اجراست:

  • با ارسال یک درخواست DELETE API به داکر رجیستری، (مشابه آدرس زیر):
${REGISTRY_URL}/v2/${repo}/manifests/sha256:${hash}
  • یا با حذف مستقیم فایل‌ها از فایل سیستم (الگوریتم پیدا کردن tag‌های حاوی manifest، به یک مرحله‌ی اضافی نیاز داره)، که به کمک دستورهای زیر انجام می‌شه:
REPO_DIR=${REGISTRY_DIR}/docker/registry/v2/repositories


rm -r $REPO_DIR/${repo}/_manifests/tags/${tag}/index/sha256/${hash}
rm -r $REPO_DIR/${repo}/_manifests/revisions/sha256/${hash}

۳. اجرای دستور garbage-collect

در نهایت، بعد از حذف کامل tag های قدیمی و manifestهای بدون tag، اسکریپت، دستور garbage-collect رو درون کانتینر داکر رجیستری، اجرا می‌کنه. برای انجام این مرحله، از دستور زیر استفاده می‌شه:

docker exec -i $REGISTRY /bin/registry garbage-collect $CONFIG

نمونه script پاکسازی

در این پست با مراحل انجام پاکسازی در داکر رجیستری آشنا شدین. در ادامه مثال کاملی از script پاکسازی رو مشاهده می‌کنین که در این لینک گیت‌هاب، هم می‌تونین بهش دسترسی داشته باشین.  

کدهای زیر روش پیشنهادی گفته شده برای انجام پاک‌سازی داکر رجیستری رو به طور کامل نشون می‌ده. اینscript با یک‌سری تنظیمات کلی شروع می‌شه که می‌تونه برای مطابقت پیدا کردن با نیازهای داکر رجیستری‌های مختلف، تغییر پیدا کنه. همچنین این اسکریپت شامل همه‌ی فانکشن‌ها و ابزارهای لازم برای انجام پاکسازی می‌شه و از اجرای dry-run هم پشتیبانی می‌کنه. 

#!/bin/bash -e
#
# automatically Cleanup old docker images and tags
#

# Configs

: ${REGISTRY_URL:=http://127.0.0.1:5000}
: ${REGISTRY_DIR:=./data}
: ${MAX_AGE_SECONDS:=$((30 * 24 * 3600))} # 30 days
: ${DOCKER_REGISTRY_NAME:=registry_web}
: ${DOCKER_REGISTRY_CONFIG:=/etc/docker/registry/config.yml}
: ${DRY_RUN:=false}

EXCLUDE_TAGS="^(\*|master|develop|latest|stable|(v|[0-9]\.)[0-9]+(\.[0-9]+)*)$"
REPO_DIR=${REGISTRY_DIR}/docker/registry/v2/repositories

# In doubt fall back to dry mode
[[ $DRY_RUN != "false" ]] && DRY_RUN=true


_curl() {
  curl -fsS ${CURL_INSECURE_ARG} "$@"
}

# parse yyyymmddHHMMSS string into unix timestamp
datetime_to_timestamp() {
  echo "$1" | awk 'BEGIN {OFS=""} { print substr($1,0,4), "-", substr($1,5,2), "-", substr($1,7,2), " ", substr($1,9,2), ":", substr($1,11,2), ":", substr($1,13,2) }'
}

run_garbage() {
  echo "Running garbage-collect command ..."
  local dry_run_arg=
  $DRY_RUN && dry_run_arg=--dry-run
  docker exec -i $DOCKER_REGISTRY_NAME /bin/registry garbage-collect $DOCKER_REGISTRY_CONFIG $dry_run_arg > /dev/null
}

remove_old_tags() {
  echo "Start Remove Old Tags ..."
  local repo_path image_path
  TAG_COUNT=0

  for repo_path in $REPO_DIR/*; do
    local repo=$(basename $repo_path)
    echo "Current repo: $repo"

    for image_path in $repo_path/*; do
      local image=$(basename $image_path)
      remove_image_tags "$repo" "$image"
    done
    echo
  done
}

remove_image_tags() {
  local repo=$1
  local image=$2

  echo "- Cleanup image $repo/$image"

  local tag_path
  for tag_path in $REPO_DIR/$repo/$image/_manifests/tags/*; do
    local tag=$(basename $tag_path)

    # Do not clenup execluded tags
    if ! [[ $tag =~ $EXCLUDE_TAGS ]]; then
      # get timestamp from tag folder
      local timestamp=$(date -d @$(stat -c %Y $tag_path) +%Y%m%d%H%M%S)

      # parse yyyymmddHHMMSS string into unix timestamp
      timestamp=$(date -d "$(datetime_to_timestamp "$timestamp")" +%s)
      local now=$(date +%s)

      # check if the tag is old enough to delete
      if ((now - timestamp > $MAX_AGE_SECONDS)); then
        if $DRY_RUN; then
          echo "To be Deleted >>  rm -rf ${tag_path}"
        else
          echo "Deleted: $tag"
          TAG_COUNT=$((TAG_COUNT+1))
          rm -rf ${tag_path}
        fi
      fi
    fi
  done
}

delete_manifests_without_tags(){
  cd ${REPO_DIR}

  local manifests_without_tags=$(
    comm -23 <(
      find . -type f -path "./*/*/_manifests/revisions/sha256/*/link" |
      grep -v "\/signatures\/sha256\/" |
      awk -F/ '{print $(NF-1)}' |
      sort -u
    ) <(
      find . -type f -path './*/*/_manifests/tags/*/current/link' |
      xargs sed 's/^sha256://' |
      sort -u
    )
  )

  CURRENT_COUNT=0
  FAILED_COUNT=0
  TOTAL_COUNT=$(echo ${manifests_without_tags} | wc -w | tr -d ' ')

  if [ ${TOTAL_COUNT} -gt 0 ]; then
    echo -n "Found ${TOTAL_COUNT} manifests. "
    if $DRY_RUN; then
      echo "Run without --dry-run to clean up"
    else
      echo "Starting to clean up"
    fi

    local manifest
    for manifest in ${manifests_without_tags}; do
      local repos=$(
        find . -path "./*/*/_manifests/revisions/sha256/${manifest}/link" |
        sed 's#^./\(.*\)/_manifest.*$#\1#'
      )

      for repo in $repos; do
        if $DRY_RUN; then
          echo "Would have run: _curl -X DELETE ${REGISTRY_URL}/v2/${repo}/manifests/sha256:${manifest} > /dev/null"
        else
          if _curl -X DELETE ${REGISTRY_URL}/v2/${repo}/manifests/sha256:${manifest} > /dev/null; then
            CURRENT_COUNT=$((CURRENT_COUNT+1))
          else
            FAILED_COUNT=$((FAILED_COUNT+1))
          fi
        fi
      done
    done
  else
    echo "No manifests without tags found. Nothing to do."
  fi
}

print_summary(){
  if $DRY_RUN; then
    echo "DRY_RUN over"
  else
    echo "Job done"
    echo "Removed ${TAG_COUNT} tags."
    echo "Removed ${CURRENT_COUNT} of ${TOTAL_COUNT} manifests."

    [ ${FAILED_COUNT} -gt 0 ] && echo "${FAILED_COUNT} manifests failed. Check for curl errors in the output above."

    echo "Disk usage before and after:"
    echo "${DF_BEFORE}"
    echo
    echo "${DF_AFTER}"
  fi
}

start_cleanup(){
  $DRY_RUN && echo "Running in dry-run mode. Will not make any changes"

  #Check registry dir
  if [ ! -d ${REPO_DIR} ]; then
    echo "REPO_DIR doesn't exist. REPO_DIR=${REPO_DIR}"
    exit 1
  fi

  #correct registry url (remove trailing slash)
  REGISTRY_URL=${REGISTRY_URL%/}

  #run curl with --insecure?
  [ "$CURL_INSECURE" == "true" ] && CURL_INSECURE_ARG=--insecure

  #verify registry url
  if ! _curl -m 3 ${REGISTRY_URL}/v2/ > /dev/null; then
    echo "Could not contact registry at ${REGISTRY_URL} - quitting"
    exit 1
  fi

  DF_BEFORE=$(df -Ph)

  remove_old_tags
  delete_manifests_without_tags
  run_garbage

  DF_AFTER=$(df -Ph)
  print_summary
}

start_cleanup